From 4976cd8b93b0d2b6a52bcb4511a996321bae4dc5 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 14 May 2020 15:25:28 -0700 Subject: [PATCH] Add documentation. --- fgs/flatten.py | 15 ++++++++++ fgs/jwt.py | 9 ++++++ fgs/model.py | 64 ++++++++++++++++++++++++++++++++++++++++++ fgs/views.py | 75 ++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 158 insertions(+), 5 deletions(-) diff --git a/fgs/flatten.py b/fgs/flatten.py index 03d0716..2a01969 100644 --- a/fgs/flatten.py +++ b/fgs/flatten.py @@ -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 = [ diff --git a/fgs/jwt.py b/fgs/jwt.py index cedb98b..7763f1a 100644 --- a/fgs/jwt.py +++ b/fgs/jwt.py @@ -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: diff --git a/fgs/model.py b/fgs/model.py index 2453dad..03ee923 100644 --- a/fgs/model.py +++ b/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) diff --git a/fgs/views.py b/fgs/views.py index 119426d..b8039d0 100644 --- a/fgs/views.py +++ b/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//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//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//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)