Implement ticket editing in the UI

This commit is contained in:
Drew DeVault 2020-08-25 15:33:12 -04:00
parent 44d05940ed
commit deb31fb00e
7 changed files with 407 additions and 4 deletions

View File

@ -0,0 +1,23 @@
"""Add superceeded_by_id column to ticket comment
Revision ID: c32f13924e46
Revises: 074182407bb2
Create Date: 2020-08-25 15:28:19.574915
"""
# revision identifiers, used by Alembic.
revision = 'c32f13924e46'
down_revision = '074182407bb2'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column("ticket_comment", sa.Column("superceeded_by_id",
sa.Integer, sa.ForeignKey("ticket_comment.id", ondelete="SET NULL")))
def downgrade():
op.drop_column("ticket_comment", "superceeded_by_id")

View File

@ -0,0 +1,11 @@
import gzip
import tarfile
from flask import Blueprint, abort, send_file
from srht.oauth import oauth
internal = Blueprint("api.internal", __name__)
@internal.route("/api/_internal/data-export")
@oauth(None, require_internal=True)
def data_export():
return send_file("/home/sircmpwn/sources/libressl-2.5.1.tar.gz")

View File

@ -1,5 +1,5 @@
import re
from urllib.parse import quote
from datetime import datetime
from flask import Blueprint, render_template, request, abort, redirect
from srht.config import cfg
from srht.database import db
@ -12,10 +12,12 @@ from todosrht.tickets import add_comment, mark_seen, assign, unassign
from todosrht.tickets import get_participant_for_user
from todosrht.trackers import get_recent_users
from todosrht.types import Event, EventType, Label, TicketLabel
from todosrht.types import TicketAccess, TicketResolution
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.webhooks import TrackerWebhook, TicketWebhook
from urllib.parse import quote
ticket = Blueprint("ticket", __name__)
@ -67,6 +69,7 @@ def get_ticket_context(ticket, tracker, access):
.filter(Event.ticket_id == ticket.id)
.order_by(Event.created)),
"access": access,
"TicketAccess": TicketAccess,
"tracker_sub": tracker_sub,
"ticket_sub": ticket_sub,
"ticket_subscribe": ticket_subscribe,
@ -199,6 +202,88 @@ def ticket_comment_POST(owner, name, ticket_id):
TrackerWebhook.Subscription.tracker_id == ticket.tracker_id)
return redirect(ticket_url(ticket, event.comment))
@ticket.route("/<owner>/<name>/<int:ticket_id>/edit/<int:comment_id>")
@loginrequired
def ticket_comment_edit_GET(owner, name, ticket_id, comment_id):
tracker, traccess = get_tracker(owner, name)
if not tracker:
abort(404)
ticket, tiaccess = get_ticket(tracker, ticket_id)
if not ticket:
abort(404)
comment = (TicketComment.query
.filter(TicketComment.id == comment_id)
.filter(TicketComment.ticket_id == ticket.id)).one_or_none()
if not comment:
abort(404)
if (comment.submitter.user_id != current_user.id
and TicketAccess.triage not in traccess):
abort(401)
ctx = get_ticket_context(ticket, tracker, tiaccess)
return render_template("edit-comment.html",
comment=comment, **ctx)
@ticket.route("/<owner>/<name>/<int:ticket_id>/edit/<int:comment_id>", methods=["POST"])
@loginrequired
def ticket_comment_edit_POST(owner, name, ticket_id, comment_id):
tracker, traccess = get_tracker(owner, name)
if not tracker:
abort(404)
ticket, tiaccess = get_ticket(tracker, ticket_id)
if not ticket:
abort(404)
comment = (TicketComment.query
.filter(TicketComment.id == comment_id)
.filter(TicketComment.ticket_id == ticket.id)).one_or_none()
if not comment:
abort(404)
if (comment.submitter.user_id != current_user.id
and TicketAccess.triage not in traccess):
abort(401)
valid = Validation(request)
text = valid.require("text", friendly_name="Comment text")
preview = valid.optional("preview")
valid.expect(not text or 3 <= len(text) <= 16384,
"Comment must be between 3 and 16384 characters.", field="text")
if not valid.ok:
ctx = get_ticket_context(ticket, tracker, tiaccess)
return render_template("edit-comment.html",
comment=comment, **ctx, **valid.kwargs)
if preview == "true":
ctx = get_ticket_context(ticket, tracker, tiaccess)
ctx.update({
"text": text,
"rendered_preview": render_markup(tracker, text),
})
return render_template("edit-comment.html", comment=comment, **ctx)
event = Event.query.filter(Event.comment_id == comment.id).one_or_none()
assert event is not None
new_comment = TicketComment()
new_comment._no_autoupdate = True
new_comment.submitter_id = comment.submitter_id
new_comment.created = comment.created
new_comment.updated = datetime.utcnow()
new_comment.ticket_id = ticket.id
if (comment.submitter.participant_type != ParticipantType.user
or comment.submitter.user_id != current_user.id):
new_comment.authenticity = TicketAuthenticity.tampered
else:
new_comment.authenticity = comment.authenticity
new_comment.text = text
db.session.add(new_comment)
db.session.flush()
comment.superceeded_by_id = new_comment.id
event.comment_id = new_comment.id
db.session.commit()
return redirect(ticket_url(ticket))
@ticket.route("/<owner>/<name>/<int:ticket_id>/edit")
@loginrequired
def ticket_edit_GET(owner, name, ticket_id):

