Initial pass on API support
Adds an API for working with trackers
This commit is contained in:
parent
060eea1c1a
commit
785574871c
|
@ -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()))
|
||||
|
|
|
@ -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')
|
|
@ -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()
|
|
@ -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
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue