update user model; templates for api; setup user page and github oauth

This commit is contained in:
crflynn
2018-04-06 23:17:18 -04:00
parent 03995ebf5a
commit 3d8247886f
12 changed files with 509 additions and 66 deletions

View File

@@ -0,0 +1,35 @@
"""empty message
Revision ID: e65ba8f3cdcf
Revises: c81b3715b9e5
Create Date: 2018-04-06 17:58:19.643259
"""
# flake8: noqa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e65ba8f3cdcf'
down_revision = 'c81b3715b9e5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('avatar_url', sa.String(length=256), nullable=True))
op.add_column('users', sa.Column('uid', sa.Integer(), nullable=True))
op.drop_constraint('users_username_key', 'users', type_='unique')
op.create_unique_constraint(None, 'users', ['uid'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', type_='unique')
op.create_unique_constraint('users_username_key', 'users', ['username'])
op.drop_column('users', 'uid')
op.drop_column('users', 'avatar_url')
# ### end Alembic commands ###

View File

@@ -10,15 +10,20 @@ from pypistats.database import SurrogatePK
from pypistats.extensions import db
MAX_FAVORITES = 20
class User(UserMixin, SurrogatePK, Model):
"""A user of the app."""
__tablename__ = 'users'
username = Column(db.String(39), unique=True, nullable=False)
# icon
uid = Column(db.Integer(), unique=True)
username = Column(db.String(39), nullable=False)
avatar_url = Column(db.String(256))
token = Column(db.String(256))
created_at = Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
created_at = \
Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
active = Column(db.Boolean(), default=False)
is_admin = Column(db.Boolean(), default=False)
favorites = Column(ARRAY(db.String(128), dimensions=1))

View File

@@ -1,5 +1,9 @@
"""Run the application."""
from flask import g
from flask import session
from pypistats.application import create_app
from pypistats.models.user import User
from pypistats.settings import DevConfig
from pypistats.settings import ProdConfig
from pypistats.settings import TestConfig
@@ -7,3 +11,11 @@ from pypistats.settings import TestConfig
# change this for migrations
app = create_app(DevConfig)
@app.before_request
def before_request():
"""Execute before requests."""
g.user = None
if "user_id" in session:
g.user = User.query.get(session['user_id'])

View File

@@ -2,6 +2,7 @@
import os
from pypistats.secret import postgresql
from pypistats.secret import github
def get_db_uri(env):
@@ -22,8 +23,6 @@ class Config(object):
SECRET_KEY = os.environ.get("PYPISTATS_SECRET", "secret-key")
APP_DIR = os.path.abspath(os.path.dirname(__file__))
PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, os.pardir))
GITHUB_CLIENT_ID = "test"
GITHUB_CLIENT_SECRET = "test"
SQLALCHEMY_TRACK_MODIFICATIONS = False
@@ -33,6 +32,8 @@ class ProdConfig(Config):
ENV = "prod"
DEBUG = False
SQLALCHEMY_DATABASE_URI = get_db_uri(ENV)
GITHUB_CLIENT_ID = github[ENV]["client_id"]
GITHUB_CLIENT_SECRET = github[ENV]["client_secret"]
class DevConfig(Config):
@@ -41,6 +42,8 @@ class DevConfig(Config):
ENV = "dev"
DEBUG = True
SQLALCHEMY_DATABASE_URI = get_db_uri(ENV)
GITHUB_CLIENT_ID = github[ENV]["client_id"]
GITHUB_CLIENT_SECRET = github[ENV]["client_secret"]
class TestConfig(Config):
@@ -51,3 +54,5 @@ class TestConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = get_db_uri(ENV)
WTF_CSRF_ENABLED = False # Allows form testing
GITHUB_CLIENT_ID = github[ENV]["client_id"]
GITHUB_CLIENT_SECRET = github[ENV]["client_secret"]

View File

@@ -4,15 +4,19 @@
<h1>About PyPI Stats</h1>
<hr>
<h3>Goal</h3>
<p>PyPI Stats aims to provide aggregate download information on python packages available from the Python Package Index.</p>
<p>PyPI Stats aims to provide aggregate download information on python packages available from the Python Package Index in lieu of having to execute queries against raw download records in Google BigQuery.</p>
<h3>Data</h3>
<p>Download stats are sourced from the Python Software Foundation's publicly available
<a href="https://bigquery.cloud.google.com/table/the-psf:pypi.downloads">download stats</a>
on Google BigQuery. All aggregate download stats ignore known PyPI mirrors (such as
<a href="{{url_for('general.package', package='bandersnatch')}}">bandersnatch</a>) unless noted otherwise.</p>
<p>PyPI Stats attempts to operate within the free tier in AWS. For this reason, aggregate data is only retained for 30 days.</p>
<p>PyPI Stats attempts to operate within the free tier of its hosted services. For this reason, aggregate data is only retained for 30 days.</p>
<h3>API</h3>
<p>A simple
<a href="{{url_for('api.api')}}">JSON API</a>
is available for aggregate download stats and time series for packages.</p>
<h3>Tech</h3>
<p>PyPI Stats is a project developed using Python 3.6.
<p>PyPI Stats is a project developed using Python 3.6. Here are some of the tools used to create it:
<ul>
<li>Framework:
<a href="{{url_for('general.package', package='flask')}}">Flask</a>

