Implement ticket editing in the UI
This commit is contained in:
parent
44d05940ed
commit
deb31fb00e
|
@ -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")
|
|
@ -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")
|
|
@ -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):
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,210 @@
|
|||
{% extends "layout.html" %}
|
||||
{% block title %}
|
||||
<title>
|
||||
{{ ticket.ref() }}: {{ ticket.title }}
|
||||
—
|
||||
{{ 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>
|
||||
</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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue