todosrht: Use GraphQL for tracker CRUD operations

Use the GraphQL API for tracker CRUD operations so that GraphQL-native
user webhooks will be delivered.
This commit is contained in:
Adnan Maolood 2022-04-04 15:14:50 -04:00 committed by Drew DeVault
parent ae7916d7fd
commit ebb55fc95a
6 changed files with 218 additions and 173 deletions

View File

@ -1,11 +1,12 @@
from datetime import datetime, timezone
from flask import Blueprint, abort, request
from flask import Blueprint, current_app, abort, request
from srht.api import paginated_response
from srht.database import db
from srht.graphql import exec_gql
from srht.oauth import oauth, current_token
from srht.validation import Validation, valid_url
from todosrht.access import get_tracker, get_ticket
from todosrht.tickets import add_comment, submit_ticket
from todosrht.tickets import add_comment
from todosrht.tickets import get_participant_for_user, get_participant_for_external
from todosrht.blueprints.api import get_user
from todosrht.types import Ticket, TicketAccess, TicketStatus, TicketResolution
@ -79,17 +80,26 @@ def tracker_tickets_POST(username, tracker_name):
if not valid.ok:
return valid.response
if external_id:
participant = get_participant_for_external(external_id, external_url)
else:
participant = get_participant_for_user(current_token.user)
input = {
"subject": title,
"body": desc,
"created": created,
"externalId": external_id,
"externalUrl": external_url,
}
ticket = submit_ticket(tracker, participant, title, desc)
if created:
ticket._no_autoupdate = True
ticket.created = created
ticket.updated = created
db.session.commit()
resp = exec_gql(current_app.site, """
mutation SubmitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
submitTicket(trackerId: $trackerId, input: $input) {
id
}
}
""", user=user, valid=valid, trackerId=tracker.id, input=input)
if not valid.ok:
return valid.response
ticket, _ = get_ticket(tracker, resp["submitTicket"]["id"])
TrackerWebhook.deliver(TrackerWebhook.Events.ticket_create,
ticket.to_dict(),

View File

@ -1,13 +1,14 @@
from flask import Blueprint, abort, request
from flask import Blueprint, current_app, abort, request
from srht.api import paginated_response
from srht.database import db
from srht.graphql import exec_gql
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.tickets import get_participant_for_user
from todosrht.types import Label, Tracker, TicketAccess, TicketSubscription
from todosrht.webhooks import UserWebhook, TrackerWebhook
from todosrht.webhooks import TrackerWebhook
trackers = Blueprint("api_trackers", __name__)
@ -26,24 +27,53 @@ def user_trackers_GET(username):
@oauth("trackers:write")
def user_trackers_POST():
user = current_token.user
tracker, valid = Tracker.create_from_request(request, user)
valid = Validation(request)
name = valid.require("name", friendly_name="Name")
description = valid.optional("description")
visibility = valid.require("visibility")
if not valid.ok:
return valid.response
db.session.add(tracker)
db.session.commit()
UserWebhook.deliver(UserWebhook.Events.tracker_create,
tracker.to_dict(),
UserWebhook.Subscription.user_id == tracker.owner_id)
# Auto-subscribe the owner
sub = TicketSubscription()
participant = get_participant_for_user(user)
sub.tracker_id = tracker.id
sub.participant_id = participant.id
db.session.add(sub)
db.session.commit()
resp = exec_gql(current_app.site, """
mutation CreateTracker($name: String!, $description: String, $visibility: Visibility!) {
createTracker(name: $name, description: $description, visibility: $visibility) {
id
created
updated
owner {
canonical_name: canonicalName
... on User {
name: username
}
}
name
description
defaultACL {
browse
submit
comment
edit
triage
}
visibility
}
}
""", user=user, valid=valid, name=name, description=description, visibility=visibility)
return tracker.to_dict(), 201
if not valid.ok:
return valid.response
resp = resp["createTracker"]
# Build default_access list
resp["default_access"] = [
key for key in [
"browse", "submit", "comment", "edit", "triage"
] if resp["defaultACL"][key]
]
del resp["defaultACL"]
return resp, 201
@trackers.route("/api/user/<username>/trackers/<tracker_name>")
@trackers.route("/api/trackers/<tracker_name>", defaults={"username": None})
@ -93,15 +123,55 @@ def user_tracker_by_name_PUT(username, tracker_name):
abort(404)
if tracker.owner_id != current_token.user_id:
abort(401)
valid = Validation(request)
tracker.update(valid)
rewrite = lambda value: None if value == "" else value
input = {
key: rewrite(valid.source[key]) for key in [
"description"
] if valid.source.get(key) is not None
}
resp = exec_gql(current_app.site, """
mutation UpdateTracker($id: Int!, $input: TrackerInput!) {
updateTracker(id: $id, input: $input) {
id
created
updated
owner {
canonical_name: canonicalName
... on User {
name: username
}
}
name
description
defaultACL {
browse
submit
comment
edit
triage
}
visibility
}
}
""", user=user, valid=valid, id=tracker.id, input=input)
if not valid.ok:
return valid.response
db.session.commit()
UserWebhook.deliver(UserWebhook.Events.tracker_update,
tracker.to_dict(),
UserWebhook.Subscription.user_id == tracker.owner_id)
return tracker.to_dict()
resp = resp["updateTracker"]
# Build default_access list
resp["default_access"] = [
key for key in [
"browse", "submit", "comment", "edit", "triage"
] if resp["defaultACL"][key]
]
del resp["defaultACL"]
return resp
@trackers.route("/api/user/<username>/trackers/<tracker_name>",
methods=["DELETE"])
@ -115,17 +185,11 @@ def user_tracker_by_name_DELETE(username, tracker_name):
abort(404)
if tracker.owner_id != current_token.user_id:
abort(401)
# SQLAlchemy shits itself on some of our weird constraints/relationships
# so fuck it, postgres knows what to do here
tracker_id = tracker.id
owner_id = tracker.owner_id
assert isinstance(tracker_id, int)
db.session.expunge_all()
db.engine.execute(f"DELETE FROM tracker WHERE id = {tracker_id};")
db.session.commit()
UserWebhook.deliver(UserWebhook.Events.tracker_delete,
{ "id": tracker_id },
UserWebhook.Subscription.user_id == owner_id)
exec_gql(current_app.site, """
mutation DeleteTracker($id: Int!) {
deleteTracker(id: $id) { id }
}
""", user=user, id=tracker.id);
return {}, 204
@trackers.route("/api/user/<username>/trackers/<tracker_name>/labels")

View File

@ -2,13 +2,14 @@ import gzip
import json
import os
from collections import OrderedDict
from flask import Blueprint, render_template, request, url_for, abort, redirect
from flask import Blueprint, current_app, render_template, request, url_for, abort, redirect
from flask import current_app, send_file
from srht.config import get_origin
from srht.crypto import sign_payload
from srht.database import db
from srht.oauth import current_user, loginrequired
from srht.flask import date_handler, session
from srht.graphql import exec_gql
from srht.validation import Validation
from tempfile import NamedTemporaryFile
from todosrht.access import get_tracker
@ -16,7 +17,6 @@ from todosrht.trackers import get_recent_users
from todosrht.types import Event, EventType, Ticket, TicketAccess, Visibility
from todosrht.types import ParticipantType, UserAccess, User
from todosrht.urls import tracker_url
from todosrht.webhooks import UserWebhook
from todosrht.tracker_import import tracker_import
settings = Blueprint("settings", __name__)
@ -63,24 +63,33 @@ def details_POST(owner, name):
abort(403)
valid = Validation(request)
desc = valid.optional("tracker_desc", default=tracker.description)
vis = valid.require("visibility", cls=Visibility)
valid.expect(not desc or len(desc) < 4096,
"Must be less than 4096 characters",
field="tracker_desc")
rewrite = lambda value: None if value == "" else value
input = {
key: rewrite(valid.source[key]) for key in [
"description", "visibility",
] if valid.source.get(key) is not None
}
resp = exec_gql(current_app.site, """
mutation UpdateTracker($id: Int!, $input: TrackerInput!) {
updateTracker(id: $id, input: $input) {
name
owner {
canonicalName
}
}
}
""", valid=valid, id=tracker.id, input=input)
if not valid.ok:
return render_template("tracker-details.html",
tracker=tracker, **valid.kwargs), 400
tracker.description = desc
tracker.visibility = vis
resp = resp["updateTracker"]
UserWebhook.deliver(UserWebhook.Events.tracker_update,
tracker.to_dict(),
UserWebhook.Subscription.user_id == tracker.owner_id)
db.session.commit()
return redirect(tracker_url(tracker))
return redirect(url_for("settings.details_GET",
owner=resp["owner"]["canonicalName"],
name=resp["name"]))
def render_tracker_access(tracker, **kwargs):
@ -111,17 +120,23 @@ def access_POST(owner, name):
valid = Validation(request)
access = parse_html_perms('default', valid)
input = {
perm: ((access & TicketAccess[perm].value) != 0) for perm in [
"browse", "submit", "comment", "edit", "triage",
]
}
resp = exec_gql(current_app.site, """
mutation updateTrackerACL($id: Int!, $input: ACLInput!) {
updateTrackerACL(trackerId: $id, input: $input) {
browse
}
}
""", valid=valid, id=tracker.id, input=input)
if not valid.ok:
return render_tracker_access(tracker, **valid.kwargs), 400
tracker.default_access = access
UserWebhook.deliver(UserWebhook.Events.tracker_update,
tracker.to_dict(),
UserWebhook.Subscription.user_id == tracker.owner_id)
db.session.commit()
return redirect(tracker_url(tracker))
@settings.route("/<owner>/<name>/settings/user-access/create", methods=["POST"])
@ -198,20 +213,14 @@ def delete_POST(owner, name):
abort(404)
if current_user.id != tracker.owner_id:
abort(403)
exec_gql(current_app.site, """
mutation DeleteTracker($id: Int!) {
deleteTracker(id: $id) { id }
}
""", id=tracker.id);
session["notice"] = f"{tracker.owner}/{tracker.name} was deleted."
# SQLAlchemy shits itself on some of our weird constraints/relationships
# so fuck it, postgres knows what to do here
tracker_id = tracker.id
owner_id = tracker.owner_id
assert isinstance(tracker_id, int)
db.session.expunge_all()
db.engine.execute(f"DELETE FROM tracker WHERE id = {tracker_id};")
db.session.commit()
UserWebhook.deliver(UserWebhook.Events.tracker_delete,
{ "id": tracker_id },
UserWebhook.Subscription.user_id == owner_id)
return redirect(url_for("html.index_GET"))
@settings.route("/<owner>/<name>/settings/import-export")

View File

@ -1,20 +1,21 @@
from flask import Blueprint, render_template, request, url_for, abort, redirect
from flask import Blueprint, current_app, render_template, request, url_for, abort, redirect
from srht.config import cfg
from srht.database import db
from srht.flask import paginate_query, session
from srht.graphql import exec_gql
from srht.oauth import current_user, loginrequired
from srht.validation import Validation
from todosrht.access import get_tracker
from todosrht.access import get_tracker, get_ticket
from todosrht.color import color_from_hex, color_to_hex, get_text_color
from todosrht.color import valid_hex_color_code
from todosrht.filters import render_markup
from todosrht.search import apply_search
from todosrht.tickets import get_participant_for_user, submit_ticket
from todosrht.tickets import get_participant_for_user
from todosrht.types import Event, Label, TicketLabel
from todosrht.types import TicketSubscription, Participant
from todosrht.types import Tracker, Ticket, TicketAccess
from todosrht.urls import tracker_url, ticket_url
from todosrht.webhooks import TrackerWebhook, UserWebhook
from todosrht.webhooks import TrackerWebhook
from urllib.parse import quote
import sqlalchemy as sa
@ -41,30 +42,37 @@ def create_GET():
@tracker.route("/tracker/create", methods=["POST"])
@loginrequired
def create_POST():
tracker, valid = Tracker.create_from_request(request, current_user)
valid = Validation(request)
name = valid.require("name", friendly_name="Name")
visibility = valid.require("visibility")
description = valid.optional("description")
if not valid.ok:
return render_template("tracker-create.html", **valid.kwargs), 400
db.session.add(tracker)
db.session.flush()
resp = exec_gql(current_app.site, """
mutation CreateTracker($name: String!, $description: String, $visibility: Visibility!) {
createTracker(name: $name, description: $description, visibility: $visibility) {
name
owner {
canonicalName
}
}
}
""", valid=valid, name=name, description=description, visibility=visibility)
UserWebhook.deliver(UserWebhook.Events.tracker_create,
tracker.to_dict(),
UserWebhook.Subscription.user_id == tracker.owner_id)
if not valid.ok:
return render_template("tracker-create.html", **valid.kwargs), 400
participant = get_participant_for_user(current_user)
sub = TicketSubscription()
sub.tracker_id = tracker.id
sub.participant_id = participant.id
db.session.add(sub)
db.session.commit()
resp = resp["createTracker"]
if "create-configure" in valid:
return redirect(url_for("settings.details_GET",
owner=current_user.canonical_name,
name=tracker.name))
name=resp["name"]))
return redirect(tracker_url(tracker))
return redirect(url_for("tracker.tracker_GET",
owner=resp["owner"]["canonicalName"],
name=resp["name"]))
def return_tracker(tracker, access, **kwargs):
another = session.get("another") or False
@ -183,20 +191,14 @@ def tracker_submit_POST(owner, name):
if not TicketAccess.submit in access:
abort(403)
db.session.commit() # Unlock tracker row
valid = Validation(request)
title = valid.require("title", friendly_name="Title")
desc = valid.optional("description")
another = valid.optional("another")
valid.expect(not title or 3 <= len(title) <= 2048,
"Title must be between 3 and 2048 characters.",
field="title")
valid.expect(not desc or len(desc) < 16384,
"Description must be no more than 16384 characters.",
field="description")
if not valid.ok:
db.session.commit() # Unlock tracker row
return return_tracker(tracker, access, **valid.kwargs), 400
if "preview" in request.form:
@ -204,13 +206,24 @@ def tracker_submit_POST(owner, name):
return return_tracker(tracker, access,
rendered_preview=preview, **valid.kwargs), 200
# TODO: Handle unique constraint failure (contention) and retry?
participant = get_participant_for_user(current_user)
ticket = submit_ticket(tracker, participant, title, desc)
input = {
"subject": title,
"body": desc,
}
resp = exec_gql(current_app.site, """
mutation SubmitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
submitTicket(trackerId: $trackerId, input: $input) {
id
}
}
""", valid=valid, trackerId=tracker.id, input=input)
if not valid.ok:
return return_tracker(tracker, access, **valid.kwargs), 400
ticket, _ = get_ticket(tracker, resp["submitTicket"]["id"])
UserWebhook.deliver(UserWebhook.Events.ticket_create,
ticket.to_dict(),
UserWebhook.Subscription.user_id == current_user.id)
TrackerWebhook.deliver(TrackerWebhook.Events.ticket_create,
ticket.to_dict(),
TrackerWebhook.Subscription.tracker_id == tracker.id)

