todo.sr.ht/todosrht/blueprints/ticket.py

506 lines
17 KiB
Python

import re
from datetime import datetime
from flask import Blueprint, render_template, request, abort, redirect
from srht.config import cfg
from srht.database import db
from srht.oauth import current_user, loginrequired
from srht.validation import Validation
from todosrht.access import get_tracker, get_ticket
from todosrht.filters import render_markup
from todosrht.search import find_usernames
from todosrht.tickets import add_comment, 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, 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__)
posting_domain = cfg("todo.sr.ht::mail", "posting-domain")
ticket_subscribe_body = """\
Sending this email will subscribe your email address to {ticket_ref},
in so doing you will start receiving comments on this ticket.
You don't need to subscribe to the ticket if you're already subscribed
to the entire {tracker_ref} tracker.
You can unsubscribe at any time by mailing <{ticket_email_ref}/unsubscribe@""" + \
posting_domain + ">.\n"
def get_ticket_context(ticket, tracker, access):
"""Returns the context required to render ticket.html"""
tracker_sub = None
ticket_sub = None
ticket_subscribe = None
if current_user:
tracker_sub = (TicketSubscription.query
.join(Participant)
.filter(TicketSubscription.ticket_id == None)
.filter(TicketSubscription.tracker_id == tracker.id)
.filter(Participant.user_id == current_user.id)
).one_or_none()
ticket_sub = (TicketSubscription.query
.join(Participant)
.filter(TicketSubscription.ticket_id == ticket.id)
.filter(TicketSubscription.tracker_id == None)
.filter(Participant.user_id == current_user.id)
).one_or_none()
else:
subj = quote("Subscribing to " + ticket.ref())
ticket_subscribe = f"mailto:{ticket.ref(email=True)}/subscribe@" + \
f"{posting_domain}?subject={subj}&body=" + \
quote(ticket_subscribe_body.format(ticket_ref=ticket.ref(),
ticket_email_ref=ticket.ref(email=True),
tracker_ref=tracker.ref()))
reply_subject = quote("Re: " + ticket.title)
return {
"tracker": tracker,
"ticket": ticket,
"events": (Event.query
.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,
"recent_users": get_recent_users(tracker),
"reply_to": f"mailto:{ticket.ref(email=True)}@{posting_domain}" +
f"?subject={reply_subject}"
}
@ticket.route("/<owner>/<name>/<int:ticket_id>")
def ticket_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)
ctx = get_ticket_context(ticket, tracker, access)
return render_template("ticket.html", **ctx)
@ticket.route("/<owner>/<name>/<int:ticket_id>/enable_notifications", methods=["POST"])
@loginrequired
def enable_notifications(owner, name, ticket_id):
tracker, access = get_tracker(owner, name)
if not tracker:
abort(404)
ticket, access = get_ticket(tracker, ticket_id)
if not ticket:
abort(404)
participant = get_participant_for_user(current_user)
sub = (TicketSubscription.query
.filter(TicketSubscription.tracker_id == None)
.filter(TicketSubscription.ticket_id == ticket.id)
.filter(TicketSubscription.participant_id == participant.id)
).one_or_none()
if sub:
return redirect(ticket_url(ticket))
sub = TicketSubscription()
sub.ticket_id = ticket.id
sub.participant_id = participant.id
db.session.add(sub)
db.session.commit()
return redirect(ticket_url(ticket))
@ticket.route("/<owner>/<name>/<int:ticket_id>/disable_notifications", methods=["POST"])
@loginrequired
def disable_notifications(owner, name, ticket_id):
tracker, access = get_tracker(owner, name)
if not tracker:
abort(404)
ticket, access = get_ticket(tracker, ticket_id)
if not ticket:
abort(404)
participant = get_participant_for_user(current_user)
sub = (TicketSubscription.query
.filter(TicketSubscription.tracker_id == None)
.filter(TicketSubscription.ticket_id == ticket.id)
.filter(TicketSubscription.participant_id == participant.id)
).one_or_none()
if not sub:
return redirect(ticket_url(ticket))
db.session.delete(sub)
db.session.commit()
return redirect(ticket_url(ticket))
@ticket.route("/<owner>/<name>/<int:ticket_id>/comment", methods=["POST"])
@loginrequired
def ticket_comment_POST(owner, name, ticket_id):
tracker, access = get_tracker(owner, name)
if not tracker:
abort(404)
ticket, access = get_ticket(tracker, ticket_id)
if not ticket:
abort(404)
valid = Validation(request)
text = valid.optional("comment")
resolve = valid.optional("resolve")
resolution = valid.optional("resolution")
reopen = valid.optional("reopen")
preview = valid.optional("preview")
valid.expect(not text or 3 <= len(text) <= 16384,
"Comment must be between 3 and 16384 characters.", field="comment")
valid.expect(text or resolve or reopen,
"Comment is required", field="comment")
if (resolve or reopen) and TicketAccess.edit not in access:
abort(403)
if resolve:
try:
resolution = TicketResolution(int(resolution))
except Exception as ex:
abort(400, "Invalid resolution")
else:
resolution = None
if not valid.ok:
ctx = get_ticket_context(ticket, tracker, access)
return render_template("ticket.html", **ctx, **valid.kwargs)
if preview == "true":
ctx = get_ticket_context(ticket, tracker, access)
ctx.update({
"comment": text,
"rendered_preview": render_markup(tracker, text),
})
return render_template("ticket.html", **ctx)
participant = get_participant_for_user(current_user)
event = add_comment(participant, ticket,
text=text, resolve=resolve, resolution=resolution, reopen=reopen)
if not event:
return redirect(ticket_url(ticket))
TicketWebhook.deliver(TicketWebhook.Events.event_create,
event.to_dict(),
TicketWebhook.Subscription.ticket_id == ticket.id)
TrackerWebhook.deliver(TrackerWebhook.Events.event_create,
event.to_dict(),
TrackerWebhook.Subscription.tracker_id == ticket.tracker_id)
return redirect(ticket_url(ticket, event))
@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)
.order_by(Event.id.desc())).first()
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):
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("edit_ticket.html",
tracker=tracker, ticket=ticket)
@ticket.route("/<owner>/<name>/<int:ticket_id>/edit", methods=["POST"])
@loginrequired
def ticket_edit_POST(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)
valid = Validation(request)
title = valid.require("title", friendly_name="Title")
desc = valid.optional("description")
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:
return render_template("edit_ticket.html",
tracker=tracker, ticket=ticket, **valid.kwargs)
if "preview" in request.form:
preview = render_markup(tracker, desc)
return render_template("edit_ticket.html",
tracker=tracker, ticket=ticket, rendered_preview=preview,
**valid.kwargs)
ticket.title = title
ticket.description = desc
db.session.commit()
return redirect(ticket_url(ticket))
@ticket.route("/<owner>/<name>/<int:ticket_id>/add_label", methods=["POST"])
@loginrequired
def ticket_add_label(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.triage in access:
abort(401)
valid = Validation(request)
label_id = valid.require("label_id", friendly_name="A label")
if not valid.ok:
ctx = get_ticket_context(ticket, tracker, access)
return render_template("ticket.html", **ctx, **valid.kwargs)
valid.expect(re.match(r"^\d+$", label_id),
"Label ID must be numeric", field="label_id")
if not valid.ok:
ctx = get_ticket_context(ticket, tracker, access)
return render_template("ticket.html", **ctx, **valid.kwargs)
label_id = int(request.form.get('label_id'))
label = Label.query.filter(Label.id == label_id).first()
if not label:
abort(404)
ticket_label = (TicketLabel.query
.filter(TicketLabel.label_id == label.id)
.filter(TicketLabel.ticket_id == ticket.id)).first()
if not ticket_label:
ticket_label = TicketLabel()
ticket_label.ticket_id = ticket.id
ticket_label.label_id = label.id
ticket_label.user_id = current_user.id
participant = get_participant_for_user(current_user)
event = Event()
event.event_type = EventType.label_added
event.participant_id = participant.id
event.ticket_id = ticket.id
event.label_id = label.id
db.session.add(ticket_label)
db.session.add(event)
db.session.commit()
TicketWebhook.deliver(TicketWebhook.Events.event_create,
event.to_dict(),
TicketWebhook.Subscription.ticket_id == ticket.id)
TrackerWebhook.deliver(TrackerWebhook.Events.event_create,
event.to_dict(),
TrackerWebhook.Subscription.tracker_id == ticket.tracker_id)
return redirect(ticket_url(ticket))
@ticket.route("/<owner>/<name>/<int:ticket_id>/remove_label/<int:label_id>",
methods=["POST"])
@loginrequired
def ticket_remove_label(owner, name, ticket_id, label_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.triage in access:
abort(401)
label = Label.query.filter(Label.id==label_id).first()
if not label:
abort(404)
ticket_label = (TicketLabel.query
.filter(TicketLabel.label_id == label_id)
.filter(TicketLabel.ticket_id == ticket.id)).first()
if ticket_label:
participant = get_participant_for_user(current_user)
event = Event()
event.event_type = EventType.label_removed
event.participant_id = participant.id
event.ticket_id = ticket.id
event.label_id = label.id
db.session.add(event)
db.session.delete(ticket_label)
db.session.commit()
TicketWebhook.deliver(TicketWebhook.Events.event_create,
event.to_dict(),
TicketWebhook.Subscription.ticket_id == ticket.id)
TrackerWebhook.deliver(TrackerWebhook.Events.event_create,
event.to_dict(),
TrackerWebhook.Subscription.tracker_id == ticket.tracker_id)
return redirect(ticket_url(ticket))
def _assignment_get_ticket(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 TicketAccess.triage not in access:
abort(401)
return ticket
def _assignment_get_user(valid):
if 'myself' in valid:
username = current_user.username
else:
username = valid.require('username', friendly_name="Username")
if not valid.ok:
return None
if username.startswith("~"):
username = username[1:]
user = User.query.filter_by(username=username).one_or_none()
valid.expect(user, "User not found.", field="username")
return user
@ticket.route("/<owner>/<name>/<int:ticket_id>/assign", methods=["POST"])
@loginrequired
def ticket_assign(owner, name, ticket_id):
valid = Validation(request)
ticket = _assignment_get_ticket(owner, name, ticket_id)
user = _assignment_get_user(valid)
if not valid.ok:
_, access = get_ticket(ticket.tracker, ticket_id)
ctx = get_ticket_context(ticket, ticket.tracker, access)
return render_template("ticket.html", **valid.kwargs, **ctx)
assign(ticket, user, current_user)
db.session.commit()
return redirect(ticket_url(ticket))
@ticket.route("/<owner>/<name>/<int:ticket_id>/unassign", methods=["POST"])
@loginrequired
def ticket_unassign(owner, name, ticket_id):
valid = Validation(request)
ticket = _assignment_get_ticket(owner, name, ticket_id)
user = _assignment_get_user(valid)
if not valid.ok:
_, access = get_ticket(ticket.tracker, ticket_id)
ctx = get_ticket_context(ticket, ticket.tracker, access)
return render_template("ticket.html", valid, **ctx)
unassign(ticket, user, current_user)
db.session.commit()
return redirect(ticket_url(ticket))
@ticket.route("/usernames")
def usernames():
query = request.args.get('q')
return {
"results": find_usernames(query)
}