Compare commits
18 Commits
4448de1288
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ec4a0ccae | |||
| 5796d01185 | |||
| e497686460 | |||
| fba6aedfb3 | |||
|
|
9b22419d2b | ||
| 4976cd8b93 | |||
| 171b5fe9c4 | |||
| 9b2f94a0e7 | |||
| f3a711787c | |||
| 1aa0933aa1 | |||
|
|
48fb5d0d9b | ||
|
|
efacfe76ec | ||
| 630ef001cc | |||
| 7d3138c48f | |||
| ec08fca5b7 | |||
|
|
f2cad65626 | ||
|
|
6b826d8e0f | ||
| f79748c113 |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*.swp
|
||||||
|
*.sqlite
|
||||||
|
__pycache__
|
||||||
113
README.md
Normal file
113
README.md
Normal 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
12
REVIEW.md
Normal 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
83
fgs/flatten.py
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
94
fgs/model.py
94
fgs/model.py
@@ -2,12 +2,35 @@ 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)
|
||||||
data_points = db.relationship('DataPoint')
|
data_points = db.relationship('DataPoint')
|
||||||
|
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))
|
||||||
@@ -25,24 +48,95 @@ 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'))
|
||||||
longitude = db.Column(db.Float(precision=10))
|
longitude = db.Column(db.Float(precision=10))
|
||||||
latitude = db.Column(db.Float(precision=10))
|
latitude = db.Column(db.Float(precision=10))
|
||||||
volume_level = db.Column(db.Integer)
|
volume_level = db.Column(db.Integer)
|
||||||
voltage_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):
|
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)
|
||||||
|
|||||||
107
fgs/views.py
107
fgs/views.py
@@ -1,16 +1,32 @@
|
|||||||
from . import app, db
|
from . import app, db
|
||||||
|
from .flatten import flatten, unflatten
|
||||||
from .jwt import jwt_required
|
from .jwt import jwt_required
|
||||||
from .model import *
|
from .model import *
|
||||||
from flask import g, jsonify, request, abort
|
from flask import g, jsonify, request, abort
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
import geopy.distance
|
from geopy.distance import geodesic
|
||||||
|
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)
|
||||||
@@ -21,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).\
|
||||||
@@ -41,17 +69,34 @@ 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
|
|
||||||
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:
|
for coord in coords_pairs:
|
||||||
distance_kilometers += geopy.distance.VincentyDistance(coord_last,coord).km
|
distance_kilometers += geodesic(coord_last,coord).km
|
||||||
coord_last = coord
|
coord_last = coord
|
||||||
|
|
||||||
active_collars.append(
|
active_collars.append(
|
||||||
@@ -64,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)
|
||||||
|
|
||||||
@@ -76,7 +126,48 @@ 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)
|
||||||
|
|
||||||
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({})
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
bcrypt==3.1.7
|
bcrypt==3.1.7
|
||||||
cffi==1.13.2
|
cffi==1.14.0
|
||||||
Click==7.0
|
click==7.1.1
|
||||||
|
cryptography==2.9.1
|
||||||
|
ecdsa==0.14.1
|
||||||
Flask==1.1.1
|
Flask==1.1.1
|
||||||
Flask-SQLAlchemy==2.4.1
|
Flask-SQLAlchemy==2.4.1
|
||||||
|
geographiclib==1.50
|
||||||
|
geopy==2.0.0a0
|
||||||
|
greenlet==0.4.15
|
||||||
itsdangerous==1.1.0
|
itsdangerous==1.1.0
|
||||||
Jinja2==2.11.1
|
Jinja2==2.11.1
|
||||||
MarkupSafe==1.1.1
|
MarkupSafe==1.1.1
|
||||||
|
msgpack==1.0.0
|
||||||
|
packaging==20.1
|
||||||
pycparser==2.19
|
pycparser==2.19
|
||||||
PyJWT==1.7.1
|
PyJWT==1.7.1
|
||||||
pynvim==0.4.1
|
pynvim==0.4.1
|
||||||
|
pyparsing==2.4.6
|
||||||
six==1.14.0
|
six==1.14.0
|
||||||
SQLAlchemy==1.3.13
|
SQLAlchemy==1.3.13
|
||||||
Werkzeug==0.16.1
|
Werkzeug==0.16.1
|
||||||
|
|||||||
1
run.sh
1
run.sh
@@ -6,5 +6,6 @@ fi
|
|||||||
|
|
||||||
# Run the Flask and Python server through inlets
|
# Run the Flask and Python server through inlets
|
||||||
export DB_LOC=$3
|
export DB_LOC=$3
|
||||||
|
export FLASK_ENV=development
|
||||||
inlets client --remote=$1 --upstream=http://127.0.0.1:5000 --token=$2 &
|
inlets client --remote=$1 --upstream=http://127.0.0.1:5000 --token=$2 &
|
||||||
FLASK_APP=fgs flask run
|
FLASK_APP=fgs flask run
|
||||||
|
|||||||
Reference in New Issue
Block a user