Compare commits

..

18 Commits

Author SHA1 Message Date
6ec4a0ccae Add Google Doc link. 2020-05-15 21:56:41 -07:00
5796d01185 Add code review remarks. 2020-05-15 21:44:00 -07:00
e497686460 Merge branch 'master' of https://dev.danilafe.com/CS-46X/server 2020-05-15 17:04:14 -07:00
fba6aedfb3 Add 'needs_push' field 2020-05-15 17:04:04 -07:00
sessionm21
9b22419d2b add inlets back 2020-05-15 20:46:28 +01:00
4976cd8b93 Add documentation. 2020-05-14 15:25:28 -07:00
171b5fe9c4 Add README 2020-05-14 01:56:12 -07:00
9b2f94a0e7 Update requirements 2020-05-14 01:38:10 -07:00
f3a711787c Add boundary editing via lists of points 2020-05-14 00:42:44 -07:00
1aa0933aa1 Start working on saving the grazing boundary 2020-05-13 22:05:56 -07:00
sessionm21
48fb5d0d9b add boundaries to model and view 2020-05-13 06:58:49 +01:00
sessionm21
efacfe76ec add boundaries to model and view 2020-05-13 06:54:04 +01:00
630ef001cc Update to latest GPS version. 2020-05-12 22:07:59 -07:00
7d3138c48f Fix typo 2020-05-12 18:42:59 -07:00
ec08fca5b7 Merge branch 'master' of https://dev.danilafe.com/CS-46X/server 2020-05-12 18:41:44 -07:00
sessionm21
f2cad65626 add stimulus activation report 2020-05-13 02:40:59 +01:00
sessionm21
6b826d8e0f add gitignore 2020-05-13 02:40:44 +01:00
f79748c113 Touch up distance API 2020-05-12 17:37:48 -07:00
9 changed files with 424 additions and 10 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.swp
*.sqlite
__pycache__

113
README.md Normal file
View File

