[chronojump-server] Authentication implemented for Chronojump Server. Now the results can be selected and removed
- From: Marcos Venteo Garcia <mventeo src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [chronojump-server] Authentication implemented for Chronojump Server. Now the results can be selected and removed
- Date: Tue, 29 Aug 2017 19:08:24 +0000 (UTC)
commit 27e1fce24f70a2afbf4dd775b80cbe32f82fa64a
Author: Marcos Venteo <mventeo gmail com>
Date: Tue Aug 29 21:07:52 2017 +0200
Authentication implemented for Chronojump Server. Now the results can be selected and removed
chronojumpserver/__init__.py | 14 ++++
chronojumpserver/api.py | 37 +++++++++-
chronojumpserver/forms.py | 6 ++
chronojumpserver/js/players.js | 3 +-
chronojumpserver/js/results.js | 90 +++++++++++++++++++++++---
chronojumpserver/js/sprints.js | 2 +-
chronojumpserver/models.py | 42 +++++++++++-
chronojumpserver/static/style.css | 22 ++++++
chronojumpserver/templates/_formhelpers.html | 3 +
chronojumpserver/templates/index.html | 13 +++-
chronojumpserver/templates/layout.html | 4 +-
chronojumpserver/templates/login.html | 32 +++++++++
chronojumpserver/templates/results.html | 2 +-
chronojumpserver/templates/sprints.html | 2 +-
chronojumpserver/views.py | 75 ++++++++++++++++++++-
requirements.txt | 1 +
16 files changed, 320 insertions(+), 28 deletions(-)
---
diff --git a/chronojumpserver/__init__.py b/chronojumpserver/__init__.py
index 83f4d5f..b8e8e9e 100755
--- a/chronojumpserver/__init__.py
+++ b/chronojumpserver/__init__.py
@@ -5,6 +5,8 @@ Chronojump Server Main application...
"""
import click
from flask import Flask, send_from_directory
+from flask_login import LoginManager
+
import ConfigParser
import os
@@ -15,6 +17,10 @@ config.read('/etc/chronojump.conf')
app = Flask(__name__)
+login_manager = LoginManager()
+login_manager.init_app(app)
+login_manager.login_view = "login"
+
# set the secret key. keep this really secret:
app.config['MYSQL_DATABASE_USER'] = config.get("db", "user")
app.config['MYSQL_DATABASE_PASS'] = config.get("db", "password")
@@ -68,6 +74,8 @@ def populate_test_data():
TestData().create_exercises()
+
+
# IMPORTANT: imports always at the end of the module, after app is created
# Following design patterns from ....
import chronojumpserver.views
@@ -83,3 +91,9 @@ def shutdown_session(exception=None):
from chronojumpserver.database import init_db
init_db()
+
+from chronojumpserver.models import User
+# To retrieve the user from session
+@login_manager.user_loader
+def load_user(user_id):
+ return User.get(user_id)
diff --git a/chronojumpserver/api.py b/chronojumpserver/api.py
index b1c7b92..b2e2278 100755
--- a/chronojumpserver/api.py
+++ b/chronojumpserver/api.py
@@ -8,6 +8,7 @@ from chronojumpserver.database import db_session
from chronojumpserver.models import Person, ResultEncoder, Station, Task
from chronojumpserver.models import ResultSprint, Exercise, RFIDHistory
from flask import jsonify, request
+from flask_login import login_required
from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError
from time import sleep
import os
@@ -80,7 +81,21 @@ def register_rfid():
msg = ""
# From original crhonojump-flask source read RFID
rfidFile = '/tmp/chronojump_rfid.txt'
- if os.environ.get('NO_MONO', False):
+ # Check if we are in development, to read directly the value pass manually
+ # instead of the mono executable
+
+ try:
+ no_mono = int(os.environ['NO_MONO'])
+ except KeyError:
+ # No development environment
+ no_mono = 0
+ pass
+ except TypeError:
+ # Invalid value passed to no_mono
+ no_mono = 0
+ pass
+ #print "DEBUG: NO_MONO = %d" % int(no_mono)
+ if no_mono == 1:
sleep(4)
else:
if os.access(rfidFile, os.W_OK):
@@ -89,14 +104,15 @@ def register_rfid():
mono_path = os.path.join(app.config['MONO_PATH'], 'RFID.exe')
rfidReadedStatus = subprocess.call(
"mono %s" % mono_path, shell=True)
-
- print rfidReadedStatus
+ # Debug: Print output status
+ #print rfidReadedStatus
# Read the RFID read
try:
with open(rfidFile) as f:
rfid = f.read()
- except:
+ except Exception, e:
+ print "ERROR: %s" % str(e)
rfid = ""
if rfid:
@@ -284,3 +300,16 @@ def active_tasks_in_station():
players_stations.append({ 'id' : player.id, 'stations': stations})
return jsonify(players_stations=players_stations)
+
+
+@app.route('/api/v1/results/delete', methods=['GET', 'POST'])
+def remove_results():
+ if request.method == "POST":
+ results = request.json
+ for _resultId in results:
+ resultId = int(_resultId)
+ r = ResultEncoder.getById(resultId)
+ db_session.delete(r)
+ db_session.commit()
+
+ return jsonify(msg="Done")
diff --git a/chronojumpserver/forms.py b/chronojumpserver/forms.py
index 17be33d..e116e94 100755
--- a/chronojumpserver/forms.py
+++ b/chronojumpserver/forms.py
@@ -12,3 +12,9 @@ class PersonForm(FlaskForm):
weight=FloatField('Pes', validators=[DataRequired('El camp pes és obligatori.'.decode('utf-8'))])
photo=FileField('Foto del Jugador')
rfid = StringField('RFID', [DataRequired('S\'ha de registrar un RFID per jugador.'.decode('utf-8')),
validators.Length(max=23)])
+
+
+class LoginForm(FlaskForm):
+ username = StringField('Usuari', validators=[DataRequired('El usuari és obligatori!'.decode('utf-8'))])
+ password = PasswordField('Contrasenya', validators=[DataRequired('La Contrasenya és
obligatoria!'.decode('utf-8'))])
+ pass
diff --git a/chronojumpserver/js/players.js b/chronojumpserver/js/players.js
index 7418b67..0db4bb9 100755
--- a/chronojumpserver/js/players.js
+++ b/chronojumpserver/js/players.js
@@ -198,7 +198,8 @@ $(document).ready(function() {
data: null,
title: "",
orderable: false,
- render: function(val) {
+ className: 'colAlignRight',
+ render: function(val) {
return "<button class='btn btn-primary'>Afegeix</button>"
}
}
diff --git a/chronojumpserver/js/results.js b/chronojumpserver/js/results.js
index b42dc96..984eac0 100755
--- a/chronojumpserver/js/results.js
+++ b/chronojumpserver/js/results.js
@@ -30,6 +30,12 @@
$(document).ready(function() {
+ var refreshIntervalId;
+
+ var COLUMN_PLAYER = 4;
+ var COLUMN_STATION = 5;
+ var COLUMN_EXERCISE = 6;
+
// Initialize datatable with results
$("input[name='filterByDayOptions']").on('click', function() {
var table = $('#results').DataTable();
@@ -68,6 +74,13 @@ $(document).ready(function() {
visible: false
},
{
+ type: "html",
+ orderable:false,
+ render: function(value, type, row) {
+ return '<input class="deleteCheckbox" type="checkbox" data-result-id="' + row.id + '"/>';
+ }
+ },
+ {
type: "customdate",
title: "Data",
data: "dt",
@@ -178,9 +191,44 @@ $(document).ready(function() {
render: $.fn.dataTable.render.number('', ',', 2)
}
],
- "dom": '<"resultsFilter">fBrtip',
+ "dom": "<'row'<'resultsFilter'>><'row'<'col-sm-6'B><'col-sm-6'f>>rtip",
buttons: [
- { extend: 'csv', text: 'Exportar Resultats', className: "btn btn-primary", fieldSeparator: ";"}
+ { extend: 'csv', text: 'Exportar Resultats', className: "btn btn-primary", fieldSeparator:
" "},
+ { text: 'Eliminar registres',
+ className: "btn btn-danger btnDeleteResults",
+ enabled: false,
+ action: function(e, dt, node, config) {
+ // Ask for confirmation
+ var total = $('.deleteCheckbox:checked').length;
+ if (total == 1) {
+ var r = confirm("Estàs segur de que vols esborrar aquest resultat?");
+ } else {
+ var r = confirm("Estàs segur de que vols esborrar aquests resultats?");
+ }
+ if (r == true) {
+ var results = [];
+ $.each($('.deleteCheckbox:checked'), function(index, value) {
+ var v = $(value);
+ console.log(index + ":" + v.attr('data-result-id'));
+ results.push(v.attr('data-result-id'));
+
+ });
+ var url = '/api/v1/results/delete';
+ $.ajax({
+ url: url,
+ type: "POST",
+ contentType: 'application/json;charset=UTF-8',
+ data: JSON.stringify(results)
+ }).done(function(msg){
+ // Results have been deleted. Refresh table and enable again the interval
+ console.log(msg);
+ $('.btnDeleteResults').addClass('disabled');
+ table.ajax.reload(null, false);
+ refreshIntervalId = refreshIntervalTrigger();
+ console.log("Interval refresh is enabled with id " + refreshIntervalId);
+ })
+ }
+ }}
],
"pageLength": 15,
"order": [
@@ -214,7 +262,7 @@ $(document).ready(function() {
var column = this;
var idx = column.index();
- if (idx == 3) {
+ if (idx == COLUMN_PLAYER) {
var select = $('<select class="form-control"><option value="">Tots els jugadors</option></select>')
.appendTo($('#filterByPlayer'))
.on('change', function() {
@@ -234,7 +282,7 @@ $(document).ready(function() {
column.data().unique().sort().each(function(d, j) {
select.append('<option value="' + d + '">' + d + '</option>')
});
- } else if (idx == 4) {
+ } else if (idx == COLUMN_STATION) {
var select = $('<select class="form-control"><option value="">Totes les
estacions</option></select>')
.appendTo($('#filterByStation'))
.on('change', function() {
@@ -251,7 +299,7 @@ $(document).ready(function() {
column.data().unique().sort().each(function(d, j) {
select.append('<option value="' + d + '">' + d + '</option>')
});
- } else if (idx == 5) {
+ } else if (idx == COLUMN_EXERCISE) {
var select = $('<select class="form-control"><option value="">Tots els
exercicis</option></select>')
.appendTo($('#filterByExercice'))
.on('change', function() {
@@ -270,13 +318,37 @@ $(document).ready(function() {
});
}
});
+
+ // Called every time a delete checkbox is changed
+ $('.deleteCheckbox').on('change', function() {
+ var totalChecked = $('.deleteCheckbox:checked').length;
+ if (totalChecked > 0) {
+ // Disable the refresh interval
+ if (refreshIntervalId != -1) {
+ clearInterval(refreshIntervalId);
+ console.log("Interval refresh is disabled");
+ refreshIntervalId = -1;
+ $('.btnDeleteResults').removeClass('disabled');
+ }
+ } else {
+ // Enable again the refresh interval
+ $('.btnDeleteResults').addClass('disabled');
+ refreshIntervalId = refreshIntervalTrigger();
+ console.log("Interval refresh is enabled with id " + refreshIntervalId);
+ }
+ });
+
}
});
- setInterval(function() {
- /* Set the interval for refresh */
- table.ajax.reload(null, false);
- }, 15000);
+ function refreshIntervalTrigger() {
+ return setInterval(function() {
+ /* Set the interval for refresh */
+ table.ajax.reload(null, false);
+ }, 15000);
+ };
+ refreshIntervalId = refreshIntervalTrigger();
+ console.log("Interval refresh is enabled with id " + refreshIntervalId);
});
diff --git a/chronojumpserver/js/sprints.js b/chronojumpserver/js/sprints.js
old mode 100644
new mode 100755
index 79e3e4e..8fc0749
--- a/chronojumpserver/js/sprints.js
+++ b/chronojumpserver/js/sprints.js
@@ -171,7 +171,7 @@ $(document).ready(function() {
render: $.fn.dataTable.render.number('', ',', 2)
}
],
- "dom": '<"resultsFilter">fBrtip',
+ "dom": "<'row'<'resultsFilter'>><'row'<'col-sm-6'B><'col-sm-6'f>>rtip",
buttons: [
{ extend: 'csv', text: 'Exportar Sprints', className: "btn btn-primary", fieldSeparator: ";"}
],
diff --git a/chronojumpserver/models.py b/chronojumpserver/models.py
index 09c776e..a2744dd 100755
--- a/chronojumpserver/models.py
+++ b/chronojumpserver/models.py
@@ -4,7 +4,7 @@ from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.types import Boolean
from sqlalchemy.orm import relationship
-
+from flask_login import UserMixin
class HelperMixin(object):
@@ -387,6 +387,11 @@ class ResultEncoder(Base):
"""Representation od the object."""
return '<ResultEncoder %d>' % (self.id)
+ @classmethod
+ def getById(cls, object_id):
+ o = cls.query.filter(cls.id == object_id).first()
+ return o
+
class ResultSprint(Base):
__tablename__ = 'resultSprint'
@@ -444,5 +449,38 @@ class ResultSprint(Base):
}
def __repr__(self):
- """Representation od the object."""
+ """Representation of the object."""
return '<ResultSprint %d>' % (self.id)
+
+
+
+class User(UserMixin, Base):
+ __tablename__ = 'user'
+
+ id = Column('id', Integer, primary_key=True)
+ username = Column('username', String(50))
+ password = Column('password', String(50))
+ user_type = Column('user_type', String(50))
+
+ def __init__(self, username, password, user_type='default'):
+ self.username = username
+ self.password = password
+ self.user_type = user_type
+
+ @property
+ def serialize(self):
+ """Object serialization (Json)."""
+ return {
+ 'id': self.id,
+ 'username': self.username,
+ 'password': self.password,
+ 'user_type': self.user_type
+ }
+
+ @classmethod
+ def get(cls, user_id):
+ return User.query.filter(User.id == user_id).first()
+
+ def __repr__(self):
+ """Representation of the object."""
+ return '<User %d>' % (self.id)
diff --git a/chronojumpserver/static/style.css b/chronojumpserver/static/style.css
index 44289d5..d163fb7 100755
--- a/chronojumpserver/static/style.css
+++ b/chronojumpserver/static/style.css
@@ -19,6 +19,18 @@ body.home {
margin-top: 0px;
}
+body.login {
+ background-color: #e3e3e3;
+}
+
+.btn-primary {
+ background-color: #0f2351;
+}
+
+.pagination>.active {
+ background-color: #0f2351;
+}
+
.navbar-fixed-top {
background-color: #0f2351;
height: 80px;
@@ -64,6 +76,16 @@ body.home {
color: blue;
}
+.dt-buttons > a.btn {
+ margin-right: 4px;
+ border-radius: 4px;
+
+}
+
+.colAlignRight {
+ text-align: right;
+}
+
#players td {
vertical-align: middle;
}
diff --git a/chronojumpserver/templates/_formhelpers.html b/chronojumpserver/templates/_formhelpers.html
index f701814..b2d8d7e 100755
--- a/chronojumpserver/templates/_formhelpers.html
+++ b/chronojumpserver/templates/_formhelpers.html
@@ -7,6 +7,9 @@
{% elif field.type == "StringField" %}
<label for="{{field.name}}">{{field.label}}</label>
<input class="form-control" type="text" value="{{field.data}}" name="{{field.name}}"
id="{{field.name}}"/>
+ {% elif field.type == "PasswordField" %}
+ <label for="{{field.name}}">{{field.label}}</label>
+ <input class="form-control" type="password" value="{{field.data}}" name="{{field.name}}"
id="{{field.name}}"/>
{% elif field.type == "FileField" %}
<div class="text-center">
{% if field.data %}
diff --git a/chronojumpserver/templates/index.html b/chronojumpserver/templates/index.html
index 7a06130..5aff6c0 100755
--- a/chronojumpserver/templates/index.html
+++ b/chronojumpserver/templates/index.html
@@ -17,11 +17,13 @@
{% endblock %}
{% block content %}
-<div class="row" style="margin-top:100px">
- <div class="col-md-6">
-
+<div class="row" style="margin-top:20px">
+ <div class="col-md-12">
+ {% if current_user.is_authenticated %}
+ <!--<div class="alert alert-success">Hola {{ current_user.username }}!</div>-->
+ {% endif %}
</div>
- <div class="col-md-6">
+ <div class="col-md-offset-6 col-md-6">
<h2 class="text-center text-uppercase">Opcions</h2>
</div>
<div class="col-md-6" >
@@ -32,6 +34,9 @@
<a class="btn btn-primary btn-lg btn-block" href="{{ url_for('show_sprints')}}">Sprints</a>
<a class="btn btn-primary btn-lg btn-block" href="{{ url_for('show_players')}}">Llistat jugadors</a>
<a class="btn btn-primary btn-lg btn-block" href="{{
url_for('show_stations')}}">Estacions/Exercisis</a>
+ {% if current_user.is_authenticated %}
+ <a class="btn btn-primary btn-lg btn-block" href="{{ url_for('logout')}}">Sortir i tancar
sessió</a>
+ {% endif %}
</div>
</div>
{% endblock %}
diff --git a/chronojumpserver/templates/layout.html b/chronojumpserver/templates/layout.html
index 437b9b7..eeaa820 100755
--- a/chronojumpserver/templates/layout.html
+++ b/chronojumpserver/templates/layout.html
@@ -34,7 +34,9 @@
<li><a href="{{ url_for('show_sprints')}}">Sprints</a></li>
<li><a href="{{ url_for('show_players')}}">Llistat Jugadors</a></li>
<li><a href="{{ url_for('show_stations')}}">Estacions/Exercisis</a></li>
- </li>
+ {% if current_user.is_authenticated %}
+ <li><a href="{{ url_for('logout')}}">Tancar sessió</a></li>
+ {% endif %}
</ul>
</div>
</div>
diff --git a/chronojumpserver/templates/login.html b/chronojumpserver/templates/login.html
new file mode 100755
index 0000000..07c7c97
--- /dev/null
+++ b/chronojumpserver/templates/login.html
@@ -0,0 +1,32 @@
+{% from "_formhelpers.html" import render_field %}
+{% extends 'layout.html' %}
+
+
+{% block nav %}{% endblock %}
+
+{% block body_class %}login{% endblock %}
+
+{% block content %}
+ <div class="container">
+ <div class="row">
+ <div class="col-sm-offset-3 col-sm-6" style="margin-top:50px;">
+ <div class="" style="background-color:#fff;;border-radius:15px;padding:10px">
+ <div style=" margin-bottom: 20px;background-color: #0f2351;padding:-10px">
+ <img class="img-responsive" src="{{ url_for('static',
filename='images/chronojump-logo.png')}}" height="180px">
+ </div>
+ {% if error_msg %}
+ <div class="alert alert-danger text-center">Error: {{error_msg}}</div>
+ {% endif %}
+ <form method="post" enctype="multipart/form-data">
+ {{ form.csrf_token }}
+
+ <p class="text-center">Introdueix el teu usuari i contrasenya</p>
+ {{ render_field(form.username)}}
+ {{ render_field(form.password)}}
+ <button class="btn btn-primary btn-block" type="submit">Accedir</button>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+{% endblock %}
diff --git a/chronojumpserver/templates/results.html b/chronojumpserver/templates/results.html
index ce5fb85..edeb9d3 100755
--- a/chronojumpserver/templates/results.html
+++ b/chronojumpserver/templates/results.html
@@ -12,7 +12,7 @@
<h1>Resultats</h1>
</div>
-<div id="resultsFilter" class="row pull-left">
+<div id="resultsFilter" class="row">
<div class="col-sm-6">
<label class="radio-inline dayfilter" style="margin-top:5px">
<input type="radio" name="filterByDayOptions" id="filterByDay1" value="1" >1d
diff --git a/chronojumpserver/templates/sprints.html b/chronojumpserver/templates/sprints.html
old mode 100644
new mode 100755
index d8cbf1b..89ec5e5
--- a/chronojumpserver/templates/sprints.html
+++ b/chronojumpserver/templates/sprints.html
@@ -11,7 +11,7 @@
<div class="page-header">
<h1>Sprints</h1>
</div>
-<div id="resultsFilter" class="row pull-left">
+<div id="resultsFilter" class="row">
<div class="col-sm-8">
<label class="radio-inline dayfilter" style="margin-top:5px">
<input type="radio" name="filterByDayOptions" id="filterByDay1" value="1" >1d
diff --git a/chronojumpserver/views.py b/chronojumpserver/views.py
index ca108f9..792b623 100755
--- a/chronojumpserver/views.py
+++ b/chronojumpserver/views.py
@@ -1,17 +1,29 @@
# -*- coding: utf-8 -*-
"""Chronojump Server views controller."""
from chronojumpserver import app
-from flask import render_template, request, redirect, url_for
+from flask import render_template, request, redirect, url_for, abort, flash
+from urlparse import urlparse, urljoin
from flask_wtf.file import FileField
-from chronojumpserver.models import Person, Station, RFIDHistory
-from chronojumpserver.forms import PersonForm
-
+from chronojumpserver.models import Person, Station, RFIDHistory, User
+from chronojumpserver.forms import PersonForm, LoginForm
+from flask_login import login_required, login_user, logout_user
from chronojumpserver.database import db_session
import os
from time import time
+def is_safe_url(target):
+ """
+ Snippet to check if the url is safe, specially when coming
+ from login action.
+ """
+ ref_url = urlparse(request.host_url)
+ test_url = urlparse(urljoin(request.host_url, target))
+ return test_url.scheme in ('http', 'https') and \
+ ref_url.netloc == test_url.netloc
+
@app.route('/')
+@login_required
def index():
"""Chronojump Server Home page."""
return render_template('index.html')
@@ -26,11 +38,13 @@ def airport():
return render_template('airport.html', stations=stations, players=players)
@app.route('/results')
+@login_required
def show_results():
"""Show results view."""
return render_template('results.html')
@app.route('/sprints')
+@login_required
def show_sprints():
"""Show sprints view."""
return render_template('sprints.html')
@@ -50,6 +64,7 @@ def show_players():
@app.route('/stations')
+@login_required
def show_stations():
"""Show Stations and Exercises."""
stations = []
@@ -89,6 +104,7 @@ def _update_player_photo(player_id, photo, previous_imageName):
@app.route('/player/<player_id>', methods=['GET', 'POST'])
+@login_required
def player_detail(player_id):
"""Show players detail."""
has_errors = False
@@ -145,6 +161,7 @@ def player_detail(player_id):
@app.route('/player/add', methods=['GET', 'POST'])
+@login_required
def add_player():
"""Show form to add a new player."""
has_errors = False
@@ -187,3 +204,53 @@ def add_player():
return render_template('player_detail.html', form=form, msg=msg,
has_errors=has_errors)
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+ # Here we use a class of some kind to represent and validate our
+ # client-side form data. For example, WTForms is a library that will
+ # handle this for us, and we use a custom LoginForm to validate.
+ form = LoginForm()
+ if request.method == "GET":
+ form.username.data = ""
+ form.password.data = ""
+ if form.validate_on_submit():
+ import md5
+ username = form.username.data
+ password = md5.md5(form.password.data).hexdigest()
+ print password
+ user = User.query.filter(User.username == username).first()
+ if user:
+ print "DEBUG: User %s found" % user.username
+ print user.password
+ if password == user.password:
+ print "DEBUG: Passwords match. Allow login"
+ # Login and validate the user.
+ # user should be an instance of your `User` class
+ login_user(user)
+
+ flash('Logged in successfully.')
+ next = request.args.get('next')
+ # is_safe_url should check if the url is safe for redirects.
+ # See http://flask.pocoo.org/snippets/62/ for an example.
+ if not is_safe_url(next):
+ return abort(400)
+
+ return redirect(next or url_for('index'))
+ else:
+ # Invalid password
+ error_msg = u"Contrasenya invàlida"
+ return render_template('login.html', form=form, error_msg=error_msg)
+ else:
+ # Invalid user
+ error_msg = u"El usuari %s no existeix!" % username
+ return render_template('login.html', form=form, error_msg=error_msg)
+
+
+ return render_template('login.html', form=form)
+
+@app.route("/logout")
+@login_required
+def logout():
+ logout_user()
+ return redirect(url_for('login'))
diff --git a/requirements.txt b/requirements.txt
index 69ebcd3..4ae1261 100755
--- a/requirements.txt
+++ b/requirements.txt
@@ -20,3 +20,4 @@ SQLAlchemy==1.1.10
virtualenv==15.1.0
Werkzeug==0.9.6
Flask-WTF==0.14.2
+flask-login
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]