View File

@@ -0,0 +1,256 @@
{% extends "layout.html" %}
{% block title %}PyPI Stats{% endblock %}
{% block body %}
<h1>PyPI Stats API</h1>
<hr>
<p>
PyPI Stats provides a simple JSON API for retrieving aggregate download stats and time series for packages. The following are the valid endpoints using host:
<code>https://pypistats.org/</code>
</p>
<h3>NOTES</h3>
<p>
<ul>
<li>All download stats exclude known mirrors (such as
<a href="{{ url_for('general.package', package='bandersnatch')}}">bandersnatch</a>) unless noted otherwise.</li>
<li>Time series data is retained only for 30 days.</li>
</ul>
</p>
<h2>Endpoints</h2>
<h3>/api/&lt;package&gt;/recent</h3>
<p>Retrieve the aggregate download quantities for the last day/week/month. Query arguments:
<ul>
<li>
<b>period</b>
(optional):
<code>day</code>
or
<code>week</code>
or
<code>month</code>
</li>
</ul>
Example response:
<pre><code>{
"data": {
"last_day": 1,
"last_month": 2,
"last_week": 3
},
"package": "package_name",
"type": "recent_downloads"
}</code></pre>
</p>
<h3>/api/&lt;package&gt;/overall</h3>
<p>Retrieve the aggregate daily download time series with or without mirror downloads. Query arguments:
<ul>
<li>
<b>mirrors</b>
(optional):
<code>true</code>
or
<code>false</code>
</li>
<li>
<b>start_date</b>
(optional): starting date of time series in format
<code>YYYY-MM-DD</code>
</li>
<li>
<b>end_date</b>
(optional): ending date of time series in format
<code>YYYY-MM-DD</code>
</li>
</ul>
Example response:
<pre><code>{
"data": [
{
"category": "with_mirrors",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "without_mirrors",
"date": "2018-02-08",
"downloads": 1
}
],
"package": "package_name",
"type": "overall_downloads"
}</code></pre>
</p>
<h3>/api/&lt;package&gt;/python_major</h3>
<p>Retrieve the aggregate daily download time series by Python major version number. Query arguments:
<ul>
<li>
<b>version</b>
(optional): the Python major version number, e.g.
<code>2</code>
or
<code>3</code>
</li>
<li>
<b>start_date</b>
(optional): starting date of time series in format
<code>YYYY-MM-DD</code>
</li>
<li>
<b>end_date</b>
(optional): ending date of time series in format
<code>YYYY-MM-DD</code>
</li>
</ul>
Example response:
<pre><code>{
"data": [
{
"category": "2",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "3",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "null",
"date": "2018-02-08",
"downloads": 1
}
],
"package": "package_name",
"type": "python_major_downloads"
}</code></pre>
</p>
<h3>/api/&lt;package&gt;/python_minor</h3>
<p>Retrieve the aggregate daily download time series by Python minor version number. Query arguments:
<ul>
<li>
<b>version</b>
(optional): the Python major version number, e.g.
<code>2.7</code>
or
<code>3.6</code>
</li>
<li>
<b>start_date</b>
(optional): starting date of time series in format
<code>YYYY-MM-DD</code>
</li>
<li>
<b>end_date</b>
(optional): ending date of time series in format
<code>YYYY-MM-DD</code>
</li>
</ul>
Example response:
<pre><code>{
"data": [
{
"category": "2.6",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "2.7",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "3.2",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "3.3",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "3.4",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "3.5",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "3.6",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "3.7",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "null",
"date": "2018-02-08",
"downloads": 1
}
],
"package": "package_name",
"type": "python_minor_downloads"
}</code></pre>
</p>
<h3>/api/&lt;package&gt;/system</h3>
<p>Retrieve the aggregate daily download time series by operating system. Query arguments:
<ul>
<li>
<b>os</b>
(optional): the operating system name, e.g.
<code>windows</code>,
<code>linux</code>, or
<code>darwin</code>
(Mac OSX).
</li>
<li>
<b>start_date</b>
(optional): starting date of time series in format
<code>YYYY-MM-DD</code>
</li>
<li>
<b>end_date</b>
(optional): ending date of time series in format
<code>YYYY-MM-DD</code>
</li>
</ul>
Example response:
<pre><code>{
"data": [
{
"category": "darwin",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "linux",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "null",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "other",
"date": "2018-02-08",
"downloads": 1
},
{
"category": "windows",
"date": "2018-02-08",
"downloads": 1
}
],
"package": "package_name",
"type": "system_downloads"
}</code></pre>
</p>
{% endblock %}

