Compare commits
16 Commits
f2cad65626
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ec4a0ccae | |||
| 5796d01185 | |||
| e497686460 | |||
| fba6aedfb3 | |||
|
|
9b22419d2b | ||
| 4976cd8b93 | |||
| 171b5fe9c4 | |||
| 9b2f94a0e7 | |||
| f3a711787c | |||
| 1aa0933aa1 | |||
|
|
48fb5d0d9b | ||
|
|
efacfe76ec | ||
| 630ef001cc | |||
| 7d3138c48f | |||
| ec08fca5b7 | |||
| f79748c113 |
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
|
||||
|
||||
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:
|
||||
|
||||
92
fgs/model.py
92
fgs/model.py
@@ -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,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))
|
||||
@@ -33,18 +68,75 @@ class StimulusActivation(db.Model):
|
||||
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)
|
||||
|
||||
98
fgs/views.py
98
fgs/views.py
@@ -1,17 +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)
|
||||
@@ -22,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).\
|
||||
@@ -42,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))
|
||||
|
||||
# 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(
|
||||
@@ -65,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)
|
||||
|
||||
@@ -77,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)
|
||||
|
||||
@@ -84,8 +140,34 @@ 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()
|
||||
|
||||
return jsonify({'id': collar.id, 'name': collar.name, 'stimilus': n_stimulus })
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user