Initial pass on API support

Adds an API for working with trackers
This commit is contained in:
Drew DeVault 2019-04-30 15:11:36 -04:00
parent 060eea1c1a
commit 785574871c
10 changed files with 256 additions and 64 deletions

View File

@ -24,12 +24,12 @@ def get_tracker(owner, name, with_for_update=False, user=None):
if not owner:
return None, None
if owner.startswith("~"):
owner = owner[1:]
owner = User.query.filter(User.username == owner).one_or_none()
if not owner:
return None, None
if not isinstance(owner, User):
if owner.startswith("~"):
owner = owner[1:]
owner = User.query.filter(User.username == owner).one_or_none()
if not owner:
return None, None
tracker = (Tracker.query
.filter(Tracker.owner_id == owner.id)
.filter(Tracker.name == name.lower()))

View File

@ -0,0 +1,30 @@
"""Add OAuthToken table
Revision ID: 0ba3b223e552
Revises: 75ff2f7624fd
Create Date: 2019-04-30 14:13:01.065669
"""
# revision identifiers, used by Alembic.
revision = '0ba3b223e552'
down_revision = '75ff2f7624fd'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table('oauthtoken',
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("created", sa.DateTime, nullable=False),
sa.Column("updated", sa.DateTime, nullable=False),
sa.Column("expires", sa.DateTime, nullable=False),
sa.Column("token_hash", sa.String(128), nullable=False),
sa.Column("token_partial", sa.String(8), nullable=False),
sa.Column("scopes", sa.String(512), nullable=False),
sa.Column("user_id", sa.Integer, sa.ForeignKey('user.id')))
def downgrade():
op.drop_table('oauthtoken')

View File

@ -0,0 +1,37 @@
import pkg_resources
from todosrht.types import User
from srht.flask import csrf_bypass
from srht.oauth import current_token, oauth
def get_user(username):
user = None
if username == None:
user = current_token.user
elif username.startswith("~"):
user = User.query.filter(User.username == username[1:]).one_or_none()
if not user:
abort(404)
return user
def register_api(app):
from todosrht.blueprints.api.trackers import trackers
trackers = csrf_bypass(trackers)
app.register_blueprint(trackers)
@app.route("/api/version")
def version():
try:
dist = pkg_resources.get_distribution("todosrht")
return { "version": dist.version }
except:
return { "version": "unknown" }
@app.route("/api/user/<username>")
@app.route("/api/user", defaults={"username": None})
@oauth(None)
def user_GET(username):
if username == None:
return current_token.user.to_dict()
return get_user(username).to_dict()

View File

@ -0,0 +1,78 @@
from flask import Blueprint, abort, request
from srht.api import paginated_response
from srht.database import db
from srht.oauth import oauth, current_token
from srht.validation import Validation
from todosrht.access import get_tracker
from todosrht.blueprints.api import get_user
from todosrht.types import Tracker, TicketAccess
trackers = Blueprint("api.trackers", __name__)
@trackers.route("/api/user/<username>/trackers")
@trackers.route("/api/trackers", defaults={"username": None})
@oauth("trackers:read")
def user_trackers_GET(username):
user = get_user(username)
trackers = Tracker.query.filter(Tracker.owner_id == user.id)
if current_token.user_id != user.id:
# TODO: proper ACLs
trackers = trackers.filter(Tracker.default_user_perms > 0)
return paginated_response(Tracker.id, trackers)
@trackers.route("/api/trackers", methods=["POST"])
@oauth("trackers:write")
def user_trackers_POST():
valid = Validation(request)
tracker = Tracker(current_token.user, valid)
if not valid.ok:
return valid.response
db.session.add(tracker)
db.session.commit()
return tracker.to_dict(), 201
@trackers.route("/api/user/<username>/trackers/<tracker_name>")
@trackers.route("/api/trackers/<tracker_name>", defaults={"username": None})
@oauth("trackers:read")
def user_tracker_by_name_GET(username, tracker_name):
user = get_user(username)
tracker, access = get_tracker(user, tracker_name, user=current_token.user)
if not tracker:
abort(404)
if not TicketAccess.browse in access:
abort(401)
return tracker.to_dict()
@trackers.route("/api/user/<username>/trackers/<tracker_name>", methods=["PUT"])
@trackers.route("/api/trackers/<tracker_name>",
defaults={"username": None}, methods=["PUT"])
@oauth("trackers:write")
def user_tracker_by_name_PUT(username, tracker_name):
user = get_user(username)
tracker, access = get_tracker(user, tracker_name, user=current_token.user)
if not tracker:
abort(404)
if tracker.owner_id != current_token.user_id:
abort(401)
valid = Validation(request)
tracker.update(valid)
if not valid.ok:
return valid.response
db.session.commit()
return tracker.to_dict()
@trackers.route("/api/user/<username>/trackers/<tracker_name>",
methods=["DELETE"])
@trackers.route("/api/trackers/<tracker_name>",
defaults={"username": None}, methods=["DELETE"])
@oauth("trackers:write")
def user_tracker_by_name_DELETE(username, tracker_name):
user = get_user(username)
tracker, access = get_tracker(user, tracker_name, user=current_token.user)
if not tracker:
abort(404)
if tracker.owner_id != current_token.user_id:
abort(401)
db.session.delete(tracker)
db.session.commit()
return {}, 204