View File

@@ -53,10 +53,14 @@
<br>
<a href="{{ url_for('general.package', package='__all__') }}">__all__</a>
<br>
<a href="{{ url_for('general.top') }}">Top</a>
<a href="{{ url_for('general.top') }}">__top__</a>
<br>
<br>
<a href="{{ url_for('user.login')}}">Personal</a>
{% if user %}
<a href="{{ url_for('user.user')}}">{{ user.username }}'s Packages</a>
{% else %}
<a href="{{ url_for('user.user')}}">My Packages</a>
{% endif %}
<br>
<a href="{{ url_for('user.logout') }}">Logout</a>
</p>
@@ -67,10 +71,10 @@
</section>
<footer>
<p>
<a href="{{ url_for('api.api') }}">API</a>
<br>
<a href="{{ url_for('general.about') }}">About</a>
<br>
<small>PyPI Stats.<br></small>
</p>
</p>
</footer>

View File

@@ -5,9 +5,19 @@
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
{% endblock %}
{% block body %}
<h1>Downloads for package:
{{package}}</h1>
<h1>{{package}}</h1>
<hr>
{% if user %}
{% if user.favorites and package in user.favorites %}
<p>
<img src="{{ user.avatar_url }}" height="20" width="20">
<a href="{{ url_for('user.user_package', package=package)}}">REMOVE from my packages</a><br></p>
{% else %}
<p>
<img src="{{ user.avatar_url }}" height="20" width="20">
<a href="{{ url_for('user.user_package', package=package)}}">ADD to my packages</a><br></p>
{% endif %}
{% endif %}
{% if package == "__all__" %}
<p>Download stats for __all__ indicate downloads across all packages on PyPI.</p>
<br>

View File

@@ -0,0 +1,33 @@
{% extends "layout.html" %}
{% block title %}PyPI Stats{% endblock %}
{% block body %}
{% if user %}
<h1><img src="{{ user.avatar_url }}" height="30" width="30">
{{ user.username }}'s Packages</h1>
<hr>
<p>Currently saved packages.</p>
{% if user.favorites %}
<p>
<ul>
{% for package in user.favorites %}
<li>
<a href="{{ url_for('general.package', package=package) }}">{{ package }}</a>
</li>
{% endfor %}
</ul>
<!-- <table> {% for package in user.favorites %} <tr> <td> <a href="{{ url_for('general.package', package=package) }}">{{ package }}</a> </td> <td> <a href="{{ url_for('user.user_package', package=package) }}">REMOVE</a> </td> </tr> {% endfor %}
</table> -->
</p>
{% else %}
<p>Not tracking any packages.</p>
{% endif %}
{% else %}
<h1>My Packages</h1>
<hr>
<p>Login with GitHub OAuth to track your own set of packages.</p>
<p>
<a href="{{ url_for('user.login') }}">Login</a>
</p>
{% endif %}
{% endblock %}

View File

@@ -1,7 +1,9 @@
"""JSON API routes."""
from flask import abort
from flask import Blueprint
from flask import g
from flask import jsonify
from flask import render_template
from flask import request
from pypistats.models.download import OverallDownloadCount
@@ -15,6 +17,12 @@ from pypistats.models.download import SystemDownloadCount
blueprint = Blueprint('api', __name__, url_prefix='/api')
@blueprint.route("/")
def api():
"""Get API documentation."""
return render_template("api.html", user=g.user)
@blueprint.route("/<package>/recent")
def api_downloads_recent(package):
"""Get the recent downloads of a package."""
@@ -99,7 +107,7 @@ def generic_downloads(model, package, arg, name):
category = request.args.get(f"{arg}")
if category is not None:
downloads = model.query.\
filter_by(package=package, category=category).\
filter_by(package=package, category=category.lower()).\
order_by(model.date).all()
else:
downloads = model.query.\

View File

@@ -4,6 +4,7 @@ import os
from flask import Blueprint
from flask import current_app
from flask import g
from flask import json
from flask import redirect
from flask import render_template
@@ -44,7 +45,7 @@ def index():
if form.validate_on_submit():
package = form.name.data
return redirect(f"/search/{package}")
return render_template("index.html", form=form)
return render_template("index.html", form=form, user=g.user)
@blueprint.route("/search/<package>", methods=("GET", "POST"))
@@ -61,14 +62,14 @@ def search(package):
limit(20).all()
packages = [r.package for r in results]
return render_template(
"search.html", search=True, form=form, packages=packages
"search.html", search=True, form=form, packages=packages, user=g.user
)
@blueprint.route("/about")
def about():
"""Render the about page."""
return render_template("about.html")
return render_template("about.html", user=g.user)
@blueprint.route("/package/<package>")
@@ -125,7 +126,8 @@ def package(package):
package=package,
plots=plots,
metadata=metadata,
recent=recent
recent=recent,
user=g.user
)
@@ -159,7 +161,7 @@ def top():
"downloads": d.downloads,
} for d in downloads]
})
return render_template("top.html", top=top)
return render_template("top.html", top=top, user=g.user)
@blueprint.route("/status")