56
todosrht/export.py Normal file
View File

@ -0,0 +1,56 @@
import json
from collections import OrderedDict
from srht.config import get_origin
from srht.crypto import sign_payload
from srht.flask import date_handler
from todosrht.types import Event, EventType, Ticket, ParticipantType
def tracker_export(tracker):
"""
Exports a tracker as a JSON string.
"""
dump = list()
tickets = Ticket.query.filter(Ticket.tracker_id == tracker.id).all()
for ticket in tickets:
td = ticket.to_dict()
td["upstream"] = get_origin("todo.sr.ht", external=True)
if ticket.submitter.participant_type == ParticipantType.user:
sigdata = OrderedDict({
"description": ticket.description,
"ref": ticket.ref(),
"submitter": ticket.submitter.user.canonical_name,
"title": ticket.title,
"upstream": get_origin("todo.sr.ht", external=True),
})
sigdata = json.dumps(sigdata)
signature = sign_payload(sigdata)
td.update(signature)
events = Event.query.filter(Event.ticket_id == ticket.id).all()
if any(events):
td["events"] = list()
for event in events:
ev = event.to_dict()
ev["upstream"] = get_origin("todo.sr.ht", external=True)
if (EventType.comment in event.event_type
and event.participant.participant_type == ParticipantType.user):
sigdata = OrderedDict({
"comment": event.comment.text,
"id": event.id,
"ticket": event.ticket.ref(),
"user": event.participant.user.canonical_name,
"upstream": get_origin("todo.sr.ht", external=True),
})
sigdata = json.dumps(sigdata)
signature = sign_payload(sigdata)
ev.update(signature)
td["events"].append(ev)
dump.append(td)
dump = json.dumps({
"owner": tracker.owner.to_dict(),
"name": tracker.name,
"labels": [l.to_dict() for l in tracker.labels],
"tickets": dump,
}, default=date_handler)
return dump

View File