View File

@ -1,5 +1,3 @@
import re
import string
from flask import Blueprint, render_template, request, url_for, abort, redirect
from flask import session
from flask_login import current_user
@ -21,8 +19,6 @@ from sqlalchemy.orm import subqueryload
tracker = Blueprint("tracker", __name__)
name_re = re.compile(r"^([a-z][a-z0-9_.-]*?)+$")
smtp_user = cfg("mail", "smtp-user", default=None)
smtp_from = cfg("mail", "smtp-from", default=None)
notify_from = cfg("todo.sr.ht", "notify-from", default=smtp_from)
@ -36,39 +32,11 @@ def create_GET():
@loginrequired
def create_POST():
valid = Validation(request)
name = valid.require("tracker_name", friendly_name="Name")
desc = valid.optional("tracker_desc")
tracker = Tracker(current_user, valid)
if not valid.ok:
return render_template("tracker-create.html", **valid.kwargs), 400
valid.expect(2 < len(name) < 256,
"Must be between 2 and 256 characters",
field="tracker_name")
valid.expect(not valid.ok or name[0] in string.ascii_lowercase,
"Must begin with a lowercase letter", field="tracker_name")
valid.expect(not valid.ok or name_re.match(name),
"Only lowercase alphanumeric characters or -.",
field="tracker_name")
valid.expect(not desc or len(desc) < 4096,
"Must be less than 4096 characters",
field="tracker_desc")
if not valid.ok:
return render_template("tracker-create.html", **valid.kwargs), 400
tracker = (Tracker.query
.filter(Tracker.owner_id == current_user.id)
.filter(Tracker.name == name)
).first()
valid.expect(not tracker,
"A tracker by this name already exists",
field="tracker_name")
if not valid.ok:
return render_template("tracker-create.html", **valid.kwargs), 400
tracker = Tracker()
tracker.owner_id = current_user.id
tracker.name = name
tracker.description = desc
db.session.add(tracker)
db.session.flush()

View File

@ -3,16 +3,9 @@ from srht.database import db
from srht.flask import SrhtFlask
from srht.oauth import AbstractOAuthService
from todosrht import urls, filters
from todosrht.service import TodoOAuthService
from todosrht.types import EventType
from todosrht.types import TicketAccess, TicketStatus, TicketResolution
from todosrht.types import User
client_id = cfg("todo.sr.ht", "oauth-client-id")
client_secret = cfg("todo.sr.ht", "oauth-client-secret")
class TodoOAuthService(AbstractOAuthService):
def __init__(self):
super().__init__(client_id, client_secret, user_class=User)
from todosrht.types import TicketAccess, TicketStatus, TicketResolution, User
class TodoApp(SrhtFlask):
def __init__(self):
@ -21,10 +14,12 @@ class TodoApp(SrhtFlask):
self.url_map.strict_slashes = False
from todosrht.blueprints.api import register_api
from todosrht.blueprints.html import html
from todosrht.blueprints.tracker import tracker
from todosrht.blueprints.ticket import ticket
register_api(self)
self.register_blueprint(html)
self.register_blueprint(tracker)
self.register_blueprint(ticket)

18
todosrht/service.py Normal file
View File

@ -0,0 +1,18 @@
from todosrht.types import User, OAuthToken
from srht.database import db
from srht.config import cfg, get_origin
from srht.oauth import AbstractOAuthService, DelegatedScope
origin = cfg("todo.sr.ht", "origin")
client_id = cfg("todo.sr.ht", "oauth-client-id")
client_secret = cfg("todo.sr.ht", "oauth-client-secret")
class TodoOAuthService(AbstractOAuthService):
def __init__(self):
super().__init__(client_id, client_secret,
delegated_scopes=[
DelegatedScope("events", "events", False),
DelegatedScope("trackers", "trackers", True),
DelegatedScope("tickets", "tickets", True),
],
token_class=OAuthToken, user_class=User)

