390 lines
13 KiB
Python
390 lines
13 KiB
Python
import re
|
|
from urllib.parse import quote
|
|
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 invalidate_markup_cache
|
|
from todosrht.search import find_usernames
|
|
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 TicketSubscription, User, Participant
|
|
from todosrht.urls import ticket_url
|
|
from todosrht.webhooks import TrackerWebhook, TicketWebhook
|
|
|
|
|
|
ticket = Blueprint("ticket", __name__)
|
|
|
|
def get_ticket_context(ticket, tracker, access):
|
|
"""Returns the context required to render ticket.html"""
|
|
tracker_sub = None
|
|
ticket_sub = 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()
|
|
|
|
posting_domain = cfg("todo.sr.ht::mail", "posting-domain")
|
|
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,
|
|
"tracker_sub": tracker_sub,
|
|
"ticket_sub": ticket_sub,
|
|
"recent_users": get_recent_users(tracker),
|
|
"reply_to": f"mailto:{tracker.owner.canonical_name}/{tracker.name}/" +
|
|
f"{ticket.scoped_id}@{posting_domain}?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)
|
|
|
|
if current_user:
|
|
mark_seen(ticket, current_user)
|
|
db.session.commit()
|
|
|
|
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")
|
|
|
|
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)
|
|
|
|
participant = get_participant_for_user(current_user)
|
|
event = add_comment(participant, ticket,
|
|
text=text, resolve=resolve, resolution=resolution, reopen=reopen)
|
|
|
|
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.comment))
|
|
|
|
@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)
|
|
|
|
ticket.title = title
|
|
ticket.description = desc
|
|
db.session.commit()
|
|
|
|
invalidate_markup_cache(ticket)
|
|
|
|
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.edit 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.edit 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)
|
|
}
|