View File

@ -6,31 +6,30 @@
<form class="row" method="POST">
{{csrf_token()}}
<div class="col-md-6">
<div class="form-group {{valid.cls("tracker_name")}}">
<div class="form-group {{valid.cls("name")}}">
<label for="tracker_name">
Name
<span class="text-muted">(you can't edit this)</p>
</label>
<input
type="text"
name="tracker_name"
id="tracker_name"
name="name"
id="name"
class="form-control"
value="{{ tracker.name }}"
disabled />
{{ valid.summary("tracker_name") }}
{{ valid.summary("name") }}
</div>
<div class="form-group {{ valid.cls('tracker_desc') }}">
<label for="tracker_desc">Description</label>
<div class="form-group {{valid.cls("description")}}">
<label for="description">Description</label>
<textarea
name="tracker_desc"
id="tracker_desc"
name="description"
id="description"
class="form-control"
rows="5"
aria-describedby="tracker_desc-help"
placeholder="Markdown supported"
>{{tracker.desc or tracker.description}}</textarea>
{{ valid.summary("tracker_desc") }}
>{{tracker.description}}</textarea>
{{ valid.summary("description") }}
</div>
{{ valid.summary() }}
<span class="pull-right">

View File

@ -45,49 +45,6 @@ class Tracker(Base):
import_in_progress = sa.Column(sa.Boolean,
nullable=False, server_default='f')
@staticmethod
def create_from_request(request, user):
valid = Validation(request)
name = valid.require("name", friendly_name="Name")
visibility = valid.require("visibility", cls=Visibility)
desc = valid.optional("description")
if not valid.ok:
return None, valid
valid.expect(1 <= len(name) < 256,
"Must be between 1 and 255 characters",
field="name")
valid.expect(not valid.ok or name_re.match(name),
"Name must match [A-Za-z0-9._-]+",
field="name")
valid.expect(not valid.ok or name not in [".", ".."],
"Name cannot be '.' or '..'",
field="name")
valid.expect(not valid.ok or name not in [".git", ".hg"],
"Name must not be '.git' or '.hg'",
field="name")
valid.expect(not desc or len(desc) < 4096,
"Must be less than 4096 characters",
field="description")
if not valid.ok:
return None, valid
tracker = (Tracker.query
.filter(Tracker.owner_id == user.id)
.filter(Tracker.name.ilike(name.replace('_', '\\_')))
).first()
valid.expect(not tracker,
"A tracker by this name already exists", field="name")
if not valid.ok:
return None, valid
tracker = Tracker(owner=user,
name=name,
description=desc,
visibility=visibility)
return tracker, valid
def ref(self):
return "{}/{}".format(
self.owner.canonical_name,
@ -114,10 +71,3 @@ class Tracker(Base):
"visibility": self.visibility,
} if not short else {})
}
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