mirror of
https://github.com/minetest/contentdb.git
synced 2024-12-22 22:12:24 +01:00
Add API Token creation
This commit is contained in:
parent
cb5451fe5d
commit
4ce388c8aa
@ -14,87 +14,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask_user import *
|
||||
from app.models import *
|
||||
from app.utils import is_package_page
|
||||
from app.querybuilder import QueryBuilder
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint("api", __name__)
|
||||
|
||||
@bp.route("/api/packages/")
|
||||
def packages():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
ver = qb.getMinetestVersion()
|
||||
|
||||
pkgs = [package.getAsDictionaryShort(current_app.config["BASE_URL"], version=ver) \
|
||||
for package in query.all()]
|
||||
return jsonify(pkgs)
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
def package(package):
|
||||
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/dependencies/")
|
||||
@is_package_page
|
||||
def package_dependencies(package):
|
||||
ret = []
|
||||
|
||||
for dep in package.dependencies:
|
||||
name = None
|
||||
fulfilled_by = None
|
||||
|
||||
if dep.package:
|
||||
name = dep.package.name
|
||||
fulfilled_by = [ dep.package.getAsDictionaryKey() ]
|
||||
|
||||
elif dep.meta_package:
|
||||
name = dep.meta_package.name
|
||||
fulfilled_by = [ pkg.getAsDictionaryKey() for pkg in dep.meta_package.packages]
|
||||
|
||||
else:
|
||||
raise "Malformed dependency"
|
||||
|
||||
ret.append({
|
||||
"name": name,
|
||||
"is_optional": dep.optional,
|
||||
"packages": fulfilled_by
|
||||
})
|
||||
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@bp.route("/api/topics/")
|
||||
def topics():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildTopicQuery(show_added=True)
|
||||
return jsonify([t.getAsDictionary() for t in query.all()])
|
||||
|
||||
|
||||
@bp.route("/api/topic_discard/", methods=["POST"])
|
||||
@login_required
|
||||
def topic_set_discard():
|
||||
tid = request.args.get("tid")
|
||||
discard = request.args.get("discard")
|
||||
if tid is None or discard is None:
|
||||
abort(400)
|
||||
|
||||
topic = ForumTopic.query.get(tid)
|
||||
if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
|
||||
abort(403)
|
||||
|
||||
topic.discarded = discard == "true"
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(topic.getAsDictionary())
|
||||
|
||||
|
||||
@bp.route("/api/minetest_versions/")
|
||||
def versions():
|
||||
return jsonify([{ "name": rel.name, "protocol_version": rel.protocol }\
|
||||
for rel in MinetestRelease.query.all() if rel.getActual() is not None])
|
||||
from . import tokens, endpoints
|
||||
|
42
app/blueprints/api/auth.py
Normal file
42
app/blueprints/api/auth.py
Normal file
@ -0,0 +1,42 @@
|
||||
# Content DB
|
||||
# Copyright (C) 2019 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import request, make_response, jsonify, abort
|
||||
from app.models import APIToken
|
||||
from functools import wraps
|
||||
|
||||
def is_api_authd(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
token = None
|
||||
|
||||
value = request.headers.get("authorization")
|
||||
if value is None:
|
||||
pass
|
||||
elif value[0:7].lower() == "bearer ":
|
||||
access_token = value[7:]
|
||||
if len(access_token) < 10:
|
||||
abort(400)
|
||||
|
||||
token = APIToken.query.filter_by(access_token=access_token).first()
|
||||
if token is None:
|
||||
abort(403)
|
||||
else:
|
||||
abort(403)
|
||||
|
||||
return f(token=token, *args, **kwargs)
|
||||
|
||||
return decorated_function
|
109
app/blueprints/api/endpoints.py
Normal file
109
app/blueprints/api/endpoints.py
Normal file
@ -0,0 +1,109 @@
|
||||
# Content DB
|
||||
# Copyright (C) 2018 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask_user import *
|
||||
from . import bp
|
||||
from .auth import is_api_authd
|
||||
from app.models import *
|
||||
from app.utils import is_package_page
|
||||
from app.querybuilder import QueryBuilder
|
||||
|
||||
@bp.route("/api/packages/")
|
||||
def packages():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
ver = qb.getMinetestVersion()
|
||||
|
||||
pkgs = [package.getAsDictionaryShort(current_app.config["BASE_URL"], version=ver) \
|
||||
for package in query.all()]
|
||||
return jsonify(pkgs)
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
def package(package):
|
||||
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/dependencies/")
|
||||
@is_package_page
|
||||
def package_dependencies(package):
|
||||
ret = []
|
||||
|
||||
for dep in package.dependencies:
|
||||
name = None
|
||||
fulfilled_by = None
|
||||
|
||||
if dep.package:
|
||||
name = dep.package.name
|
||||
fulfilled_by = [ dep.package.getAsDictionaryKey() ]
|
||||
|
||||
elif dep.meta_package:
|
||||
name = dep.meta_package.name
|
||||
fulfilled_by = [ pkg.getAsDictionaryKey() for pkg in dep.meta_package.packages]
|
||||
|
||||
else:
|
||||
raise "Malformed dependency"
|
||||
|
||||
ret.append({
|
||||
"name": name,
|
||||
"is_optional": dep.optional,
|
||||
"packages": fulfilled_by
|
||||
})
|
||||
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@bp.route("/api/topics/")
|
||||
def topics():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildTopicQuery(show_added=True)
|
||||
return jsonify([t.getAsDictionary() for t in query.all()])
|
||||
|
||||
|
||||
@bp.route("/api/topic_discard/", methods=["POST"])
|
||||
@login_required
|
||||
def topic_set_discard():
|
||||
tid = request.args.get("tid")
|
||||
discard = request.args.get("discard")
|
||||
if tid is None or discard is None:
|
||||
abort(400)
|
||||
|
||||
topic = ForumTopic.query.get(tid)
|
||||
if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
|
||||
abort(403)
|
||||
|
||||
topic.discarded = discard == "true"
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(topic.getAsDictionary())
|
||||
|
||||
|
||||
@bp.route("/api/minetest_versions/")
|
||||
def versions():
|
||||
return jsonify([{ "name": rel.name, "protocol_version": rel.protocol }\
|
||||
for rel in MinetestRelease.query.all() if rel.getActual() is not None])
|
||||
|
||||
|
||||
@bp.route("/api/whoami/")
|
||||
@is_api_authd
|
||||
def whoami(token):
|
||||
if token is None:
|
||||
return jsonify({ "is_authenticated": False, "username": None })
|
||||
else:
|
||||
return jsonify({ "is_authenticated": True, "username": token.owner.username })
|
141
app/blueprints/api/tokens.py
Normal file
141
app/blueprints/api/tokens.py
Normal file
@ -0,0 +1,141 @@
|
||||
# Content DB
|
||||
# Copyright (C) 2018 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import render_template, redirect, request, session, url_for
|
||||
from flask_user import login_required, current_user
|
||||
from . import bp
|
||||
from app.models import db, User, APIToken, Package, Permission
|
||||
from app.utils import randomString
|
||||
from app.querybuilder import QueryBuilder
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
|
||||
class CreateAPIToken(FlaskForm):
|
||||
name = StringField("Name", [InputRequired(), Length(1, 30)])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/users/<username>/tokens/")
|
||||
@login_required
|
||||
def list_tokens(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
return render_template("api/list_tokens.html", user=user)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/tokens/new/", methods=["GET", "POST"])
|
||||
@bp.route("/users/<username>/tokens/<int:id>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_edit_token(username, id=None):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
is_new = id is None
|
||||
|
||||
token = None
|
||||
access_token = None
|
||||
if not is_new:
|
||||
token = APIToken.query.get(id)
|
||||
if token is None:
|
||||
abort(404)
|
||||
elif token.owner != user:
|
||||
abort(403)
|
||||
|
||||
access_token = session.pop("token_" + str(id), None)
|
||||
|
||||
form = CreateAPIToken(formdata=request.form, obj=token)
|
||||
if request.method == "POST" and form.validate():
|
||||
if is_new:
|
||||
token = APIToken()
|
||||
token.owner = user
|
||||
token.access_token = randomString(32)
|
||||
|
||||
form.populate_obj(token)
|
||||
db.session.add(token)
|
||||
|
||||
db.session.commit() # save
|
||||
|
||||
# Store token so it can be shown in the edit page
|
||||
session["token_" + str(token.id)] = token.access_token
|
||||
|
||||
return redirect(url_for("api.create_edit_token", username=username, id=token.id))
|
||||
|
||||
return render_template("api/create_edit_token.html", user=user, form=form, token=token, access_token=access_token)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/tokens/<int:id>/reset/", methods=["POST"])
|
||||
@login_required
|
||||
def reset_token(username, id):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
is_new = id is None
|
||||
|
||||
token = APIToken.query.get(id)
|
||||
if token is None:
|
||||
abort(404)
|
||||
elif token.owner != user:
|
||||
abort(403)
|
||||
|
||||
token.access_token = randomString(32)
|
||||
|
||||
db.session.commit() # save
|
||||
|
||||
# Store token so it can be shown in the edit page
|
||||
session["token_" + str(token.id)] = token.access_token
|
||||
|
||||
return redirect(url_for("api.create_edit_token", username=username, id=token.id))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/tokens/<int:id>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
def delete_token(username, id):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
is_new = id is None
|
||||
|
||||
token = APIToken.query.get(id)
|
||||
if token is None:
|
||||
abort(404)
|
||||
elif token.owner != user:
|
||||
abort(403)
|
||||
|
||||
db.session.delete(token)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("api.list_tokens", username=username))
|
@ -4,3 +4,4 @@ title: Help
|
||||
* [Ranks and Permissions](ranks_permissions)
|
||||
* [Content Ratings and Flags](content_flags)
|
||||
* [Reporting Content](reporting)
|
||||
* [API](api)
|
||||
|
51
app/flatpages/help/api.md
Normal file
51
app/flatpages/help/api.md
Normal file
@ -0,0 +1,51 @@
|
||||
title: API
|
||||
|
||||
## Authentication
|
||||
|
||||
Not all endpoints require authentication.
|
||||
Authentication is done using Bearer tokens:
|
||||
|
||||
Authorization: Bearer YOURTOKEN
|
||||
|
||||
You can use the `/api/whoami` to check authentication.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Misc
|
||||
|
||||
* GET `/api/whoami/` - Json dictionary with the following keys:
|
||||
* `is_authenticated` - True on successful API authentication
|
||||
* `username` - Username of the user authenticated as, null otherwise.
|
||||
* 403 will be thrown on unsupported authentication type, invalid access token, or other errors.
|
||||
|
||||
### Packages
|
||||
|
||||
* GET `/api/packages/` - See [Package Queries](#package-queries)
|
||||
* GET `/api/packages/<username>/<name>/`
|
||||
|
||||
### Topics
|
||||
|
||||
* GET `/api/topics/` - Supports [Package Queries](#package-queries), and the following two options:
|
||||
* `show_added` - Show topics which exist as packages, default true.
|
||||
* `show_discarded` - Show topics which have been marked as outdated, default false.
|
||||
|
||||
### Minetest
|
||||
|
||||
* GET `/api/minetest_versions/`
|
||||
|
||||
|
||||
## Package Queries
|
||||
|
||||
Example:
|
||||
|
||||
/api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore
|
||||
|
||||
Supported query parameters:
|
||||
|
||||
* `type` - Package types (`mod`, `game`, `txp`).
|
||||
* `q` - Query string
|
||||
* `random` - When present, enable random ordering and ignore `sort`.
|
||||
* `hide` - Hide content based on [Content Flags](content_flags).
|
||||
* `sort` - Sort by (`name`, `views`, `date`, `score`).
|
||||
* `order` - Sort ascending (`Asc`) or descending (`desc`).
|
||||
* `protocol_version` - Only show packages supported by this Minetest protocol version.
|
@ -219,6 +219,21 @@ title: Ranks and Permissions
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Create Token</td>
|
||||
<th></th> <!-- new -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- trusted member -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- editor -->
|
||||
<th></th>
|
||||
<th>✓</th> <!-- moderator -->
|
||||
<th>✓<sup>2</sup></th>
|
||||
<th>✓</th> <!-- admin -->
|
||||
<th>✓</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Set Rank</td>
|
||||
<th></th> <!-- new -->
|
||||
|
@ -92,6 +92,7 @@ class Permission(enum.Enum):
|
||||
CREATE_THREAD = "CREATE_THREAD"
|
||||
UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE"
|
||||
TOPIC_DISCARD = "TOPIC_DISCARD"
|
||||
CREATE_TOKEN = "CREATE_TOKEN"
|
||||
|
||||
# Only return true if the permission is valid for *all* contexts
|
||||
# See Package.checkPerm for package-specific contexts
|
||||
@ -142,6 +143,7 @@ class User(db.Model, UserMixin):
|
||||
packages = db.relationship("Package", backref="author", lazy="dynamic")
|
||||
requests = db.relationship("EditRequest", backref="author", lazy="dynamic")
|
||||
threads = db.relationship("Thread", backref="author", lazy="dynamic")
|
||||
tokens = db.relationship("APIToken", backref="owner", lazy="dynamic")
|
||||
replies = db.relationship("ThreadReply", backref="author", lazy="dynamic")
|
||||
|
||||
def __init__(self, username, active=False, email=None, password=None):
|
||||
@ -183,6 +185,11 @@ class User(db.Model, UserMixin):
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
elif perm == Permission.CHANGE_EMAIL:
|
||||
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank))
|
||||
elif perm == Permission.CREATE_TOKEN:
|
||||
if user == self:
|
||||
return user.rank.atLeast(UserRank.MEMBER)
|
||||
else:
|
||||
return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)
|
||||
else:
|
||||
raise Exception("Permission {} is not related to users".format(perm.name))
|
||||
|
||||
@ -776,6 +783,16 @@ class PackageScreenshot(db.Model):
|
||||
return self.url.replace("/uploads/", ("/thumbnails/{:d}/").format(level))
|
||||
|
||||
|
||||
class APIToken(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
access_token = db.Column(db.String(34), unique=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
def canOperateOnPackage(self, package):
|
||||
return packages.count() == 0 or package in packages
|
||||
|
||||
|
||||
class EditRequest(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
53
app/templates/api/create_edit_token.html
Normal file
53
app/templates/api/create_edit_token.html
Normal file
@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% if token %}
|
||||
{{ _("Edit - %(name)s", name=token.name) }}
|
||||
{% else %}
|
||||
{{ _("Create API Token") }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% from "macros/forms.html" import render_field, render_submit_field, render_radio_field %}
|
||||
|
||||
{% block content %}
|
||||
{% if token %}
|
||||
<form class="float-right" method="POST" action="{{ url_for('api.delete_token', username=token.owner.username, id=token.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input class="btn btn-danger" type="submit" value="Delete">
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h1 class="mt-0">{{ self.title() }}</h1>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
{{ _("Use carefully, as you may be held responsible for any damage caused by rogue scripts") }}
|
||||
</div>
|
||||
|
||||
{% if token %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">{{ _("Access Token") }}</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
For security reasons, access tokens will only be shown once.
|
||||
Reset the token if it is lost.
|
||||
</p>
|
||||
{% if access_token %}
|
||||
<input class="form-control my-3" type="text" readonly value="{{ access_token }}" class="form-control">
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('api.reset_token', username=token.owner.username, id=token.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input class="btn btn-primary" type="submit" value="Reset">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
{{ render_field(form.name, placeholder="Human readable") }}
|
||||
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</form>
|
||||
{% endblock %}
|
23
app/templates/api/list_tokens.html
Normal file
23
app/templates/api/list_tokens.html
Normal file
@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ _("List tokens for %(username)s", username=user.username) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<a class="btn btn-primary float-right" href="{{ url_for('api.create_edit_token', username=user.username) }}">Create</a>
|
||||
<h1 class="mt-0">{{ self.title() }}</h1>
|
||||
|
||||
<ul>
|
||||
{% for token in user.tokens %}
|
||||
<li>
|
||||
<a href="{{ url_for('api.create_edit_token', username=user.username, id=token.id) }}">{{ token.name }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<i>No tokens created</i>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
@ -127,6 +127,15 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if user.checkPerm(current_user, "CREATE_TOKEN") %}
|
||||
<tr>
|
||||
<td>API Tokens:</td>
|
||||
<td>
|
||||
<a href="{{ url_for('api.list_tokens', username=user.username) }}">Manage</a>
|
||||
<span class="badge badge-primary">{{ user.tokens.count() }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
37
migrations/versions/fd25bf3e57c3_.py
Normal file
37
migrations/versions/fd25bf3e57c3_.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: fd25bf3e57c3
|
||||
Revises: d6ae9682c45f
|
||||
Create Date: 2019-11-26 23:43:47.476346
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'fd25bf3e57c3'
|
||||
down_revision = 'd6ae9682c45f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('api_token',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('access_token', sa.String(length=34), nullable=True),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('access_token')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('api_token')
|
||||
# ### end Alembic commands ###
|
Loading…
Reference in New Issue
Block a user