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 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.api import paginated_response
from srht.database import db from srht.database import db
from srht.graphql import exec_gql
from srht.oauth import oauth, current_token from srht.oauth import oauth, current_token
from srht.validation import Validation, valid_url from srht.validation import Validation, valid_url
from todosrht.access import get_tracker, get_ticket 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.tickets import get_participant_for_user, get_participant_for_external
from todosrht.blueprints.api import get_user from todosrht.blueprints.api import get_user
from todosrht.types import Ticket, TicketAccess, TicketStatus, TicketResolution from todosrht.types import Ticket, TicketAccess, TicketStatus, TicketResolution
@ -79,17 +80,26 @@ def tracker_tickets_POST(username, tracker_name):
if not valid.ok: if not valid.ok:
return valid.response return valid.response
if external_id: input = {
participant = get_participant_for_external(external_id, external_url) "subject": title,
else: "body": desc,
participant = get_participant_for_user(current_token.user) "created": created,
"externalId": external_id,
"externalUrl": external_url,
}
ticket = submit_ticket(tracker, participant, title, desc) resp = exec_gql(current_app.site, """
if created: mutation SubmitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
ticket._no_autoupdate = True submitTicket(trackerId: $trackerId, input: $input) {
ticket.created = created id
ticket.updated = created }
db.session.commit() }
""", 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, TrackerWebhook.deliver(TrackerWebhook.Events.ticket_create,
ticket.to_dict(), 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.api import paginated_response
from srht.database import db from srht.database import db
from srht.graphql import exec_gql
from srht.oauth import oauth, current_token from srht.oauth import oauth, current_token
from srht.validation import Validation from srht.validation import Validation
from todosrht.access import get_tracker from todosrht.access import get_tracker
from todosrht.blueprints.api import get_user from todosrht.blueprints.api import get_user
from todosrht.tickets import get_participant_for_user from todosrht.tickets import get_participant_for_user
from todosrht.types import Label, Tracker, TicketAccess, TicketSubscription from todosrht.types import Label, Tracker, TicketAccess, TicketSubscription
from todosrht.webhooks import UserWebhook, TrackerWebhook from todosrht.webhooks import TrackerWebhook
trackers = Blueprint("api_trackers", __name__) trackers = Blueprint("api_trackers", __name__)
@ -26,24 +27,53 @@ def user_trackers_GET(username):
@oauth("trackers:write") @oauth("trackers:write")
def user_trackers_POST(): def user_trackers_POST():
user = current_token.user 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: if not valid.ok:
return valid.response 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 resp = exec_gql(current_app.site, """
sub = TicketSubscription() mutation CreateTracker($name: String!, $description: String, $visibility: Visibility!) {
participant = get_participant_for_user(user) createTracker(name: $name, description: $description, visibility: $visibility) {
sub.tracker_id = tracker.id id
sub.participant_id = participant.id created
db.session.add(sub) updated
db.session.commit() 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/user/<username>/trackers/<tracker_name>")
@trackers.route("/api/trackers/<tracker_name>", defaults={"username": None}) @trackers.route("/api/trackers/<tracker_name>", defaults={"username": None})
@ -93,15 +123,55 @@ def user_tracker_by_name_PUT(username, tracker_name):
abort(404) abort(404)
if tracker.owner_id != current_token.user_id: if tracker.owner_id != current_token.user_id:
abort(401) abort(401)
valid = Validation(request) 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: if not valid.ok:
return valid.response return valid.response
db.session.commit()
UserWebhook.deliver(UserWebhook.Events.tracker_update, resp = resp["updateTracker"]
tracker.to_dict(),
UserWebhook.Subscription.user_id == tracker.owner_id) # Build default_access list
return tracker.to_dict() 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>", @trackers.route("/api/user/<username>/trackers/<tracker_name>",
methods=["DELETE"]) methods=["DELETE"])
@ -115,17 +185,11 @@ def user_tracker_by_name_DELETE(username, tracker_name):
abort(404) abort(404)
if tracker.owner_id != current_token.user_id: if tracker.owner_id != current_token.user_id:
abort(401) abort(401)
# SQLAlchemy shits itself on some of our weird constraints/relationships exec_gql(current_app.site, """
# so fuck it, postgres knows what to do here mutation DeleteTracker($id: Int!) {
tracker_id = tracker.id deleteTracker(id: $id) { id }
owner_id = tracker.owner_id }
assert isinstance(tracker_id, int) """, user=user, id=tracker.id);
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 {}, 204 return {}, 204
@trackers.route("/api/user/<username>/trackers/<tracker_name>/labels") @trackers.route("/api/user/<username>/trackers/<tracker_name>/labels")

View File

@ -2,13 +2,14 @@ import gzip
import json import json
import os import os
from collections import OrderedDict 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 flask import current_app, send_file
from srht.config import get_origin from srht.config import get_origin
from srht.crypto import sign_payload from srht.crypto import sign_payload
from srht.database import db from srht.database import db
from srht.oauth import current_user, loginrequired from srht.oauth import current_user, loginrequired
from srht.flask import date_handler, session from srht.flask import date_handler, session
from srht.graphql import exec_gql
from srht.validation import Validation from srht.validation import Validation
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from todosrht.access import get_tracker 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 Event, EventType, Ticket, TicketAccess, Visibility
from todosrht.types import ParticipantType, UserAccess, User from todosrht.types import ParticipantType, UserAccess, User
from todosrht.urls import tracker_url from todosrht.urls import tracker_url
from todosrht.webhooks import UserWebhook
from todosrht.tracker_import import tracker_import from todosrht.tracker_import import tracker_import
settings = Blueprint("settings", __name__) settings = Blueprint("settings", __name__)
@ -63,24 +63,33 @@ def details_POST(owner, name):
abort(403) abort(403)
valid = Validation(request) valid = Validation(request)
desc = valid.optional("tracker_desc", default=tracker.description) rewrite = lambda value: None if value == "" else value
vis = valid.require("visibility", cls=Visibility) input = {
valid.expect(not desc or len(desc) < 4096, key: rewrite(valid.source[key]) for key in [
"Must be less than 4096 characters", "description", "visibility",
field="tracker_desc") ] 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: if not valid.ok:
return render_template("tracker-details.html", return render_template("tracker-details.html",
tracker=tracker, **valid.kwargs), 400 tracker=tracker, **valid.kwargs), 400
tracker.description = desc resp = resp["updateTracker"]
tracker.visibility = vis
UserWebhook.deliver(UserWebhook.Events.tracker_update, return redirect(url_for("settings.details_GET",
tracker.to_dict(), owner=resp["owner"]["canonicalName"],
UserWebhook.Subscription.user_id == tracker.owner_id) name=resp["name"]))
db.session.commit()
return redirect(tracker_url(tracker))
def render_tracker_access(tracker, **kwargs): def render_tracker_access(tracker, **kwargs):
@ -111,17 +120,23 @@ def access_POST(owner, name):
valid = Validation(request) valid = Validation(request)
access = parse_html_perms('default', valid) 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: if not valid.ok:
return render_tracker_access(tracker, **valid.kwargs), 400 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)) return redirect(tracker_url(tracker))
@settings.route("/<owner>/<name>/settings/user-access/create", methods=["POST"]) @settings.route("/<owner>/<name>/settings/user-access/create", methods=["POST"])
@ -198,20 +213,14 @@ def delete_POST(owner, name):
abort(404) abort(404)
if current_user.id != tracker.owner_id: if current_user.id != tracker.owner_id:
abort(403) 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." 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")) return redirect(url_for("html.index_GET"))
@settings.route("/<owner>/<name>/settings/import-export") @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.config import cfg
from srht.database import db from srht.database import db
from srht.flask import paginate_query, session from srht.flask import paginate_query, session
from srht.graphql import exec_gql
from srht.oauth import current_user, loginrequired from srht.oauth import current_user, loginrequired
from srht.validation import Validation 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 color_from_hex, color_to_hex, get_text_color
from todosrht.color import valid_hex_color_code from todosrht.color import valid_hex_color_code
from todosrht.filters import render_markup from todosrht.filters import render_markup
from todosrht.search import apply_search 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 Event, Label, TicketLabel
from todosrht.types import TicketSubscription, Participant from todosrht.types import TicketSubscription, Participant
from todosrht.types import Tracker, Ticket, TicketAccess from todosrht.types import Tracker, Ticket, TicketAccess
from todosrht.urls import tracker_url, ticket_url from todosrht.urls import tracker_url, ticket_url
from todosrht.webhooks import TrackerWebhook, UserWebhook from todosrht.webhooks import TrackerWebhook
from urllib.parse import quote from urllib.parse import quote
import sqlalchemy as sa import sqlalchemy as sa
@ -41,30 +42,37 @@ def create_GET():
@tracker.route("/tracker/create", methods=["POST"]) @tracker.route("/tracker/create", methods=["POST"])
@loginrequired @loginrequired
def create_POST(): 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: if not valid.ok:
return render_template("tracker-create.html", **valid.kwargs), 400 return render_template("tracker-create.html", **valid.kwargs), 400
db.session.add(tracker) resp = exec_gql(current_app.site, """
db.session.flush() 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, if not valid.ok:
tracker.to_dict(), return render_template("tracker-create.html", **valid.kwargs), 400
UserWebhook.Subscription.user_id == tracker.owner_id)
participant = get_participant_for_user(current_user) resp = resp["createTracker"]
sub = TicketSubscription()
sub.tracker_id = tracker.id
sub.participant_id = participant.id
db.session.add(sub)
db.session.commit()
if "create-configure" in valid: if "create-configure" in valid:
return redirect(url_for("settings.details_GET", return redirect(url_for("settings.details_GET",
owner=current_user.canonical_name, 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): def return_tracker(tracker, access, **kwargs):
another = session.get("another") or False another = session.get("another") or False
@ -183,20 +191,14 @@ def tracker_submit_POST(owner, name):
if not TicketAccess.submit in access: if not TicketAccess.submit in access:
abort(403) abort(403)
db.session.commit() # Unlock tracker row
valid = Validation(request) valid = Validation(request)
title = valid.require("title", friendly_name="Title") title = valid.require("title", friendly_name="Title")
desc = valid.optional("description") desc = valid.optional("description")
another = valid.optional("another") 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: if not valid.ok:
db.session.commit() # Unlock tracker row
return return_tracker(tracker, access, **valid.kwargs), 400 return return_tracker(tracker, access, **valid.kwargs), 400
if "preview" in request.form: if "preview" in request.form:
@ -204,13 +206,24 @@ def tracker_submit_POST(owner, name):
return return_tracker(tracker, access, return return_tracker(tracker, access,
rendered_preview=preview, **valid.kwargs), 200 rendered_preview=preview, **valid.kwargs), 200
# TODO: Handle unique constraint failure (contention) and retry? input = {
participant = get_participant_for_user(current_user) "subject": title,
ticket = submit_ticket(tracker, participant, title, desc) "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, TrackerWebhook.deliver(TrackerWebhook.Events.ticket_create,
ticket.to_dict(), ticket.to_dict(),
TrackerWebhook.Subscription.tracker_id == tracker.id) TrackerWebhook.Subscription.tracker_id == tracker.id)

View File

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

View File

@ -45,49 +45,6 @@ class Tracker(Base):
import_in_progress = sa.Column(sa.Boolean, import_in_progress = sa.Column(sa.Boolean,
nullable=False, server_default='f') 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): def ref(self):
return "{}/{}".format( return "{}/{}".format(
self.owner.canonical_name, self.owner.canonical_name,
@ -114,10 +71,3 @@ class Tracker(Base):
"visibility": self.visibility, "visibility": self.visibility,
} if not short else {}) } 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