@@ -0,0 +1,113 @@
# Fenceless Grazing System API Server
This is the repository for the API server component of the Fenceless
Grazing System. This server is written in Python using the Flask
microframework, and uses the SQLAlchemy ORM to interact with the SQLite
database.
## Getting the Dependencies
The API server depends on several packages to operate properly, which
will likely not be installed by default on your system. There are two
ways to get the required dependencies: using Python's
`virtualenv` (standard in the Python ecosystem), or using Nix (only if you are into that kind of thing).
### Getting the Dependencies with `virtualenv`
First, create a virtual environment (this only has to be done once):
```Bash
virtualenv venv
```
Once the virtual environment has been created, activate it using the
following command:
```Bash
source venv/bin/activate
```
This effectively creates an independent Python environment, into
which you can install packages without affecting your entire
system. You can now install the packages from the `requirements.txt` file:
```Bash
pip install -r requirements.txt
```
With this, you should have all the necessary dependencies.
### Getting the Dependencies with Nix
Below is the Nix expression I use daily to work on
this application:
```Nix
with import <nixpkgs> {};
mkShell {
buildInputs = [ (python3.withPackages (ps: with ps; [ flask flask_sqlalchemy pyjwt bcrypt geopy pip ])) ];
}
```
Note that some packages (`geopy` in particular) are currently unstable; the expressions from stable Nixpkgs
may not provide a working derivation. When using the `nixpkgs-unstable`, I have no such issues.
## Setting Up
The application requires the `DB_LOC` environment variable to be set to indicate the location
of the database that the server should use:
```Bash
export DB_LOC=database.sqlite
```
Additionally, Flask uses the `FLASK_APP` environment variable to determine the entry point
of a web application
```Bash
# Here, fgs represents the fgs/ directory in the repository's root.
export FLASK_APP=fgs
```
Finally, on first run, you need to create the tables in the application's database.
This can be done as follows:
```Python
flask shell
# Imports, among other things, the db variable, which is the handle for the SQLAlchemy ORM.
from fgs import *
# SQLAlchemy needs to be aware of the various tables in our database
from fgs.model import *
# Creates all tables
db.create_all()
# It also helps to set up a user and a couple of collars:
user = User(username='user', password='password')
collar1 = Collar(name='Sample Collar', active=True)
collar2 = Collar(name='Another Collar', active=True)
# To add the newly created objects to the database, use
# db.session.add and db.session.commit
db.session.add(user)
db.session.add(collar1)
db.session.add(collar2)
db.session.commit()
# You can now press Ctrl-D to exit the Flask shell.
```
Once the database has been created, you can start the server
with:
```Bash
flask run
```
## Next Steps
Once the server is up and running, consider setting up the
[app](https://dev.danilafe.com/CS-46X/app), which is able
interact with this API server. Additionally, check out the
[cow description language](https://dev.danilafe.com/CS-46X/cdl)
if you want to generate mock data for the API server's database.

12
REVIEW.md Normal file
View File

@@ -0,0 +1,12 @@
# Code Review Changes
Original tables with verbatim feedback can be found in [this Google Doc](https://docs.google.com/document/d/19d5U-ieQyGVVNQjj0rUouzIqF53evEP6yss-wyqNXiQ/edit?usp=sharing).
|Comment|Adjustment|
|-------|----------|
|The `models.py` file is too difficult to understand.| `models.py` updated with Python docstrings.|
|The code in general is not commented.| Every function in the codebase commented with Python docstring to explain usage and implementation. Some comments added to functions longer than a few lines.|
|It's difficult to set up the server software.| `requirements.txt` file updated with all packages; `default.nix` provided via `README.md` to provide alternative installation method.|
|No README file.| README file added with installation and configuration instructions.|
|No unit tests.| Developed [cow description language](https://dev.danilafe.com.CS-46x/cdl.git) to test database access (tests still need to be run manually, due to time constraints).
|Unrolled list of variables can be converted to list.| Unrolled fields kept to maintain isomorphism with hardware, where unrolling variables is necessary due to hardware and radio limitations. Methods written to limit the need for manually accessing `coord*x` and `coord*y` variables.|

83
fgs/flatten.py Normal file
View File

@@ -0,0 +1,83 @@
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
def set3x(b, v): b.coord3x = v
def set4x(b, v): b.coord4x = v
def set5x(b, v): b.coord5x = v
def set6x(b, v): b.coord6x = v
def set7x(b, v): b.coord7x = v
def set8x(b, v): b.coord8x = v
def set9x(b, v): b.coord9x = v
def set0y(b, v): b.coord0y = v
def set1y(b, v): b.coord1y = v
def set2y(b, v): b.coord2y = v
def set3y(b, v): b.coord3y = v
def set4y(b, v): b.coord4y = v
def set5y(b, v): b.coord5y = v
def set6y(b, v): b.coord6y = v
def set7y(b, v): b.coord7y = v
def set8y(b, v): b.coord8y = v
def set9y(b, v): b.coord9y = v
setters_x = [ set0x, set1x, set2x, set3x, set4x, set5x, set6x, set7x, set8x, set9x ]
setters_y = [ set0y, set1y, set2y, set3y, set4y, set5y, set6y, set7y, set8y, set9y ]
boundary = BoundingBox()
boundary.num_points = len(points)
for (point, set_x, set_y) in zip(points, setters_x, setters_y):
set_x(boundary, float(point['longitude']))
set_y(boundary, float(point['latitude']))
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 = [
lambda b: b.coord0x,
lambda b: b.coord1x,
lambda b: b.coord2x,
lambda b: b.coord3x,
lambda b: b.coord4x,
lambda b: b.coord5x,
lambda b: b.coord6x,
lambda b: b.coord7x,
lambda b: b.coord8x,
lambda b: b.coord9x
]
getters_y = [
lambda b: b.coord0y,
lambda b: b.coord1y,
lambda b: b.coord2y,
lambda b: b.coord3y,
lambda b: b.coord4y,
lambda b: b.coord5y,
lambda b: b.coord6y,
lambda b: b.coord7y,
lambda b: b.coord8y,
lambda b: b.coord9y
]
points = []
for i in range(boundary.num_points):
points.append({'longitude': str(getters_x[i](boundary)), 'latitude': str(getters_y[i](boundary))})
return points

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,12 +2,35 @@ 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)
data_points = db.relationship('DataPoint')
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))
@@ -25,24 +48,95 @@ 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))
latitude = db.Column(db.Float(precision=10))
volume_level = db.Column(db.Integer)
voltage_level = db.Column(db.Integer)
datetime = db.Column(db.DateTime)
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)
needs_push = db.Column(db.Boolean)
coord0x = db.Column(db.Float(precision=10))
coord0y = db.Column(db.Float(precision=10))
coord1x = db.Column(db.Float(precision=10))
coord1y = db.Column(db.Float(precision=10))
coord2x = db.Column(db.Float(precision=10))
coord2y = db.Column(db.Float(precision=10))
coord3x = db.Column(db.Float(precision=10))
coord3y = db.Column(db.Float(precision=10))
coord4x = db.Column(db.Float(precision=10))
coord4y = db.Column(db.Float(precision=10))
coord5x = db.Column(db.Float(precision=10))
coord5y = db.Column(db.Float(precision=10))
coord6x = db.Column(db.Float(precision=10))
coord6y = db.Column(db.Float(precision=10))
coord7x = db.Column(db.Float(precision=10))
coord7y = db.Column(db.Float(precision=10))
coord8x = db.Column(db.Float(precision=10))
coord8y = db.Column(db.Float(precision=10))
coord9x = db.Column(db.Float(precision=10))
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

