Add documentation.
This commit is contained in:
parent
171b5fe9c4
commit
4976cd8b93
|
@ -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 = [
|
||||
|
|
|
@ -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:
|
||||
|
|
64
fgs/model.py
64
fgs/model.py
|
@ -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)
|
||||
|
|
73
fgs/views.py
73
fgs/views.py
|
@ -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)
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user