View File

@ -14,29 +14,29 @@
<form method="POST" action="/tracker/create">
{{csrf_token()}}
<div class="form-group">
<label for="tracker_name">Name</label>
<label for="name">Name</label>
<input
type="text"
name="tracker_name"
id="tracker_name"
class="form-control {{valid.cls("tracker_name")}}"
value="{{ tracker_name or "" }}"
aria-describedby="tracker_name-help" />
{{valid.summary("tracker_name")}}
name="name"
id="name"
class="form-control {{valid.cls("name")}}"
value="{{ name or "" }}"
aria-describedby="name-help" />
{{valid.summary("name")}}
</div>
<div class="form-group">
<label for="tracker_desc">Description</label>
<label for="description">Description</label>
<textarea
name="tracker_desc"
id="tracker_desc"
class="form-control {{valid.cls("tracker_desc")}}"
value="{{ tracker_desc or "" }}"
name="description"
id="description"
class="form-control {{valid.cls("description")}}"
value="{{ description or "" }}"
rows="5"
aria-describedby="tracker_desc-help">{{tracker_desc or ""}}</textarea>
<p id="tracker_desc-help" class="form-text text-muted">
aria-describedby="description-help">{{description or ""}}</textarea>
<p id="description-help" class="form-text text-muted">
Markdown supported
</p>
{{valid.summary("tracker_desc")}}
{{valid.summary("description")}}
</div>
{{valid.summary()}}
<button

View File

@ -1,9 +1,13 @@
from srht.database import Base
from srht.oauth import ExternalUserMixin
from srht.oauth import ExternalOAuthTokenMixin
class User(Base, ExternalUserMixin):
pass
class OAuthToken(Base, ExternalOAuthTokenMixin):
pass
from todosrht.types.ticketaccess import TicketAccess
from todosrht.types.ticketstatus import TicketStatus, TicketResolution
from todosrht.types.tracker import Tracker

View File

@ -1,8 +1,12 @@
import re
import sqlalchemy as sa
import string
from srht.database import Base
from srht.flagtype import FlagType
from todosrht.types import TicketAccess, TicketStatus, TicketResolution
name_re = re.compile(r"^([a-z][a-z0-9_.-]*?)+$")
class Tracker(Base):
__tablename__ = 'tracker'
id = sa.Column(sa.Integer, primary_key=True)
@ -50,5 +54,63 @@ class Tracker(Base):
default=TicketAccess.browse)
"""Permissions granted to anonymous (non-logged in) users"""
def __init__(self, user, valid):
name = valid.require("name", friendly_name="Name")
desc = valid.optional("description")
if not valid.ok:
return
valid.expect(2 < len(name) < 256,
"Must be between 2 and 256 characters",
field="name")
valid.expect(not valid.ok or name[0] in string.ascii_lowercase,
"Must begin with a lowercase letter", field="name")
valid.expect(not valid.ok or name_re.match(name),
"Only lowercase alphanumeric characters or -.",
field="name")
valid.expect(not desc or len(desc) < 4096,
"Must be less than 4096 characters",
field="description")
if not valid.ok:
return
tracker = (Tracker.query
.filter(Tracker.owner_id == user.id)
.filter(Tracker.name == name)
).first()
valid.expect(not tracker,
"A tracker by this name already exists", field="name")
if not valid.ok:
return
self.owner_id = user.id
self.name = name
self.description = desc
def __repr__(self):
return '<Tracker {} {}>'.format(self.id, self.name)
def to_dict(self):
def permissions(w):
return [p.name for p in TicketAccess
if p in w and p not in [TicketAccess.none, TicketAccess.all]]
return {
"id": self.id,
"owner": self.owner.to_dict(short=True),
"created": self.created,
"updated": self.updated,
"name": self.name,
"description": self.description,
"default_permissions": {
"anonymous": permissions(self.default_anonymous_perms),
"submitter": permissions(self.default_submitter_perms),
"user": permissions(self.default_user_perms),
},
}
def update(self, valid):
desc = valid.optional("description", default=self.description)
valid.expect(not desc or len(desc) < 4096,
"Must be less than 4096 characters",
field="description")
self.description = desc