todo.sr.ht/todosrht/tickets.py

507 lines
18 KiB
Python
Raw Normal View History

2019-02-24 19:34:58 +01:00
import re
2018-10-30 11:20:07 +01:00
from collections import namedtuple
from datetime import datetime
from itertools import chain
from srht.config import cfg
from srht.database import db
2019-02-26 02:41:48 +01:00
from todosrht.email import notify, format_lines
from todosrht.types import Event, EventType, EventNotification
from todosrht.types import TicketComment, TicketStatus, TicketSubscription
from todosrht.types import TicketAssignee, User, Ticket, Tracker
2019-08-21 08:35:44 +02:00
from todosrht.types import Participant, ParticipantType
2018-10-30 11:20:07 +01:00
from todosrht.urls import ticket_url
from sqlalchemy import func, or_, and_
smtp_user = cfg("mail", "smtp-user", default=None)
smtp_from = cfg("mail", "smtp-from", default=None)
notify_from = cfg("todo.sr.ht", "notify-from", default=smtp_from)
posting_domain = cfg("todo.sr.ht::mail", "posting-domain")
origin = cfg("todo.sr.ht", "origin")
2018-10-30 11:20:07 +01:00
StatusChange = namedtuple("StatusChange", [
"old_status",
"new_status",
"old_resolution",
"new_resolution",
])
2019-02-24 19:34:58 +01:00
# Matches user mentions, e.g. ~username
USER_MENTION_PATTERN = re.compile(r"""
(?<![^\s(]) # No leading non-whitespace characters
~ # Literal tilde
(\w+) # The username
\b # Word boundary
(?!/) # Not followed by slash, possible qualified ticket mention
""", re.VERBOSE)
2019-02-24 19:34:58 +01:00
# Matches ticket mentions, e.g. #17, tracker#17 and ~user/tracker#17
TICKET_MENTION_PATTERN = re.compile(r"""
(?<![^\s(]) # No leading non-whitespace characters
(~(?P<username>\w+)/)? # Optional username
(?P<tracker_name>[A-Za-z0-9_.-]+)? # Optional tracker name
\#(?P<ticket_id>\d+) # Ticket ID
\b # Word boundary
""", re.VERBOSE)
# Matches ticket URL
TICKET_URL_PATTERN = re.compile(f"""
(?<![^\\s(]) # No leading non-whitespace characters
{origin}/ # Base URL
~(?P<username>\\w+)/ # Username
(?P<tracker_name>[A-Za-z0-9_.-]+)/ # Tracker name
(?P<ticket_id>\\d+) # Ticket ID
\\b # Word boundary
""", re.VERBOSE)
2019-08-21 08:35:44 +02:00
def get_participant_for_user(user):
participant = Participant.query.filter(
Participant.user_id == user.id).one_or_none()
if not participant:
participant = Participant()
participant.user_id = user.id
participant.participant_type = ParticipantType.user
db.session.add(participant)
db.session.flush()
return participant
def get_participant_for_email(email, email_name=None):
user = User.query.filter(User.email == email).one_or_none()
if user:
return get_participant_for_user(user)
participant = Participant.query.filter(
Participant.email == email).one_or_none()
if not participant:
participant = Participant()
participant.email = email
participant.email_name = email_name
participant.participant_type = ParticipantType.email
db.session.add(participant)
db.session.flush()
return participant
def get_participant_for_external(external_id, external_url):
participant = Participant.query.filter(
Participant.external_id == external_id).one_or_none()
if not participant:
participant = Participant()
participant.external_id = external_id
participant.external_url = external_url
participant.participant_type = ParticipantType.external
db.session.add(participant)
db.session.flush()
return participant
2019-02-24 19:34:58 +01:00
def find_mentioned_users(text):
if text is None:
2021-01-07 19:42:07 +01:00
return set()
2019-08-21 08:35:44 +02:00
# TODO: Find mentioned email addresses as well
2019-02-24 19:34:58 +01:00
usernames = re.findall(USER_MENTION_PATTERN, text)
users = User.query.filter(User.username.in_(usernames)).all()
2019-08-21 08:35:44 +02:00
participants = set([get_participant_for_user(u) for u in set(users)])
return participants
2019-02-24 19:34:58 +01:00
def find_mentioned_tickets(tracker, text):
2021-01-07 18:53:25 +01:00
if text is None:
return set()
filters = []
matches = chain(
re.finditer(TICKET_MENTION_PATTERN, text),
re.finditer(TICKET_URL_PATTERN, text),
)
for match in matches:
username = match.group('username') or tracker.owner.username
tracker_name = match.group('tracker_name') or tracker.name
ticket_id = int(match.group('ticket_id'))
filters.append(and_(
Ticket.scoped_id == ticket_id,
Tracker.name == tracker_name,
User.username == username,
))
# No tickets mentioned
if len(filters) == 0:
return set()
return set(Ticket.query
.join(Tracker, User)
.filter(or_(*filters))
.all())
2019-08-21 08:35:44 +02:00
def _create_comment(ticket, participant, text):
2018-10-30 11:20:07 +01:00
comment = TicketComment()
comment.text = text
2019-08-21 08:35:44 +02:00
comment.submitter_id = participant.id
2018-10-30 11:20:07 +01:00
comment.ticket_id = ticket.id
db.session.add(comment)
db.session.flush()
return comment
2019-08-21 08:35:44 +02:00
def _create_comment_event(ticket, participant, comment, status_change):
2018-10-30 11:20:07 +01:00
event = Event()
event.event_type = 0
2019-08-21 08:35:44 +02:00
event.participant_id = participant.id
2018-10-30 11:20:07 +01:00
event.ticket_id = ticket.id
2018-10-30 11:20:07 +01:00
if comment:
event.event_type |= EventType.comment
event.comment_id = comment.id
2018-10-30 11:20:07 +01:00
if status_change:
event.event_type |= EventType.status_change
event.old_status = status_change.old_status
event.old_resolution = status_change.old_resolution
event.new_status = status_change.new_status
event.new_resolution = status_change.new_resolution
2018-10-30 11:20:07 +01:00
db.session.add(event)
db.session.flush()
return event
2019-08-21 08:35:44 +02:00
def _create_event_notification(participant, event):
if participant.participant_type != ParticipantType.user:
return None # We only record notifications for registered users
2018-10-30 11:20:07 +01:00
notification = EventNotification()
2019-08-21 08:35:44 +02:00
notification.user_id = participant.user.id
2018-10-30 11:20:07 +01:00
notification.event_id = event.id
db.session.add(notification)
return notification
2019-08-21 08:35:44 +02:00
def _send_comment_notification(subscription, ticket,
2020-08-28 16:31:01 +02:00
participant, event, comment, resolution):
subject = "Re: {}: {}".format(ticket.ref(), ticket.title)
subscription_ref = subscription.tracker.ref() if subscription.tracker \
else ticket.ref(email=True)
2018-10-30 11:20:07 +01:00
headers = {
2019-08-21 08:35:44 +02:00
"From": "{} <{}>".format(participant.name, notify_from),
"In-Reply-To": f"<{ticket.ref(email=True)}@{posting_domain}>",
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
2020-09-05 17:51:28 +02:00
"Sender": f"<{smtp_user}@{posting_domain}>",
"List-Unsubscribe": f"mailto:{subscription_ref}/unsubscribe" +
f"@{posting_domain}"
2018-10-30 11:20:07 +01:00
}
2020-08-28 16:31:01 +02:00
url = ticket_url(ticket, event=event)
2018-10-30 11:20:07 +01:00
notify(subscription, "ticket_comment", subject,
headers=headers,
ticket=ticket,
comment=comment,
comment_text=format_lines(comment.text) if comment else "",
resolution=f"""Ticket resolved: {resolution.name}\n\n""" if resolution else "",
2018-10-30 11:20:07 +01:00
ticket_url=url)
def _change_ticket_status(ticket, resolve, resolution, reopen):
if not (resolve or reopen):
return None
old_status = ticket.status
old_resolution = ticket.resolution
2018-10-30 11:20:07 +01:00
if resolve:
if old_status == TicketStatus.resolved and old_resolution == resolution:
return None
ticket.status = TicketStatus.resolved
ticket.resolution = resolution
if reopen:
if old_status == TicketStatus.reported:
return None
ticket.status = TicketStatus.reported
2019-08-21 08:35:44 +02:00
return StatusChange(old_status, ticket.status,
old_resolution, ticket.resolution)
2019-08-21 08:35:44 +02:00
def _send_comment_notifications(
participant, ticket, event, comment, resolution, from_email):
"""
Notify users subscribed to the ticket or tracker.
Returns a list of notified users.
"""
2018-10-30 11:20:07 +01:00
# Find subscribers, eliminate duplicates
2019-08-21 08:35:44 +02:00
subscriptions = {sub.participant: sub
for sub in ticket.tracker.subscriptions + ticket.subscriptions}
2018-10-30 11:20:07 +01:00
# Subscribe commenter if not already subscribed
2019-08-21 08:35:44 +02:00
if participant not in subscriptions:
2018-10-30 11:20:07 +01:00
subscription = TicketSubscription()
subscription.ticket_id = ticket.id
subscription.participant = participant
2019-08-21 08:35:44 +02:00
subscription.participant_id = participant.id
2018-10-30 11:20:07 +01:00
db.session.add(subscription)
2019-08-21 08:35:44 +02:00
subscriptions[participant] = subscription
2018-10-30 11:20:07 +01:00
for subscriber, subscription in subscriptions.items():
_create_event_notification(subscriber, event)
if (participant.notify_self and not from_email) or subscriber != participant:
2018-10-30 11:20:07 +01:00
_send_comment_notification(
2020-08-28 16:31:01 +02:00
subscription, ticket, participant, event, comment, resolution)
2018-10-30 11:20:07 +01:00
return subscriptions.keys()
2019-03-30 09:27:21 +01:00
def _send_mention_notification(sub, submitter, text, ticket, comment=None):
subject = "{}: {}".format(ticket.ref(), ticket.title)
subscription_ref = sub.tracker.ref() if sub.tracker \
else ticket.ref(email=True)
headers = {
2019-08-21 08:35:44 +02:00
"From": "{} <{}>".format(submitter.name, notify_from),
"In-Reply-To": f"<{ticket.ref(email=True)}@{posting_domain}>",
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
2020-09-05 17:51:28 +02:00
"Sender": f"<{smtp_user}@{posting_domain}>",
"List-Unsubscribe": f"mailto:{subscription_ref}/unsubscribe" +
f"@{posting_domain}"
}
context = {
2019-03-30 09:27:21 +01:00
"text": format_lines(text),
2019-08-21 08:35:44 +02:00
"submitter": submitter.name,
"ticket_ref": ticket.ref(),
2019-03-30 09:27:21 +01:00
"ticket_url": ticket_url(ticket, comment),
}
2019-03-30 09:27:21 +01:00
notify(sub, "ticket_mention", subject, headers, **context)
2019-03-30 09:27:21 +01:00
def _handle_mentions(ticket, submitter, text, notified_users, comment=None):
"""
Create events for mentioned tickets and users and notify mentioned users.
"""
2019-08-21 08:35:44 +02:00
mentioned_participants = find_mentioned_users(text)
2019-03-30 09:27:21 +01:00
mentioned_tickets = find_mentioned_tickets(ticket.tracker, text)
2019-08-21 08:35:44 +02:00
for participant in mentioned_participants:
db.session.add(Event(
event_type=EventType.user_mentioned,
2019-08-21 08:35:44 +02:00
participant=participant,
from_ticket=ticket,
2019-08-21 08:35:44 +02:00
by_participant=submitter,
comment=comment,
))
for mentioned_ticket in mentioned_tickets:
if mentioned_ticket != ticket:
db.session.add(Event(
event_type=EventType.ticket_mentioned,
ticket=mentioned_ticket,
from_ticket=ticket,
by_participant=submitter,
comment=comment,
))
# Notify users who are mentioned, but only if they haven't already received
# a notification due to being subscribed to the event or tracker
2019-03-30 09:27:21 +01:00
# Also don't notify the submitter if they mention themselves.
2019-08-21 08:35:44 +02:00
to_notify = mentioned_participants - set(notified_users) - set([submitter])
for target in to_notify:
sub = get_or_create_subscription(ticket, target)
2019-03-30 09:27:21 +01:00
_send_mention_notification(sub, submitter, text, ticket, comment)
2019-08-21 08:35:44 +02:00
def add_comment(submitter, ticket,
text=None, resolve=False, resolution=None, reopen=False,
from_email=False):
2018-10-30 11:20:07 +01:00
"""
Comment on a ticket, optionally resolve or reopen the ticket.
"""
# TODO better error handling
assert text or resolve or reopen
assert not (resolve and reopen)
if resolve:
assert resolution is not None
2019-08-21 08:35:44 +02:00
comment = _create_comment(ticket, submitter, text) if text else None
2018-10-30 11:20:07 +01:00
status_change = _change_ticket_status(ticket, resolve, resolution, reopen)
if not comment and not status_change:
return None
2019-08-21 08:35:44 +02:00
event = _create_comment_event(ticket, submitter, comment, status_change)
notified_participants = _send_comment_notifications(
submitter, ticket, event, comment, resolution, from_email)
if comment and comment.text:
2019-03-30 09:27:21 +01:00
_handle_mentions(
ticket,
comment.submitter,
comment.text,
2019-08-21 08:35:44 +02:00
notified_participants,
2019-03-30 09:27:21 +01:00
comment,
)
ticket.comment_count = get_comment_count(ticket.id)
2018-10-30 11:20:07 +01:00
ticket.updated = datetime.utcnow()
ticket.tracker.updated = datetime.utcnow()
db.session.commit()
2019-04-30 22:17:43 +02:00
return event
2018-11-27 10:01:37 +01:00
2019-08-21 08:35:44 +02:00
def get_or_create_subscription(ticket, participant):
2019-01-01 15:15:19 +01:00
"""
2019-08-21 08:35:44 +02:00
If participant is subscribed to ticket or tracker, returns that
subscription, otherwise subscribes the user to the ticket and returns that
one.
2019-01-01 15:15:19 +01:00
"""
subscription = TicketSubscription.query.filter(
2019-08-21 08:35:44 +02:00
(TicketSubscription.participant == participant) & (
2019-01-01 15:15:19 +01:00
(TicketSubscription.ticket == ticket) |
(TicketSubscription.tracker == ticket.tracker)
)
).first()
if not subscription:
2019-08-21 08:35:44 +02:00
subscription = TicketSubscription(
ticket=ticket, participant=participant)
2019-01-01 15:15:19 +01:00
db.session.add(subscription)
return subscription
2019-08-21 08:35:44 +02:00
# TODO: support arbitrary participants being assigned to tickets
def notify_assignee(subscription, ticket, assigner, assignee):
"""
Sends a notification email to the person who was assigned to the issue.
"""
subject = "{}: {}".format(ticket.ref(), ticket.title)
subscription_ref = subscription.tracker.ref() if subscription.tracker \
else ticket.ref(email=True)
headers = {
"From": "~{} <{}>".format(assigner.username, notify_from),
"In-Reply-To": f"<{ticket.ref(email=True)}@{posting_domain}>",
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
2020-09-05 17:51:28 +02:00
"Sender": f"<{smtp_user}@{posting_domain}>",
"List-Unsubscribe": f"mailto:{subscription_ref}/unsubscribe" +
f"@{posting_domain}"
}
context = {
"assigner": assigner.canonical_name,
"ticket_ref": ticket.ref(),
"ticket_url": ticket_url(ticket)
}
notify(subscription, "ticket_assigned", subject, headers, **context)
def assign(ticket, assignee, assigner):
role = "" # Role is not yet implemented
ticket_assignee = TicketAssignee.query.filter_by(
ticket=ticket, assignee=assignee).one_or_none()
# If already assigned, do nothing
if ticket_assignee:
return ticket_assignee
ticket_assignee = TicketAssignee(
ticket=ticket,
assignee=assignee,
assigner=assigner,
role=role,
)
db.session.add(ticket_assignee)
2019-08-21 08:35:44 +02:00
assignee_participant = get_participant_for_user(assignee)
assigner_participant = get_participant_for_user(assigner)
subscription = get_or_create_subscription(ticket, assignee_participant)
if assigner.notify_self or assigner != assignee:
notify_assignee(subscription, ticket, assigner, assignee)
event = Event()
event.event_type = EventType.assigned_user
2019-08-21 08:35:44 +02:00
event.participant_id = assignee_participant.id
event.ticket_id = ticket.id
event.by_participant_id = assigner_participant.id
db.session.add(event)
return ticket_assignee
def unassign(ticket, assignee, assigner):
ticket_assignee = TicketAssignee.query.filter_by(
ticket=ticket, assignee=assignee).one_or_none()
# If not assigned, do nothing
if not ticket_assignee:
return None
db.session.delete(ticket_assignee)
2019-08-21 08:35:44 +02:00
assignee_participant = get_participant_for_user(assignee)
assigner_participant = get_participant_for_user(assigner)
event = Event()
event.event_type = EventType.unassigned_user
2019-08-21 08:35:44 +02:00
event.participant_id = assignee_participant.id
event.ticket_id = ticket.id
event.by_participant_id = assigner_participant.id
db.session.add(event)
def get_comment_count(ticket_id):
"""Returns the number of comments on a given ticket."""
return (
TicketComment.query
.filter_by(ticket_id=ticket_id, superceeded_by_id=None)
.count()
)
def _send_new_ticket_notification(subscription, ticket, email_trigger_id):
2019-03-06 17:39:47 +01:00
subject = f"{ticket.ref()}: {ticket.title}"
subscription_ref = subscription.tracker.ref() if subscription.tracker \
else ticket.ref(email=True)
2019-03-06 17:39:47 +01:00
headers = {
2019-08-21 08:35:44 +02:00
"From": "{} <{}>".format(ticket.submitter.name, notify_from),
"Message-ID": f"<{ticket.ref(email=True)}@{posting_domain}>",
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
2020-09-05 17:51:28 +02:00
"Sender": f"<{smtp_user}@{posting_domain}>",
"List-Unsubscribe": f"mailto:{subscription_ref}/unsubscribe" +
f"@{posting_domain}"
2019-03-06 17:39:47 +01:00
}
if email_trigger_id:
headers["In-Reply-To"] = email_trigger_id
2019-03-06 17:39:47 +01:00
notify(subscription, "new_ticket", subject,
2021-05-16 22:14:45 +02:00
headers=headers, description=ticket.description,
ticket_url=ticket_url(ticket))
2019-03-06 17:39:47 +01:00
def submit_ticket(tracker, submitter, title, description,
importing=False, from_email=False, from_email_id=None):
2019-03-06 17:39:47 +01:00
ticket = Ticket(
submitter=submitter,
tracker=tracker,
scoped_id=tracker.next_ticket_id,
title=title,
description=description,
)
db.session.add(ticket)
2019-03-06 17:39:47 +01:00
db.session.flush()
tracker.next_ticket_id += 1
tracker.updated = datetime.utcnow()
2019-03-06 17:39:47 +01:00
2019-08-21 08:35:44 +02:00
event = Event(event_type=EventType.created,
participant=submitter, ticket=ticket)
db.session.add(event)
db.session.flush()
2019-03-06 17:39:47 +01:00
# Subscribe submitter to the ticket if not already subscribed to the tracker
2020-01-09 18:19:44 +01:00
if not importing:
get_or_create_subscription(ticket, submitter)
all_subscriptions = {sub.participant: sub
for sub
in ticket.subscriptions + tracker.subscriptions}
2019-03-30 09:27:21 +01:00
2020-01-09 18:19:44 +01:00
# Send notifications
for sub in all_subscriptions.values():
2020-01-09 18:19:44 +01:00
_create_event_notification(sub.participant, event)
# Always notify submitter for tickets created by email
if from_email or submitter.notify_self or sub.participant != submitter:
_send_new_ticket_notification(sub, ticket, from_email_id)
2020-01-09 18:19:44 +01:00
_handle_mentions(
ticket,
ticket.submitter,
ticket.description,
all_subscriptions.keys(),
2020-01-09 18:19:44 +01:00
)
db.session.commit()
return ticket