412 lines
13 KiB
Python
412 lines
13 KiB
Python
import re
|
|
from collections import namedtuple
|
|
from datetime import datetime
|
|
from srht.config import cfg
|
|
from srht.database import db
|
|
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 TicketSeen, TicketAssignee, User, Ticket, Tracker
|
|
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")
|
|
|
|
StatusChange = namedtuple("StatusChange", [
|
|
"old_status",
|
|
"new_status",
|
|
"old_resolution",
|
|
"new_resolution",
|
|
])
|
|
|
|
# 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)
|
|
|
|
# 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-z0-9_.-]+)? # Optional tracker name
|
|
\#(?P<ticket_id>\d+) # Ticket ID
|
|
\b # Word boundary
|
|
""", re.VERBOSE)
|
|
|
|
def find_mentioned_users(text):
|
|
usernames = re.findall(USER_MENTION_PATTERN, text)
|
|
users = User.query.filter(User.username.in_(usernames)).all()
|
|
return set(users)
|
|
|
|
def find_mentioned_tickets(tracker, text):
|
|
filters = or_()
|
|
|
|
for match in re.finditer(TICKET_MENTION_PATTERN, text):
|
|
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(filters)
|
|
.all())
|
|
|
|
def _create_comment(ticket, user, text):
|
|
comment = TicketComment()
|
|
comment.text = text
|
|
# TODO: anonymous comments (when configured appropriately)
|
|
comment.submitter_id = user.id
|
|
comment.ticket_id = ticket.id
|
|
|
|
db.session.add(comment)
|
|
db.session.flush()
|
|
return comment
|
|
|
|
def _create_comment_event(ticket, user, comment, status_change):
|
|
event = Event()
|
|
event.event_type = 0
|
|
event.user_id = user.id
|
|
event.ticket_id = ticket.id
|
|
|
|
if comment:
|
|
event.event_type |= EventType.comment
|
|
event.comment_id = comment.id
|
|
|
|
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
|
|
|
|
db.session.add(event)
|
|
db.session.flush()
|
|
return event
|
|
|
|
def _create_event_notification(user, event):
|
|
notification = EventNotification()
|
|
notification.user_id = user.id
|
|
notification.event_id = event.id
|
|
db.session.add(notification)
|
|
return notification
|
|
|
|
def _send_comment_notification(subscription, ticket, user, comment, resolution):
|
|
subject = "Re: {}: {}".format(ticket.ref(), ticket.title)
|
|
headers = {
|
|
"From": "~{} <{}>".format(user.username, notify_from),
|
|
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
|
|
"Sender": smtp_user,
|
|
}
|
|
|
|
url = ticket_url(ticket, comment=comment)
|
|
|
|
notify(subscription, "ticket_comment", subject,
|
|
headers=headers,
|
|
ticket=ticket,
|
|
comment=comment,
|
|
comment_text=format_lines(comment.text) if comment else "",
|
|
resolution=resolution.name if resolution else None,
|
|
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
|
|
|
|
if resolve:
|
|
ticket.status = TicketStatus.resolved
|
|
ticket.resolution = resolution
|
|
|
|
if reopen:
|
|
ticket.status = TicketStatus.reported
|
|
|
|
return StatusChange(
|
|
old_status, ticket.status, old_resolution, ticket.resolution)
|
|
|
|
def _send_comment_notifications(user, ticket, event, comment, resolution):
|
|
"""
|
|
Notify users subscribed to the ticket or tracker.
|
|
Returns a list of notified users.
|
|
"""
|
|
# Find subscribers, eliminate duplicates
|
|
subscriptions = {sub.user: sub
|
|
for sub in ticket.tracker.subscriptions + ticket.subscriptions}
|
|
|
|
# Subscribe commenter if not already subscribed
|
|
if user not in subscriptions:
|
|
subscription = TicketSubscription()
|
|
subscription.ticket_id = ticket.id
|
|
subscription.user_id = user.id
|
|
db.session.add(subscription)
|
|
subscriptions[user] = subscription
|
|
|
|
for subscriber, subscription in subscriptions.items():
|
|
_create_event_notification(subscriber, event)
|
|
if subscriber != user:
|
|
_send_comment_notification(
|
|
subscription, ticket, user, comment, resolution)
|
|
|
|
return subscriptions.keys()
|
|
|
|
def _send_mention_notification(sub, submitter, text, ticket, comment=None):
|
|
subject = "{}: {}".format(ticket.ref(), ticket.title)
|
|
headers = {
|
|
"From": "~{} <{}>".format(submitter.username, notify_from),
|
|
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
|
|
"Sender": smtp_user,
|
|
}
|
|
|
|
context = {
|
|
"text": format_lines(text),
|
|
"submitter": submitter.canonical_name,
|
|
"ticket_ref": ticket.ref(),
|
|
"ticket_url": ticket_url(ticket, comment),
|
|
}
|
|
|
|
notify(sub, "ticket_mention", subject, headers, **context)
|
|
|
|
|
|
def _handle_mentions(ticket, submitter, text, notified_users, comment=None):
|
|
"""
|
|
Create events for mentioned tickets and users and notify mentioned users.
|
|
"""
|
|
mentioned_users = find_mentioned_users(text)
|
|
mentioned_tickets = find_mentioned_tickets(ticket.tracker, text)
|
|
|
|
for user in mentioned_users:
|
|
db.session.add(Event(
|
|
event_type=EventType.user_mentioned,
|
|
user=user,
|
|
from_ticket=ticket,
|
|
by_user=submitter,
|
|
comment=comment,
|
|
))
|
|
|
|
for mentioned_ticket in mentioned_tickets:
|
|
db.session.add(Event(
|
|
event_type=EventType.ticket_mentioned,
|
|
ticket=mentioned_ticket,
|
|
from_ticket=ticket,
|
|
by_user=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
|
|
# Also don't notify the submitter if they mention themselves.
|
|
to_notify_users = mentioned_users - set(notified_users) - set([submitter])
|
|
for user in to_notify_users:
|
|
sub = get_or_create_subscription(ticket, user)
|
|
_send_mention_notification(sub, submitter, text, ticket, comment)
|
|
|
|
|
|
def add_comment(user, ticket,
|
|
text=None, resolve=False, resolution=None, reopen=False):
|
|
"""
|
|
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
|
|
|
|
comment = _create_comment(ticket, user, text) if text else None
|
|
status_change = _change_ticket_status(ticket, resolve, resolution, reopen)
|
|
event = _create_comment_event(ticket, user, comment, status_change)
|
|
notified_users = _send_comment_notifications(
|
|
user, ticket, event, comment, resolution)
|
|
|
|
if comment and comment.text:
|
|
_handle_mentions(
|
|
ticket,
|
|
comment.submitter,
|
|
comment.text,
|
|
notified_users,
|
|
comment,
|
|
)
|
|
|
|
ticket.updated = datetime.utcnow()
|
|
ticket.tracker.updated = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
return comment
|
|
|
|
def mark_seen(ticket, user):
|
|
"""Mark the ticket as seen by user."""
|
|
seen = TicketSeen.query.filter_by(user=user, ticket=ticket).one_or_none()
|
|
if seen:
|
|
seen.update() # Updates last_view time
|
|
else:
|
|
seen = TicketSeen(user_id=user.id, ticket_id=ticket.id)
|
|
db.session.add(seen)
|
|
|
|
return seen
|
|
|
|
def get_or_create_subscription(ticket, user):
|
|
"""
|
|
If user is subscribed to ticket or tracker, returns that subscription,
|
|
otherwise subscribes the user to the ticket and returns that one.
|
|
"""
|
|
subscription = TicketSubscription.query.filter(
|
|
(TicketSubscription.user == user) & (
|
|
(TicketSubscription.ticket == ticket) |
|
|
(TicketSubscription.tracker == ticket.tracker)
|
|
)
|
|
).first()
|
|
|
|
if not subscription:
|
|
subscription = TicketSubscription(ticket=ticket, user=user)
|
|
db.session.add(subscription)
|
|
|
|
return subscription
|
|
|
|
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)
|
|
headers = {
|
|
"From": "~{} <{}>".format(assigner.username, notify_from),
|
|
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
|
|
"Sender": smtp_user,
|
|
}
|
|
|
|
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)
|
|
|
|
subscription = get_or_create_subscription(ticket, assignee)
|
|
if assigner != assignee:
|
|
notify_assignee(subscription, ticket, assigner, assignee)
|
|
|
|
event = Event()
|
|
event.event_type = EventType.assigned_user
|
|
event.user_id = assignee.id
|
|
event.ticket_id = ticket.id
|
|
event.by_user_id = assigner.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)
|
|
|
|
event = Event()
|
|
event.event_type = EventType.unassigned_user
|
|
event.user_id = assignee.id
|
|
event.ticket_id = ticket.id
|
|
event.by_user_id = assigner.id
|
|
db.session.add(event)
|
|
|
|
def get_last_seen_times(user, tickets):
|
|
"""Fetches last times the user has seen each of the given tickets."""
|
|
return dict(db.session.query(TicketSeen.ticket_id, TicketSeen.last_view)
|
|
.filter(TicketSeen.ticket_id.in_([t.id for t in tickets]))
|
|
.filter(TicketSeen.user == user))
|
|
|
|
def get_comment_counts(tickets):
|
|
"""Returns comment counts indexed by ticket id."""
|
|
col = TicketComment.ticket_id
|
|
return dict(db.session
|
|
.query(col, func.count(col))
|
|
.filter(col.in_([t.id for t in tickets]))
|
|
.group_by(col))
|
|
|
|
def _send_new_ticket_notification(subscription, ticket):
|
|
subject = f"{ticket.ref()}: {ticket.title}"
|
|
headers = {
|
|
"From": "~{} <{}>".format(ticket.submitter.username, notify_from),
|
|
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
|
|
"Sender": smtp_user,
|
|
}
|
|
|
|
notify(subscription, "new_ticket", subject,
|
|
headers=headers, ticket=ticket, ticket_url=ticket_url(ticket))
|
|
|
|
def submit_ticket(tracker, submitter, title, description):
|
|
ticket = Ticket(
|
|
submitter=submitter,
|
|
tracker=tracker,
|
|
scoped_id=tracker.next_ticket_id,
|
|
title=title,
|
|
description=description,
|
|
)
|
|
db.session.add(ticket)
|
|
db.session.flush()
|
|
|
|
tracker.next_ticket_id += 1
|
|
tracker.updated = datetime.utcnow()
|
|
|
|
event = Event(event_type=EventType.created, user=submitter, ticket=ticket)
|
|
db.session.add(event)
|
|
db.session.flush()
|
|
|
|
# Subscribe submitter to the ticket if not already subscribed to the tracker
|
|
get_or_create_subscription(ticket, submitter)
|
|
|
|
# Send notifications
|
|
for sub in tracker.subscriptions:
|
|
_create_event_notification(sub.user, event)
|
|
if sub.user != submitter:
|
|
_send_new_ticket_notification(sub, ticket)
|
|
|
|
notified_users = [sub.user for sub in tracker.subscriptions]
|
|
_handle_mentions(
|
|
ticket,
|
|
ticket.submitter,
|
|
ticket.description,
|
|
notified_users,
|
|
)
|
|
|
|
db.session.commit()
|
|
return ticket
|