Add documentation.

This commit is contained in:
Danila Fedorin 2020-05-14 15:25:28 -07:00
parent 171b5fe9c4
commit 4976cd8b93
4 changed files with 158 additions and 5 deletions

View File

@ -2,6 +2,16 @@ from fgs.model import *
import itertools
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 set1x(b, v): b.coord1x = v
def set2x(b, v): b.coord2x = v
@ -35,6 +45,11 @@ def flatten(points):
return 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 []
getters_x = [

View File

@ -5,11 +5,20 @@ from .model import User
from functools import wraps
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')
User.get_jwt = get_jwt
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)
def decorated_function(*args, **kwargs):
if 'Authorization' not in request.headers:

View File

@ -2,6 +2,15 @@ from . import db
import bcrypt
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)
name = db.Column(db.String)
active = db.Column(db.Boolean)
@ -9,6 +18,19 @@ class Collar(db.Model):
boundary = db.relationship("BoundingBox", uselist=False)
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)
collar_id = db.Column(db.Integer, db.ForeignKey('collar.id'))
longitude = db.Column(db.Float(precision=10))
@ -26,6 +48,18 @@ class DataPoint(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)
collar_id = db.Column(db.Integer, db.ForeignKey('collar.id'))
longitude = db.Column(db.Float(precision=10))
@ -36,6 +70,18 @@ class StimulusActivation(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)
collar_id = db.Column(db.Integer, db.ForeignKey('collar.id'))
num_points = db.Column(db.Integer)
@ -61,17 +107,35 @@ class BoundingBox(db.Model):
coord9y = db.Column(db.Float(precision=10))
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)
username = db.Column(db.String)
password_hash = db.Column(db.String)
def get_password(self):
"""
Returns the user's encrypted password.
"""
return self.password_hash
def set_password(self, password):
"""
Encrypts and updates the user's password.
"""
self.password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
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)
password = property(get_password, set_password)

View File

@ -9,10 +9,24 @@ from datetime import datetime, timedelta
@app.route('/')
def index():
"""
Index function, mostly used to ensure that the API is up and running.
Does not require authentication.
"""
return 'Hello, world!'
@app.route('/login', methods=['POST'])
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')
password = request.form.get('password')
if username is None or password is None: abort(400)
@ -23,11 +37,23 @@ def login():
@app.route('/me')
@jwt_required
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 })
@app.route('/collars')
@jwt_required
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 = []
for collar in Collar.query.filter_by(active=True).all():
max_point = DataPoint.query.filter_by(collar_id=collar.id).\
@ -43,15 +69,31 @@ def collars():
@app.route('/collars/stats/distance')
@jwt_required
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 = []
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())
distance_kilometers = 0
coords_pairs = list(map(lambda e : (e.longitude, e.latitude), coords))
# Extract longitude and latitude
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
# Start with the current location, and work backwards
# one data point at a time.
distance_kilometers = 0
coord_last = coords_pairs.pop(0)
for coord in coords_pairs:
distance_kilometers += geodesic(coord_last,coord).km
@ -67,6 +109,11 @@ def collars_distance():
@app.route('/collars/<int:id>/history')
@jwt_required
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()
if collar is None: abort(404)
@ -79,6 +126,13 @@ def collar_history(id):
@app.route('/collars/<int:id>/details')
@jwt_required
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()
if collar is None: abort(404)
@ -86,7 +140,7 @@ def collar_detail(id):
since = datetime.now() - timedelta(hours=24)
stimulus_points = StimulusActivation.query.\
filter_by(collar_id=id).\
filter(StimulusActivation.datetime < since).\
filter(StimulusActivation.datetime > since).\
order_by(StimulusActivation.datetime.desc())
n_stimulus = stimulus_points.count()
@ -95,6 +149,17 @@ def collar_detail(id):
@app.route('/collars/<int:id>/boundary/set', methods=['POST'])
@jwt_required
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()
if collar is None: abort(404)