api/graph: Implement ticket deletion

This commit is contained in:
Adnan Maolood 2022-05-06 10:20:03 -04:00 committed by Drew DeVault
parent 5650249985
commit 7d65b3653c
12 changed files with 248 additions and 3 deletions

View File

@ -164,6 +164,7 @@ enum WebhookEvent {
TRACKER_DELETED @access(scope: TRACKERS, kind: RO)
TICKET_CREATED @access(scope: TICKETS, kind: RO)
TICKET_UPDATE @access(scope: TICKETS, kind: RO)
TICKET_DELETED @access(scope: TICKETS, kind: RO)
LABEL_CREATED @access(scope: TRACKERS, kind: RO)
LABEL_UPDATE @access(scope: TRACKERS, kind: RO)
LABEL_DELETED @access(scope: TRACKERS, kind: RO)
@ -263,6 +264,15 @@ type TicketEvent implements WebhookPayload {
ticket: Ticket!
}
type TicketDeletedEvent implements WebhookPayload {
uuid: String!
event: WebhookEvent!
date: Time!
trackerId: Int!
ticketId: Int!
}
type EventCreated implements WebhookPayload {
uuid: String!
event: WebhookEvent!
@ -904,6 +914,9 @@ type Mutation {
submitTicket(trackerId: Int!,
input: SubmitTicketInput!): Ticket! @access(scope: TICKETS, kind: RW)
"Deletes a ticket."
deleteTicket(trackerId: Int!, ticketId: Int!): Ticket! @access(scope: TICKETS, kind: RW)
# Creates a new ticket from an incoming email. (For internal use only)
submitTicketEmail(trackerId: Int!,
input: SubmitTicketEmailInput!): Ticket! @internal

View File

@ -835,6 +835,44 @@ func (r *mutationResolver) SubmitTicket(ctx context.Context, trackerID int, inpu
return &ticket, nil
}
func (r *mutationResolver) DeleteTicket(ctx context.Context, trackerID int, ticketID int) (*model.Ticket, error) {
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
if err != nil {
return nil, err
} else if tracker == nil {
return nil, fmt.Errorf("No tracker by ID %d found for this user", trackerID)
}
if !tracker.CanEdit() {
return nil, fmt.Errorf("Access denied")
}
var ticket model.Ticket
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
DELETE FROM ticket tk
WHERE tk.tracker_id = $1 AND tk.scoped_id = $2
RETURNING
id, scoped_id, submitter_id, tracker_id, created, updated,
title, description, authenticity, status, resolution
`, trackerID, ticketID)
if err := row.Scan(&ticket.PKID, &ticket.ID, &ticket.SubmitterID,
&ticket.TrackerID, &ticket.Created, &ticket.Updated, &ticket.Subject,
&ticket.Body, &ticket.RawAuthenticity, &ticket.RawStatus,
&ticket.RawResolution); err != nil {
return err
}
webhooks.DeliverTrackerTicketDeletedEvent(ctx, ticket.TrackerID, &ticket)
webhooks.DeliverTicketDeletedEvent(ctx, ticket.PKID, &ticket)
return nil
}); err != nil {
if err == sql.ErrNoRows {
return nil, errors.New("No such ticket")
}
return nil, err
}
return &ticket, nil
}
func (r *mutationResolver) SubmitTicketEmail(ctx context.Context, trackerID int, input model.SubmitTicketEmailInput) (*model.Ticket, error) {
validation := valid.New(ctx)
validation.Expect(len(input.Subject) <= 2048,
@ -2089,7 +2127,8 @@ func (r *mutationResolver) CreateTrackerWebhook(ctx context.Context, trackerID i
model.WebhookEventLabelCreated, model.WebhookEventLabelUpdate,
model.WebhookEventLabelDeleted:
access = "TRACKERS"
case model.WebhookEventTicketCreated, model.WebhookEventTicketUpdate:
case model.WebhookEventTicketCreated, model.WebhookEventTicketUpdate,
model.WebhookEventTicketDeleted:
access = "TICKETS"
case model.WebhookEventEventCreated:
access = "EVENTS"
@ -2196,7 +2235,7 @@ func (r *mutationResolver) CreateTicketWebhook(ctx context.Context, trackerID in
// manual fuckery
var access string
switch ev {
case model.WebhookEventTicketUpdate:
case model.WebhookEventTicketUpdate, model.WebhookEventTicketDeleted:
access = "TICKETS"
case model.WebhookEventEventCreated:
access = "EVENTS"

View File

@ -108,6 +108,19 @@ func DeliverTrackerTicketEvent(ctx context.Context,
deliverTrackerWebhook(ctx, trackerID, event, &payload, payloadUUID)
}
func DeliverTrackerTicketDeletedEvent(ctx context.Context, trackerID int, ticket *model.Ticket) {
event := model.WebhookEventTicketDeleted
payloadUUID := uuid.New()
payload := model.TicketDeletedEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
TrackerID: ticket.TrackerID,
TicketID: ticket.ID,
}
deliverTrackerWebhook(ctx, trackerID, event, &payload, payloadUUID)
}
func DeliverTrackerEventCreated(ctx context.Context, trackerID int, newEvent *model.Event) {
event := model.WebhookEventEventCreated
payloadUUID := uuid.New()
@ -132,6 +145,19 @@ func DeliverTicketEvent(ctx context.Context,
deliverTicketWebhook(ctx, ticketID, event, &payload, payloadUUID)
}
func DeliverTicketDeletedEvent(ctx context.Context, ticketID int, ticket *model.Ticket) {
event := model.WebhookEventTicketDeleted
payloadUUID := uuid.New()
payload := model.TicketDeletedEvent{
UUID: payloadUUID.String(),
Event: event,
Date: time.Now().UTC(),
TrackerID: ticket.TrackerID,
TicketID: ticket.ID,
}
deliverTicketWebhook(ctx, ticketID, event, &payload, payloadUUID)
}
func DeliverTicketEventCreated(ctx context.Context, ticketID int, newEvent *model.Event) {
event := model.WebhookEventEventCreated
payloadUUID := uuid.New()

View File

@ -0,0 +1,53 @@
"""Add TICKET_DELETED webhook event
Revision ID: e1e2e901be0c
Revises: 7c117bc9e99b
Create Date: 2022-05-03 11:47:11.143292
"""
# revision identifiers, used by Alembic.
revision = 'e1e2e901be0c'
down_revision = '7c117bc9e99b'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.execute("""
ALTER TYPE tracker_webhook_event ADD VALUE 'TICKET_DELETED';
ALTER TYPE ticket_webhook_event ADD VALUE 'TICKET_DELETED';
""")
def downgrade():
op.execute("""
ALTER TYPE tracker_webhook_event RENAME TO tracker_webhook_event_old;
CREATE TYPE tracker_webhook_event AS ENUM (
'TRACKER_UPDATE',
'TRACKER_DELETED',
'TICKET_CREATED',
'TICKET_UPDATE',
'LABEL_CREATED',
'LABEL_UPDATE',
'LABEL_DELETED',
'EVENT_CREATED'
);
ALTER TABLE gql_tracker_wh_sub ALTER COLUMN events TYPE varchar[];
ALTER TABLE gql_tracker_wh_sub ALTER COLUMN events TYPE tracker_webhook_event[] USING events::tracker_webhook_event[];
ALTER TABLE gql_tracker_wh_delivery ALTER COLUMN event TYPE varchar;
ALTER TABLE gql_tracker_wh_delivery ALTER COLUMN event TYPE tracker_webhook_event USING event::tracker_webhook_event;
DROP TYPE tracker_webhook_event_old;
ALTER TYPE ticket_webhook_event RENAME TO ticket_webhook_event_old;
CREATE TYPE ticket_webhook_event AS ENUM (
'TICKET_UPDATE',
'EVENT_CREATED'
);
ALTER TABLE gql_ticket_wh_sub ALTER COLUMN events TYPE varchar[];
ALTER TABLE gql_ticket_wh_sub ALTER COLUMN events TYPE ticket_webhook_event[] USING events::ticket_webhook_event[];
ALTER TABLE gql_ticket_wh_delivery ALTER COLUMN event TYPE varchar;
ALTER TABLE gql_ticket_wh_delivery ALTER COLUMN event TYPE ticket_webhook_event USING event::ticket_webhook_event;
DROP TYPE ticket_webhook_event_old;
""")

View File

@ -3,6 +3,7 @@ from datetime import datetime
from flask import Blueprint, current_app, render_template, request, abort, redirect
from srht.config import cfg
from srht.database import db
from srht.flask import session
from srht.graphql import exec_gql
from srht.oauth import current_user, loginrequired
from srht.validation import Validation
@ -16,7 +17,7 @@ from todosrht.types import Event, EventType, Label, TicketLabel
from todosrht.types import TicketAccess, TicketResolution, ParticipantType
from todosrht.types import TicketComment, TicketAuthenticity
from todosrht.types import TicketSubscription, User, Participant
from todosrht.urls import ticket_url
from todosrht.urls import tracker_url, ticket_url
from urllib.parse import quote
@ -348,6 +349,39 @@ def ticket_edit_POST(owner, name, ticket_id):
return redirect(ticket_url(ticket))
@ticket.route("/<owner>/<name>/<int:ticket_id>/delete")
@loginrequired
def ticket_delete_GET(owner, name, ticket_id):
tracker, _ = get_tracker(owner, name)
if not tracker:
abort(404)
ticket, access = get_ticket(tracker, ticket_id)
if not ticket:
abort(404)
if not TicketAccess.edit in access:
abort(401)
return render_template("ticket-delete.html",
view="delete", tracker=tracker, ticket=ticket, access=access)
@ticket.route("/<owner>/<name>/<int:ticket_id>/delete", methods=["POST"])
@loginrequired
def ticket_delete_POST(owner, name, ticket_id):
tracker, _ = get_tracker(owner, name)
if not tracker:
abort(404)
exec_gql(current_app.site, """
mutation DeleteTicket($trackerId: Int!, $ticketId: Int!) {
deleteTicket(trackerId: $trackerId, ticketId: $ticketId) {
id
}
}
""", trackerId=tracker.id, ticketId=ticket_id)
session["notice"] = f"{owner}/{name}#{ticket_id} was deleted."
return redirect(tracker_url(tracker))
@ticket.route("/<owner>/<name>/<int:ticket_id>/add_label", methods=["POST"])
@loginrequired
def ticket_add_label(owner, name, ticket_id):

View File

@ -132,6 +132,7 @@ def tracker_GET(owner, name):
kwargs = {
"title": request.args.get("title"),
"description": request.args.get("description"),
"notice": session.pop("notice", None),
}
return return_tracker(tracker, access, **kwargs)

View File

@ -37,6 +37,7 @@ class TodoApp(SrhtFlask):
self.add_template_filter(urls.participant_url)
self.add_template_filter(urls.ticket_assign_url)
self.add_template_filter(urls.ticket_edit_url)
self.add_template_filter(urls.ticket_delete_url)
self.add_template_filter(urls.ticket_unassign_url)
self.add_template_filter(urls.ticket_url)
self.add_template_filter(urls.tracker_labels_url)

View File

@ -29,6 +29,10 @@
<a href="{{ ticket|ticket_edit_url }}"
class="nav-link active">edit</a>
</li>
<li class="nav-item">
<a href="{{ ticket|ticket_delete_url }}"
class="nav-link">delete</a>
</li>
</ul>
</div>
</div>

View File

@ -0,0 +1,59 @@
{% extends "layout.html" %}
{% block title %}
<title>
Delete {{ ticket.ref() }}: {{ ticket.title }}
&mdash;
{{ cfg("sr.ht", "site-name") }} todo
</title>
{% endblock %}
{% block body %}
<div class="container">
<h2 class="ticket-title">
<div>
<a href="{{ tracker.owner|user_url }}">{{ tracker.owner }}</a>/<wbr
><a href="{{ tracker|tracker_url }}">{{ tracker.name }}</a><wbr
>#{{ ticket.scoped_id }}<spanclass="d-none d-md-inline">:&nbsp;</span>
</div>
<div id="title-field">
{{ticket.title}}
</div>
</h2>
</div>
<div class="header-tabbed">
<div class="container">
<ul class="nav nav-tabs">
<li class="nav-item">
<a href="{{ ticket|ticket_url }}" class="nav-link">view</a>
</li>
<li class="nav-item">
<a href="{{ ticket|ticket_edit_url }}"
class="nav-link">edit</a>
</li>
<li class="nav-item">
<a href="{{ ticket|ticket_delete_url }}"
class="nav-link active">delete</a>
</li>
</ul>
</div>
</div>
<div class="container">
<div class="row">
<div class="col-md-12">
<p>
This will permanently delete this ticket and all of its comments. This
cannot be undone.
</p>
<form method="POST">
{{csrf_token()}}
<button type="submit" class="btn btn-danger">
Proceed and delete {{icon("caret-right")}}
</button>
<a
href="/{{ tracker.owner }}/{{ tracker.name }}"
class="btn btn-default"
>Nevermind</a>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -43,6 +43,10 @@
<a href="{{ ticket|ticket_edit_url }}"
class="nav-link">edit</a>
</li>
<li class="nav-item">
<a href="{{ ticket|ticket_delete_url }}"
class="nav-link">delete</a>
</li>
{% endif %}
<li class="flex-grow-1 d-none d-md-block"></li>
<li class="nav-item">

View File

@ -193,6 +193,11 @@
An import operation is currently in progress.
</div>
{% endif %}
{% if notice %}
<div class="alert alert-success">
{{ notice }}
</div>
{% endif %}
{% if TicketAccess.browse not in access and TicketAccess.submit in access %}
<div class="alert alert-warning">
You do not have permission to view tickets on this tracker unless you

View File

@ -34,6 +34,12 @@ def ticket_edit_url(ticket):
name=ticket.tracker.name,
ticket_id=ticket.scoped_id)
def ticket_delete_url(ticket):
return url_for("ticket.ticket_delete_GET",
owner=ticket.tracker.owner.canonical_name,
name=ticket.tracker.name,
ticket_id=ticket.scoped_id)
def ticket_assign_url(ticket):
return url_for("ticket.ticket_assign",
owner=ticket.tracker.owner.canonical_name,