@ -0,0 +1,210 @@
{% extends "layout.html" %}
{% block title %}
<title>
{{ 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>/<a href="{{ tracker|tracker_url }}"
>{{ tracker.name }}</a>#{{ ticket.scoped_id }}<span
class="d-none d-md-inline">:</span>&nbsp;
</div>
<div id="title-field">
{{ticket.title}}
</div>
</h2>
</div>
<div class="header-tabbed">
{% if not tracker_sub %}
<form method="POST" action="{{url_for("ticket." +
("disable_notifications" if ticket_sub else "enable_notifications"),
owner=tracker.owner.canonical_name,
name=tracker.name,
ticket_id=ticket.scoped_id)}}"
class="container"
>
{{csrf_token()}}
{% else %}
<div class="container">
{% endif %}
<ul class="nav nav-tabs">
<li class="nav-item">
<a href="{{ ticket|ticket_url }}"
class="nav-link active">view</a>
</li>
{% if TicketAccess.edit in access %}
<li class="nav-item">
<a href="{{ ticket|ticket_edit_url }}"
class="nav-link">edit</a>
</li>
{% endif %}
<li class="flex-grow-1 d-none d-md-block"></li>
<li class="nav-item">
{% if current_user %}
<button
class="nav-link active"
{% if tracker_sub %}
title="you are subscribed to all activity on this tracker"
disabled
{% else %}
type="submit"
{% endif %}
>
{{icon("envelope-o")}}
{% if ticket_sub or tracker_sub %}
Disable notifications
{% else %}
Enable notifications
{% endif %}
{{icon("caret-right")}}
</button>
{% else %}
<a class="nav-link active" href="{{ ticket_subscribe }}">
{{icon("envelope-o")}}
Subscribe
{{icon("caret-right")}}
</a>
{% endif %}
</li>
</ul>
{% if not tracker_sub %}
</form>
{% else %}
</div>
{% endif %}
</div>
<div class="container">
<div class="row">
<div class="col-md-6">
{% if ticket.description %}
<div id="description-field">
{{ ticket|render_ticket_description }}
</div>
{% endif %}
</div>
<div class="col-md-6">
<dl class="row">
<dt class="col-md-3">Status</dt>
<dd class="col-md-9">
<strong id="status-field" class="text-success">
{{ ticket.status.name.upper() }}
{% if ticket.status == TicketStatus.resolved %}
{{ ticket.resolution.name.upper() }}
{% endif %}
</strong>
</dd>
<dt class="col-md-3">Submitter</dt>
<dd class="col-md-9">
<a
id="submitter-field"
href="{{ ticket.submitter|participant_url }}"
>{{ ticket.submitter }}</a>
{% if ticket.authenticity.name == "unauthenticated" %}
<span
class="text-danger"
title="This ticket was imported from an external source and its authenticity cannot be guaranteed."
>(unverified)</span>
{% elif ticket.authenticity.name == "tampered" %}
<span
class="text-danger"
title="This ticket has been edited by a third-party - its contents are not genuine."
>(edited)</span>
{% endif %}
</dd>
<dt class="col-md-3">Assigned to</dt>
<dd id="assignee-field" class="col-md-9">
{% for assignee in ticket.assigned_users %}
<div class="row">
<div class="col">
<a href="{{ assignee|user_url }}">{{ assignee }}</a>
</div>
</div>
{% endfor %}
{% if not ticket.assigned_users %}
No-one
{% endif %}
</dd>
<dt class="col-md-3">Submitted</dt>
<dd id="submitted-field" class="col-md-9">
{{ ticket.created | date }}</dd>
<dt class="col-md-3">Updated</dt>
<dd id="updated-field" class="col-md-9">
{{ ticket.updated | date }}</dd>
<dt class="col-md-3">Labels</dt>
<dd id="labels-field" class="col-md-9">
{% for label in ticket.labels %}
{{ label|label_badge }}
{% else %}
No labels applied.
{% endfor %}
</dd>
</div>
</div>
<div class="row">
<div class="col-md-12 event-list ticket-events">
<h3>Edit comment</h3>
<div class="event">
<h4>
<a
href="{{ comment.submitter|participant_url }}"
>{{ comment.submitter }}</a>
{% if comment.authenticity.name == "unauthenticated" %}
<span
class="text-danger"
title="This comment was imported from an external source and its authenticity cannot be guaranteed."
>(unverified)</span>
{% elif comment.authenticity.name == "tampered" %}
<span
class="text-danger"
title="This comment has been edited by a third-party."
>(edited)</span>
{% endif %}
<span class="pull-right">
<small>
{{ comment.created | date }}
</small>
</span>
</h4>
<form style="margin-top: 1rem" method="POST">
{{csrf_token()}}
<div class="form-group">
<textarea
class="form-control {{valid.cls('text')}}"
name="text"
rows="5"
>{{text or comment.text}}</textarea>
{{valid.summary("text")}}
</div>
<button
id="comment-submit"
type="submit"
class="btn btn-primary"
>Submit {{icon("caret-right")}}</button>
<button
type="submit"
name="preview"
value="true"
class="btn btn-default"
>Preview {{icon("caret-right")}}</button>
</form>
</div>
{% if rendered_preview %}
<div class="event preview">
<span class="preview-tag">Comment preview</span>
<a href="{{ current_user|user_url }}">{{ current_user }}</a>
<blockquote>
{{ rendered_preview }}
</blockquote>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -270,12 +270,12 @@
{% if event.comment.authenticity.name == "unauthenticated" %}
<span
class="text-danger"
title="This ticket was imported from an external source and its authenticity cannot be guaranteed."
title="This comment was imported from an external source and its authenticity cannot be guaranteed."
>(unverified)</span>
{% elif event.comment.authenticity.name == "tampered" %}
<span
class="text-danger"
title="This ticket has been edited by a third-party - its contents are not genuine."
title="This comment has been edited by a third-party."
>(edited)</span>
{% endif %}
{% endif %}
@ -348,6 +348,18 @@
<span class="pull-right">
<small>
<a href="#event-{{event.id}}">{{ event.created | date }}</a>
{%- if EventType.comment in event.event_type and
event.comment.superceedes -%}
<span title="This comment has been edited">*</span>
{% endif %}
{% if EventType.comment in event.event_type
and (TicketAccess.triage in access
or event.comment.submitter.user == current_user) %}
· <a href="{{url_for("ticket.ticket_comment_edit_GET",
owner=tracker.owner.canonical_name, name=tracker.name,
ticket_id=ticket.scoped_id,
comment_id=event.comment.id)}}">edit</a>
{% endif %}
</small>
</span>
</h4>

View File

@ -33,6 +33,12 @@ class TicketComment(Base):
signature is present, or tampered if the signature does not validate.
"""
superceeded_by_id = sa.Column(sa.Integer,
sa.ForeignKey("ticket_comment.id", ondelete="SET NULL"))
superceeded_by = sa.orm.relationship("TicketComment",
backref=sa.orm.backref("superceedes"),
remote_side=[id])
def to_dict(self, short=False):
return {
"id": self.id,