From deb31fb00e9da1a52a4478c3f86f389940266ded Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Tue, 25 Aug 2020 15:33:12 -0400 Subject: [PATCH] Implement ticket editing in the UI --- ...add_superceeded_by_id_column_to_ticket_.py | 23 ++ todosrht/blueprints/api/internal.py | 11 + todosrht/blueprints/ticket.py | 89 +++++++- todosrht/export.py | 56 +++++ todosrht/templates/edit-comment.html | 210 ++++++++++++++++++ todosrht/templates/ticket.html | 16 +- todosrht/types/ticketcomment.py | 6 + 7 files changed, 407 insertions(+), 4 deletions(-) create mode 100644 todosrht/alembic/versions/c32f13924e46_add_superceeded_by_id_column_to_ticket_.py create mode 100644 todosrht/blueprints/api/internal.py create mode 100644 todosrht/export.py create mode 100644 todosrht/templates/edit-comment.html diff --git a/todosrht/alembic/versions/c32f13924e46_add_superceeded_by_id_column_to_ticket_.py b/todosrht/alembic/versions/c32f13924e46_add_superceeded_by_id_column_to_ticket_.py new file mode 100644 index 0000000..86a7dc5 --- /dev/null +++ b/todosrht/alembic/versions/c32f13924e46_add_superceeded_by_id_column_to_ticket_.py @@ -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") diff --git a/todosrht/blueprints/api/internal.py b/todosrht/blueprints/api/internal.py new file mode 100644 index 0000000..c8249b1 --- /dev/null +++ b/todosrht/blueprints/api/internal.py @@ -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") diff --git a/todosrht/blueprints/ticket.py b/todosrht/blueprints/ticket.py index ad71188..20c8229 100644 --- a/todosrht/blueprints/ticket.py +++ b/todosrht/blueprints/ticket.py @@ -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("////edit/") +@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("////edit/", 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("////edit") @loginrequired def ticket_edit_GET(owner, name, ticket_id): diff --git a/todosrht/export.py b/todosrht/export.py new file mode 100644 index 0000000..0595b45 --- /dev/null +++ b/todosrht/export.py @@ -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 diff --git a/todosrht/templates/edit-comment.html b/todosrht/templates/edit-comment.html new file mode 100644 index 0000000..5de5dd2 --- /dev/null +++ b/todosrht/templates/edit-comment.html @@ -0,0 +1,210 @@ +{% extends "layout.html" %} +{% block title %} + + {{ ticket.ref() }}: {{ ticket.title }} + — + {{ cfg("sr.ht", "site-name") }} todo + +{% endblock %} +{% block body %} +
+

+
+ {{ tracker.owner }}/{{ tracker.name }}#{{ ticket.scoped_id }}:  +
+
+ {{ticket.title}} +
+

+
+
+ {% if not tracker_sub %} +
+ {{csrf_token()}} + {% else %} +
+ {% endif %} + + {% if not tracker_sub %} + + {% else %} +
+ {% endif %} +
+
+
+
+ {% if ticket.description %} +
+ {{ ticket|render_ticket_description }} +
+ {% endif %} +
+
+
+
Status
+
+ + {{ ticket.status.name.upper() }} + {% if ticket.status == TicketStatus.resolved %} + {{ ticket.resolution.name.upper() }} + {% endif %} + +
+
Submitter
+
+ {{ ticket.submitter }} + {% if ticket.authenticity.name == "unauthenticated" %} + (unverified) + {% elif ticket.authenticity.name == "tampered" %} + (edited) + {% endif %} +
+
Assigned to
+
+ {% for assignee in ticket.assigned_users %} +
+ +
+ {% endfor %} + {% if not ticket.assigned_users %} + No-one + {% endif %} +
+
Submitted
+
+ {{ ticket.created | date }}
+
Updated
+
+ {{ ticket.updated | date }}
+
Labels
+
+ {% for label in ticket.labels %} + {{ label|label_badge }} + {% else %} + No labels applied. + {% endfor %} +
+
+
+
+
+

Edit comment

+
+

+ {{ comment.submitter }} + {% if comment.authenticity.name == "unauthenticated" %} + (unverified) + {% elif comment.authenticity.name == "tampered" %} + (edited) + {% endif %} + + + {{ comment.created | date }} + + +

+
+ {{csrf_token()}} +
+ + {{valid.summary("text")}} +
+ + +
+
+ + {% if rendered_preview %} +
+ Comment preview + {{ current_user }} +
+ {{ rendered_preview }} +
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/todosrht/templates/ticket.html b/todosrht/templates/ticket.html index 0d45907..8b13e4c 100644 --- a/todosrht/templates/ticket.html +++ b/todosrht/templates/ticket.html @@ -270,12 +270,12 @@ {% if event.comment.authenticity.name == "unauthenticated" %} (unverified) {% elif event.comment.authenticity.name == "tampered" %} (edited) {% endif %} {% endif %} @@ -348,6 +348,18 @@ {{ event.created | date }} + {%- if EventType.comment in event.event_type and + event.comment.superceedes -%} + * + {% endif %} + {% if EventType.comment in event.event_type + and (TicketAccess.triage in access + or event.comment.submitter.user == current_user) %} + ยท edit + {% endif %} diff --git a/todosrht/types/ticketcomment.py b/todosrht/types/ticketcomment.py index e128ed8..661e8e1 100644 --- a/todosrht/types/ticketcomment.py +++ b/todosrht/types/ticketcomment.py @@ -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,