View File

@@ -1,61 +1,21 @@
"""User page for tracking packages."""
from flask import abort
from flask import Blueprint
from flask import flash
from flask import g
from flask import redirect
from flask import render_template
from flask import request
from flask import session
from flask import url_for
from pypistats.extensions import db
from pypistats.extensions import github
from pypistats.models.download import RecentDownloadCount
from pypistats.models.user import MAX_FAVORITES
from pypistats.models.user import User
blueprint = Blueprint('user', __name__, template_folder='templates')
@blueprint.route("/user/<user>")
def user(user):
"""Render the user's personal page."""
return user + "'s page"
@blueprint.route("/user/<user>/package/<package>", methods=['POST', 'DELETE'])
def user_package(user):
"""Handle adding and deleting packages to user's list."""
return "SOMETHING"
@blueprint.route('/login')
def login():
"""Login."""
return github.authorize()
@blueprint.route('/logout')
def logout():
"""Logout."""
session.pop('user_id', None)
return redirect(url_for('general.index'))
@blueprint.route('/github-callback')
@github.authorized_handler
def authorized(oauth_token):
"""Github authorization callback."""
next_url = request.args.get('next') or url_for('index')
if oauth_token is None:
flash("Authorization failed.")
return redirect(next_url)
user = User.query.filter_by(token=oauth_token).first()
if user is None:
user = User(oauth_token)
db.add(user)
user.github_access_token = oauth_token
db.commit()
return redirect(next_url)
blueprint = Blueprint("user", __name__, template_folder="templates")
@github.access_token_getter
@@ -63,4 +23,113 @@ def token_getter():
"""Get the token for a user."""
user = g.user
if user is not None:
return user.github_access_token
return user.token
@blueprint.route("/github-callback")
@github.authorized_handler
def authorized(oauth_token):
"""Github authorization callback."""
next_url = request.args.get("next") or url_for("user.user")
if oauth_token is None:
flash("Authorization failed.")
return redirect(next_url)
# Ensure a user with token doesn't already exist
user = User.query.filter_by(token=oauth_token).first()
if user is None:
user = User(token=oauth_token)
# Set this to use API to get user data
g.user = user
user_data = github.get("user")
# extract data
uid = user_data["id"]
username = user_data["login"]
avatar_url = user_data["avatar_url"]
# Create/update the user
user = User.query.filter_by(uid=uid).first()
if user is None:
user = User(
token=oauth_token,
uid=uid,
username=username,
avatar_url=avatar_url,
)
else:
user.username = username
user.avatar_url = avatar_url
user.token = oauth_token
user.save()
session["username"] = user.username
session["user_id"] = user.id
g.user = user
return redirect(next_url)
@blueprint.route("/login")
def login():
"""Login via GitHub OAuth."""
if session.get("user_id", None) is None:
return github.authorize()
else:
return redirect(url_for("user.user"))
@blueprint.route("/logout")
def logout():
"""Logout."""
session.pop("user_id", None)
session.pop("username", None)
g.user = None
return redirect(url_for("general.index"))
@blueprint.route("/user")
def user():
"""Render the user's personal page."""
return render_template("user.html", user=g.user)
@blueprint.route("/user/package/<package>")
def user_package(package):
"""Handle adding and deleting packages to user's list."""
if g.user:
# Ensure package is valid.
downloads = RecentDownloadCount.query.filter_by(package=package).all()
if downloads is None:
return abort(400)
# Handle add/remove to favorites
if g.user.favorites is None:
g.user.favorites = [package]
g.user.update()
return redirect(url_for("user.user"))
elif package in g.user.favorites:
favorites = g.user.favorites
favorites.remove(package)
# Workaround for sqlalchemy mutable ARRAY types
g.user.favorites = None
g.user.save()
g.user.favorites = favorites
g.user.save()
return redirect(url_for("user.user"))
else:
if len(g.user.favorites) < MAX_FAVORITES:
favorites = g.user.favorites
favorites.append(package)
favorites = sorted(favorites)
# Workaround for sqlalchemy mutable ARRAY types
g.user.favorites = None
g.user.save()
g.user.favorites = favorites
g.user.save()
return redirect(url_for("user.user"))
else:
return f"Maximum package number reached ({MAX_FAVORITES})."
return abort(400)