Add documentation.
This commit is contained in:
parent
171b5fe9c4
commit
4976cd8b93
|
@ -2,6 +2,16 @@ from fgs.model import *
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
def flatten(points):
|
def flatten(points):
|
||||||
|
"""
|
||||||
|
This function is used to convert
|
||||||
|
a list of points of arbitrary length into
|
||||||
|
a BoundingBox, which only has a fixed number
|
||||||
|
of fields (all of which are hardcoded variable names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# To convert an index to a variable namne, we define
|
||||||
|
# a list of setters, where the 1st setter changes the
|
||||||
|
# coord0 field, and so on.
|
||||||
def set0x(b, v): b.coord0x = v
|
def set0x(b, v): b.coord0x = v
|
||||||
def set1x(b, v): b.coord1x = v
|
def set1x(b, v): b.coord1x = v
|
||||||
def set2x(b, v): b.coord2x = v
|
def set2x(b, v): b.coord2x = v
|
||||||
|
@ -35,6 +45,11 @@ def flatten(points):
|
||||||
return boundary
|
return boundary
|
||||||
|
|
||||||
def unflatten(boundary):
|
def unflatten(boundary):
|
||||||
|
"""
|
||||||
|
This function is used to convert a grazing boundary,
|
||||||
|
which has 10 hardcoded vertex fields, to
|
||||||
|
an arbitrary list of points.
|
||||||
|
"""
|
||||||
if boundary is None: return []
|
if boundary is None: return []
|
||||||
|
|
||||||
getters_x = [
|
getters_x = [
|
||||||
|
|
|
@ -5,11 +5,20 @@ from .model import User
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
def get_jwt(self):
|
def get_jwt(self):
|
||||||
|
"""
|
||||||
|
Extension method for the User class to
|
||||||
|
compute the user's JSON Web Token
|
||||||
|
"""
|
||||||
return jwt.encode({'id': self.id}, app.secret_key, algorithm='HS256')
|
return jwt.encode({'id': self.id}, app.secret_key, algorithm='HS256')
|
||||||
|
|
||||||
User.get_jwt = get_jwt
|
User.get_jwt = get_jwt
|
||||||
|
|
||||||
def jwt_required(f):
|
def jwt_required(f):
|
||||||
|
"""
|
||||||
|
Decorator for routes in the views module,
|
||||||
|
returning a bad request error if the "Authorization"
|
||||||
|
header is not set with a valid authentication token.
|
||||||
|
"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if 'Authorization' not in request.headers:
|
if 'Authorization' not in request.headers:
|
||||||
|
|
64
fgs/model.py
64
fgs/model.py
|
@ -2,6 +2,15 @@ from . import db
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
class Collar(db.Model):
|
class Collar(db.Model):
|
||||||
|
"""
|
||||||
|
Table representing a collar device.
|
||||||
|
|
||||||
|
The collar has the following columns:
|
||||||
|
name - the collar's human-readable name.
|
||||||
|
active - whether or not the collar should be shown to users.
|
||||||
|
data_points - the list of coordinates transmitted by this collar.
|
||||||
|
boundary - the collar's valid grazing boundary (can be null)
|
||||||
|
"""
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String)
|
name = db.Column(db.String)
|
||||||
active = db.Column(db.Boolean)
|
active = db.Column(db.Boolean)
|
||||||
|
@ -9,6 +18,19 @@ class Collar(db.Model):
|
||||||
boundary = db.relationship("BoundingBox", uselist=False)
|
boundary = db.relationship("BoundingBox", uselist=False)
|
||||||
|
|
||||||
class DataPoint(db.Model):
|
class DataPoint(db.Model):
|
||||||
|
"""
|
||||||
|
A data point transmitted by the collar.
|
||||||
|
|
||||||
|
The data point has the following attributes; though they
|
||||||
|
were specified in the design document, currently, only the latitude,
|
||||||
|
longitude, and datetime (along with the collar ID) are used.
|
||||||
|
collar_id - the identifier of the collar that generated this point.
|
||||||
|
longitude - the collar's longitude.
|
||||||
|
latitude - the collar's latitude.
|
||||||
|
battery_level - the collar's battery level, between 0 and 1.
|
||||||
|
datetime - the time when this data point was collected.
|
||||||
|
outside - whether or not the collar is outside the grazing boundary.
|
||||||
|
"""
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
collar_id = db.Column(db.Integer, db.ForeignKey('collar.id'))
|
collar_id = db.Column(db.Integer, db.ForeignKey('collar.id'))
|
||||||
longitude = db.Column(db.Float(precision=10))
|
longitude = db.Column(db.Float(precision=10))
|
||||||
|
@ -26,6 +48,18 @@ class DataPoint(db.Model):
|
||||||
}
|
}
|
||||||
|
|
||||||
class StimulusActivation(db.Model):
|
class StimulusActivation(db.Model):
|
||||||
|
"""
|
||||||
|
A report made by the collar when it attempts
|
||||||
|
to get the animal to move back into the grazing area.
|
||||||
|
|
||||||
|
The report has the following attributes:
|
||||||
|
collar_id - the collar that reported the attempt.
|
||||||
|
longitude - the collar's longitude at the time of the attempt.
|
||||||
|
latitude - the collar's latitude at the time of the attempt.
|
||||||
|
volume_level - the volume level of the sound the collar emitted.
|
||||||
|
voltage_level - the voltage of the electric shock the collar emitted.
|
||||||
|
datetime - the time at which the attempt was made.
|
||||||
|
"""
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
collar_id = db.Column(db.Integer, db.ForeignKey('collar.id'))
|
collar_id = db.Column(db.Integer, db.ForeignKey('collar.id'))
|
||||||
longitude = db.Column(db.Float(precision=10))
|
longitude = db.Column(db.Float(precision=10))
|
||||||
|
@ -36,6 +70,18 @@ class StimulusActivation(db.Model):
|
||||||
|
|
||||||
|
|
||||||
class BoundingBox(db.Model):
|
class BoundingBox(db.Model):
|
||||||
|
"""
|
||||||
|
A polygon bounding box represent the valid grazing area.
|
||||||
|
|
||||||
|
To more closely match the collar's hardware limitation,
|
||||||
|
this table has exactly 10 vertex points. These points
|
||||||
|
are converted to and from lists of coordinates
|
||||||
|
by the unflatten and flatten functions, respectively.
|
||||||
|
Other than the coodinates, the polygon has the following
|
||||||
|
attributes:
|
||||||
|
collar_id - the identifier of the collar whose grazing area this is.
|
||||||
|
num_points - the number of points (from 0) that are actually used in this polygon.
|
||||||
|
"""
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
collar_id = db.Column(db.Integer, db.ForeignKey('collar.id'))
|
collar_id = db.Column(db.Integer, db.ForeignKey('collar.id'))
|
||||||
num_points = db.Column(db.Integer)
|
num_points = db.Column(db.Integer)
|
||||||
|
@ -61,17 +107,35 @@ class BoundingBox(db.Model):
|
||||||
coord9y = db.Column(db.Float(precision=10))
|
coord9y = db.Column(db.Float(precision=10))
|
||||||
|
|
||||||
class User(db.Model):
|
class User(db.Model):
|
||||||
|
"""
|
||||||
|
Database model representing the user. The user's password is stored
|
||||||
|
as a Bcrypt hash, and accessed via the password property.
|
||||||
|
|
||||||
|
The user has the following attributes:
|
||||||
|
username - the username of the user
|
||||||
|
password_hash - the Bcrypt hash stored in the database. This can be set using the password property.
|
||||||
|
"""
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String)
|
username = db.Column(db.String)
|
||||||
password_hash = db.Column(db.String)
|
password_hash = db.Column(db.String)
|
||||||
|
|
||||||
def get_password(self):
|
def get_password(self):
|
||||||
|
"""
|
||||||
|
Returns the user's encrypted password.
|
||||||
|
"""
|
||||||
return self.password_hash
|
return self.password_hash
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
|
"""
|
||||||
|
Encrypts and updates the user's password.
|
||||||
|
"""
|
||||||
self.password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
|
self.password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
|
||||||
|
|
||||||
def check_password(self, password):
|
def check_password(self, password):
|
||||||
|
"""
|
||||||
|
Checks if the given (unencrypted) password
|
||||||
|
matches the user's password hash.
|
||||||
|
"""
|
||||||
return bcrypt.checkpw(password.encode(), self.password_hash)
|
return bcrypt.checkpw(password.encode(), self.password_hash)
|
||||||
|
|
||||||
password = property(get_password, set_password)
|
password = property(get_password, set_password)
|
||||||
|
|
73
fgs/views.py
73
fgs/views.py
|
@ -9,10 +9,24 @@ from datetime import datetime, timedelta
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
|
"""
|
||||||
|
Index function, mostly used to ensure that the API is up and running.
|
||||||
|
Does not require authentication.
|
||||||
|
"""
|
||||||
return 'Hello, world!'
|
return 'Hello, world!'
|
||||||
|
|
||||||
@app.route('/login', methods=['POST'])
|
@app.route('/login', methods=['POST'])
|
||||||
def login():
|
def login():
|
||||||
|
"""
|
||||||
|
Authentication function.
|
||||||
|
|
||||||
|
The form data containing the username
|
||||||
|
and password is retrieved and compared against the database; if the
|
||||||
|
username does not exist, or the password doesn't match,
|
||||||
|
the service responds with a "bad request". A JSON token, unique
|
||||||
|
to the user, is returned when the authentication succeeds. This
|
||||||
|
token can then be used by the routes protected with @jwt_required.
|
||||||
|
"""
|
||||||
username = request.form.get('username')
|
username = request.form.get('username')
|
||||||
password = request.form.get('password')
|
password = request.form.get('password')
|
||||||
if username is None or password is None: abort(400)
|
if username is None or password is None: abort(400)
|
||||||
|
@ -23,11 +37,23 @@ def login():
|
||||||
@app.route('/me')
|
@app.route('/me')
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def me():
|
def me():
|
||||||
|
"""
|
||||||
|
Function used to ensure that a token is valid with the API.
|
||||||
|
|
||||||
|
Simply returns the username of the user to whom the token belongs.
|
||||||
|
"""
|
||||||
return jsonify({ 'username' : g.user.username })
|
return jsonify({ 'username' : g.user.username })
|
||||||
|
|
||||||
@app.route('/collars')
|
@app.route('/collars')
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def collars():
|
def collars():
|
||||||
|
"""
|
||||||
|
Function to retrieve the initial list of collars, to be shown on
|
||||||
|
the application's main page.
|
||||||
|
|
||||||
|
The response contains the identifier, name, and position of each
|
||||||
|
currently active collar.
|
||||||
|
"""
|
||||||
active_collars = []
|
active_collars = []
|
||||||
for collar in Collar.query.filter_by(active=True).all():
|
for collar in Collar.query.filter_by(active=True).all():
|
||||||
max_point = DataPoint.query.filter_by(collar_id=collar.id).\
|
max_point = DataPoint.query.filter_by(collar_id=collar.id).\
|
||||||
|
@ -43,15 +69,31 @@ def collars():
|
||||||
@app.route('/collars/stats/distance')
|
@app.route('/collars/stats/distance')
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def collars_distance():
|
def collars_distance():
|
||||||
|
"""
|
||||||
|
One of the functions used for displaying graphs / statistics to the user.
|
||||||
|
Computes the total distance traveled by an animal.
|
||||||
|
|
||||||
|
Returns, for each active collar, the identifier, name,
|
||||||
|
and total distance traveled. The name is included for convenience,
|
||||||
|
to prevent further API requests.
|
||||||
|
"""
|
||||||
|
|
||||||
active_collars = []
|
active_collars = []
|
||||||
for collar in Collar.query.filter_by(active=True).all():
|
for collar in Collar.query.filter_by(active=True).all():
|
||||||
coords = DataPoint.query.filter_by(collar_id=collar.id).\
|
# Can't use SQLAlchemy's data_points field
|
||||||
|
# to further restrict query (without some tinkering, which would hurt elsewhere)
|
||||||
|
data_points = DataPoint.query.filter_by(collar_id=collar.id).\
|
||||||
order_by(DataPoint.datetime.desc())
|
order_by(DataPoint.datetime.desc())
|
||||||
|
|
||||||
distance_kilometers = 0
|
# Extract longitude and latitude
|
||||||
coords_pairs = list(map(lambda e : (e.longitude, e.latitude), coords))
|
coords_pairs = list(map(lambda e : (e.latitude, e.longitude), data_points))
|
||||||
|
|
||||||
|
# The animal has not traveled at all; skip.
|
||||||
if len(coords_pairs) < 1: continue
|
if len(coords_pairs) < 1: continue
|
||||||
|
|
||||||
|
# Start with the current location, and work backwards
|
||||||
|
# one data point at a time.
|
||||||
|
distance_kilometers = 0
|
||||||
coord_last = coords_pairs.pop(0)
|
coord_last = coords_pairs.pop(0)
|
||||||
for coord in coords_pairs:
|
for coord in coords_pairs:
|
||||||
distance_kilometers += geodesic(coord_last,coord).km
|
distance_kilometers += geodesic(coord_last,coord).km
|
||||||
|
@ -67,6 +109,11 @@ def collars_distance():
|
||||||
@app.route('/collars/<int:id>/history')
|
@app.route('/collars/<int:id>/history')
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def collar_history(id):
|
def collar_history(id):
|
||||||
|
"""
|
||||||
|
Function to retrieve the complete history of a collar, sorted newest to oldest.
|
||||||
|
|
||||||
|
Returns a list of coordinates.
|
||||||
|
"""
|
||||||
collar = Collar.query.filter_by(id=id).first()
|
collar = Collar.query.filter_by(id=id).first()
|
||||||
if collar is None: abort(404)
|
if collar is None: abort(404)
|
||||||
|
|
||||||
|
@ -79,6 +126,13 @@ def collar_history(id):
|
||||||
@app.route('/collars/<int:id>/details')
|
@app.route('/collars/<int:id>/details')
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def collar_detail(id):
|
def collar_detail(id):
|
||||||
|
"""
|
||||||
|
Function to get the information needed for a single-collar view.
|
||||||
|
|
||||||
|
At the moment, this information includes the collar's name, location,
|
||||||
|
valid grazing area, and how many times it had triggered the
|
||||||
|
out-of-grazing-area stimulus in the last 24 hours.
|
||||||
|
"""
|
||||||
collar = Collar.query.filter_by(id=id).first()
|
collar = Collar.query.filter_by(id=id).first()
|
||||||
if collar is None: abort(404)
|
if collar is None: abort(404)
|
||||||
|
|
||||||
|
@ -86,7 +140,7 @@ def collar_detail(id):
|
||||||
since = datetime.now() - timedelta(hours=24)
|
since = datetime.now() - timedelta(hours=24)
|
||||||
stimulus_points = StimulusActivation.query.\
|
stimulus_points = StimulusActivation.query.\
|
||||||
filter_by(collar_id=id).\
|
filter_by(collar_id=id).\
|
||||||
filter(StimulusActivation.datetime < since).\
|
filter(StimulusActivation.datetime > since).\
|
||||||
order_by(StimulusActivation.datetime.desc())
|
order_by(StimulusActivation.datetime.desc())
|
||||||
n_stimulus = stimulus_points.count()
|
n_stimulus = stimulus_points.count()
|
||||||
|
|
||||||
|
@ -95,6 +149,17 @@ def collar_detail(id):
|
||||||
@app.route('/collars/<int:id>/boundary/set', methods=['POST'])
|
@app.route('/collars/<int:id>/boundary/set', methods=['POST'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def collar_set_boundary(id):
|
def collar_set_boundary(id):
|
||||||
|
"""
|
||||||
|
Function to configure a collar's grazing boundary.
|
||||||
|
|
||||||
|
A grazing boundary is received from the application as a list of
|
||||||
|
vertices; we hide the the 10-point constraint from that part
|
||||||
|
of the system to prevent coupling. However, this means
|
||||||
|
that we need to convert an arbitrary list of points into
|
||||||
|
the flattened BoundingBox instance, which is handled
|
||||||
|
by flatten. Setting a collar boundary removes the old
|
||||||
|
boundary from the database.
|
||||||
|
"""
|
||||||
collar = Collar.query.filter_by(id=id).first()
|
collar = Collar.query.filter_by(id=id).first()
|
||||||
if collar is None: abort(404)
|
if collar is None: abort(404)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user