@@ -1,16 +1,32 @@
from . import app, db
from .flatten import flatten, unflatten
from .jwt import jwt_required
from .model import *
from flask import g, jsonify, request, abort
from sqlalchemy import func
import geopy.distance
from geopy.distance import geodesic
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)
@@ -21,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).\
@@ -41,17 +69,34 @@ 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))
coord_last = coords_pairs.pop()
# 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 += geopy.distance.VincentyDistance(coord_last,coord).km
distance_kilometers += geodesic(coord_last,coord).km
coord_last = coord
active_collars.append(
@@ -64,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)
@@ -76,7 +126,48 @@ 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)
return jsonify({'id': collar.id, 'name': collar.name })
# get stimulus activation reports from within last 24 hours
since = datetime.now() - timedelta(hours=24)
stimulus_points = StimulusActivation.query.\
filter_by(collar_id=id).\
filter(StimulusActivation.datetime > since).\
order_by(StimulusActivation.datetime.desc())
n_stimulus = stimulus_points.count()
return jsonify({'id': collar.id, 'name': collar.name, 'stimilus': n_stimulus, 'boundary': unflatten(collar.boundary) })
@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)
new_boundary = flatten(request.json)
new_boundary.needs_push = True
if collar.boundary is not None:
db.session.delete(collar.boundary)
collar.boundary = new_boundary
db.session.add(new_boundary)
db.session.commit()
return jsonify({})

View File

@@ -1,14 +1,22 @@
bcrypt==3.1.7
cffi==1.13.2
Click==7.0
cffi==1.14.0
click==7.1.1
cryptography==2.9.1
ecdsa==0.14.1
Flask==1.1.1
Flask-SQLAlchemy==2.4.1
geographiclib==1.50
geopy==2.0.0a0
greenlet==0.4.15
itsdangerous==1.1.0
Jinja2==2.11.1
MarkupSafe==1.1.1
msgpack==1.0.0
packaging==20.1
pycparser==2.19
PyJWT==1.7.1
pynvim==0.4.1
pyparsing==2.4.6
six==1.14.0
SQLAlchemy==1.3.13
Werkzeug==0.16.1

1
run.sh
View File

@@ -6,5 +6,6 @@ fi
# Run the Flask and Python server through inlets
export DB_LOC=$3
export FLASK_ENV=development
inlets client --remote=$1 --upstream=http://127.0.0.1:5000 --token=$2 &
FLASK_APP=fgs flask run