Generalize users into participants

This commit is contained in:
Drew DeVault 2019-08-21 15:35:44 +09:00
parent f4f961c00f
commit b4c92a39db
26 changed files with 575 additions and 245 deletions

View File

@ -2,7 +2,7 @@ import factory
from datetime import datetime, timedelta
from factory.fuzzy import FuzzyText
from srht.database import db
from todosrht.types import Tracker, User, Ticket
from todosrht.types import Tracker, User, Ticket, Participant, ParticipantType
future_datetime = datetime.now() + timedelta(days=10)
@ -20,6 +20,15 @@ class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
sqlalchemy_session = db.session
class ParticipantFactory(factory.alchemy.SQLAlchemyModelFactory):
participant_type = ParticipantType.user
user = factory.SubFactory(UserFactory)
class Meta:
model = Participant
sqlalchemy_session = db.session
class TrackerFactory(factory.alchemy.SQLAlchemyModelFactory):
owner = factory.SubFactory(UserFactory)
name = factory.Sequence(lambda n: f"tracker{n}")
@ -32,7 +41,7 @@ class TrackerFactory(factory.alchemy.SQLAlchemyModelFactory):
class TicketFactory(factory.alchemy.SQLAlchemyModelFactory):
tracker = factory.SubFactory(TrackerFactory)
scoped_id = factory.Sequence(lambda n: n)
submitter = factory.SubFactory(UserFactory)
submitter = factory.SubFactory(ParticipantFactory)
title = "A wild ticket appeared"
class Meta:

View File

@ -9,20 +9,21 @@ from todosrht.types import TicketResolution, TicketStatus
from todosrht.types import TicketSubscription, EventType
from .factories import UserFactory, TrackerFactory, TicketFactory
from .factories import ParticipantFactory
def test_ticket_comment(mailbox):
user = UserFactory()
submitter = ParticipantFactory()
tracker = TrackerFactory()
ticket = TicketFactory(tracker=tracker)
subscribed_to_ticket = UserFactory()
subscribed_to_tracker = UserFactory()
subscribed_to_both = UserFactory()
subscribed_to_ticket = ParticipantFactory()
subscribed_to_tracker = ParticipantFactory()
subscribed_to_both = ParticipantFactory()
sub1 = TicketSubscription(user=subscribed_to_ticket, ticket=ticket)
sub2 = TicketSubscription(user=subscribed_to_tracker, tracker=tracker)
sub3 = TicketSubscription(user=subscribed_to_both, ticket=ticket)
sub4 = TicketSubscription(user=subscribed_to_both, tracker=tracker)
sub1 = TicketSubscription(participant=subscribed_to_ticket, ticket=ticket)
sub2 = TicketSubscription(participant=subscribed_to_tracker, tracker=tracker)
sub3 = TicketSubscription(participant=subscribed_to_both, ticket=ticket)
sub4 = TicketSubscription(participant=subscribed_to_both, tracker=tracker)
db.session.add(sub1)
db.session.add(sub2)
@ -35,22 +36,22 @@ def test_ticket_comment(mailbox):
emails = mailbox[-3:]
assert {e.to for e in emails} == {
subscribed_to_ticket.email,
subscribed_to_tracker.email,
subscribed_to_both.email,
subscribed_to_ticket.user.email,
subscribed_to_tracker.user.email,
subscribed_to_both.user.email,
}
for e in emails:
assert e.headers['From'].startswith(user.canonical_name)
assert e.headers['From'].startswith(submitter.name)
if starts_with:
assert e.body.startswith(starts_with)
def assert_event_notifications_created(event):
assert {en.user.email for en in event.notifications} == {
subscribed_to_ticket.email,
subscribed_to_tracker.email,
subscribed_to_both.email,
event.user.email,
subscribed_to_ticket.user.email,
subscribed_to_tracker.user.email,
subscribed_to_both.user.email,
event.participant.user.email,
}
assert len(mailbox) == 0
@ -58,12 +59,13 @@ def test_ticket_comment(mailbox):
assert ticket.resolution == TicketResolution.unresolved
# Comment without status change
event = add_comment(user, ticket, text="how do you do, i")
event = add_comment(submitter, ticket, text="how do you do, i")
# Submitter gets automatically subscribed
assert TicketSubscription.query.filter_by(ticket=ticket, user=user).first()
assert TicketSubscription.query.filter_by(
ticket=ticket, participant=submitter).first()
assert event.comment.submitter == user
assert event.comment.submitter == submitter
assert event.comment.ticket == ticket
assert event.comment.text == "how do you do, i"
@ -80,10 +82,10 @@ def test_ticket_comment(mailbox):
assert_event_notifications_created(event)
# Comment and resolve issue
event = add_comment(user, ticket, text="see you've met my",
event = add_comment(submitter, ticket, text="see you've met my",
resolve=True, resolution=TicketResolution.fixed)
assert event.comment.submitter == user
assert event.comment.submitter == submitter
assert event.comment.ticket == ticket
assert event.comment.text == "see you've met my"
@ -104,9 +106,10 @@ def test_ticket_comment(mailbox):
assert_event_notifications_created(event)
# Comment and reopen issue
event = add_comment(user, ticket, text="faithful handyman", reopen=True)
event = add_comment(submitter, ticket,
text="faithful handyman", reopen=True)
assert event.comment.submitter == user
assert event.comment.submitter == submitter
assert event.comment.ticket == ticket
assert event.comment.text == "faithful handyman"
@ -122,7 +125,7 @@ def test_ticket_comment(mailbox):
assert_event_notifications_created(event)
# Resolve without commenting
event = add_comment(user, ticket,
event = add_comment(submitter, ticket,
resolve=True, resolution=TicketResolution.wont_fix)
assert ticket.status == TicketStatus.resolved
@ -138,7 +141,7 @@ def test_ticket_comment(mailbox):
assert_event_notifications_created(event)
# Reopen without commenting
event = add_comment(user, ticket, reopen=True)
event = add_comment(submitter, ticket, reopen=True)
assert ticket.status == TicketStatus.reported
assert ticket.resolution == TicketResolution.wont_fix
@ -153,13 +156,13 @@ def test_ticket_comment(mailbox):
def test_failed_comments():
user = UserFactory()
participant = ParticipantFactory()
tracker = TrackerFactory()
ticket = TicketFactory(tracker=tracker)
db.session.flush()
with pytest.raises(AssertionError):
add_comment(user, ticket)
add_comment(participant, ticket)
def test_user_mention_pattern():
@ -189,25 +192,25 @@ def test_find_mentioned_users():
assert find_mentioned_users(comment) == set()
u1 = UserFactory(username="mention1")
p1 = ParticipantFactory(user=UserFactory(username="mention1"))
db.session.commit()
assert find_mentioned_users(comment) == {u1}
assert find_mentioned_users(comment) == {p1}
u2 = UserFactory(username="mention2")
p2 = ParticipantFactory(user=UserFactory(username="mention2"))
db.session.commit()
assert find_mentioned_users(comment) == {u1, u2}
assert find_mentioned_users(comment) == {p1, p2}
u3 = UserFactory(username="mention3")
p3 = ParticipantFactory(user=UserFactory(username="mention3"))
db.session.commit()
assert find_mentioned_users(comment) == {u1, u2, u3}
assert find_mentioned_users(comment) == {p1, p2, p3}
def test_notifications_and_events(mailbox):
u1 = UserFactory()
u2 = UserFactory()
u3 = UserFactory() # not mentioned
p1 = ParticipantFactory()
p2 = ParticipantFactory()
p3 = ParticipantFactory() # not mentioned
commenter = UserFactory()
commenter = ParticipantFactory()
ticket = TicketFactory()
t1 = TicketFactory(tracker=ticket.tracker)
@ -217,16 +220,16 @@ def test_notifications_and_events(mailbox):
db.session.flush()
text = (
f"mentioning users {u1.canonical_name}, ~doesnotexist, "
f"and {u2.canonical_name} "
f"mentioning users {p1.identifier}, ~doesnotexist, "
f"and {p2.identifier} "
f"also mentioning tickets #{t1.scoped_id}, and #{t2.scoped_id} and #999999"
)
event = add_comment(commenter, ticket, text)
assert len(mailbox) == 2
email1 = next(e for e in mailbox if e.to == u1.email)
email2 = next(e for e in mailbox if e.to == u2.email)
email1 = next(e for e in mailbox if e.to == p1.user.email)
email2 = next(e for e in mailbox if e.to == p2.user.email)
expected_title = f"{ticket.ref()}: {ticket.title}"
expected_body = f"You were mentioned in {ticket.ref()} by {commenter}."
@ -240,25 +243,25 @@ def test_notifications_and_events(mailbox):
# Check correct events are generated
comment_events = {e for e in ticket.events
if e.event_type == EventType.comment}
u1_events = {e for e in u1.events
p1_events = {e for e in p1.events
if e.event_type == EventType.user_mentioned}
u2_events = {e for e in u2.events
p2_events = {e for e in p2.events
if e.event_type == EventType.user_mentioned}
assert len(comment_events) == 1
assert len(u1_events) == 1
assert len(u2_events) == 1
assert len(p1_events) == 1
assert len(p2_events) == 1
u1_mention = u1_events.pop()
u2_mention = u2_events.pop()
p1_mention = p1_events.pop()
p2_mention = p2_events.pop()
assert u1_mention.comment == event.comment
assert u1_mention.from_ticket == ticket
assert u1_mention.by_user == commenter
assert p1_mention.comment == event.comment
assert p1_mention.from_ticket == ticket
assert p1_mention.by_participant == commenter
assert u2_mention.comment == event.comment
assert u2_mention.from_ticket == ticket
assert u2_mention.by_user == commenter
assert p2_mention.comment == event.comment
assert p2_mention.from_ticket == ticket
assert p2_mention.by_participant == commenter
assert len(t1.events) == 1
assert len(t2.events) == 1
@ -269,11 +272,11 @@ def test_notifications_and_events(mailbox):
assert t1_mention.comment == event.comment
assert t1_mention.from_ticket == ticket
assert t1_mention.by_user == commenter
assert t1_mention.by_participant == commenter
assert t2_mention.comment == event.comment
assert t2_mention.from_ticket == ticket
assert t2_mention.by_user == commenter
assert t2_mention.by_participant == commenter
def test_ticket_mention_pattern():
def match(text):

View File

@ -2,6 +2,7 @@ import pytest
from itertools import chain
from srht.database import db
from tests.factories import UserFactory, TrackerFactory, TicketFactory
from tests.factories import ParticipantFactory
from todosrht.tickets import submit_ticket
from todosrht.types import Ticket, EventType, TicketSubscription, Event
from todosrht.urls import ticket_url
@ -9,16 +10,16 @@ from todosrht.urls import ticket_url
@pytest.mark.parametrize("submitter_subscribed", [True, False])
def test_submit_ticket(client, mailbox, submitter_subscribed):
tracker = TrackerFactory()
submitter = UserFactory()
submitter = ParticipantFactory()
subscriber = UserFactory()
TicketSubscription(user=subscriber, tracker=tracker)
subscriber = ParticipantFactory()
TicketSubscription(participant=subscriber, tracker=tracker)
# `submitter_subscribed` parameter defines whether the submitter was
# subscribed to the tracker prior to submitting the ticket
# this affects whether a new subscription is created when submitting
if submitter_subscribed:
TicketSubscription(user=submitter, tracker=tracker)
TicketSubscription(participant=submitter, tracker=tracker)
db.session.commit()
@ -40,48 +41,44 @@ def test_submit_ticket(client, mailbox, submitter_subscribed):
event = ticket.events[0]
assert event.event_type == EventType.created
assert event.ticket == ticket
assert event.user == submitter
assert event.participant == submitter
# Check subscriber got an email
assert len(mailbox) == 1
email = mailbox[0]
assert email.to == subscriber.email
assert email.to == subscriber.user.email
assert title in email.subject
assert ticket.ref() in email.subject
assert description in email.body
assert ticket_url(ticket) in email.body
assert email.headers['From'].startswith(submitter.canonical_name)
assert email.headers['From'].startswith(submitter.name)
# Check event notification is created for the subscriber
assert len(subscriber.notifications) == 1
notification = subscriber.notifications[0]
assert len(subscriber.user.notifications) == 1
notification = subscriber.user.notifications[0]
assert notification.event == event
# Check submitter is subscribed to the ticket
assert len(submitter.subscriptions) == 1
subscription = submitter.subscriptions[0]
if submitter_subscribed:
assert subscription.tracker == tracker
assert subscription.ticket is None
next((s for s in submitter.subscriptions if s.tracker == tracker))
else:
assert subscription.tracker is None
assert subscription.ticket == ticket
next((s for s in submitter.subscriptions if s.ticket == ticket))
def test_mentions_in_ticket_description(mailbox):
owner = UserFactory()
tracker = TrackerFactory(owner=owner)
submitter = UserFactory()
submitter = ParticipantFactory()
t1 = TicketFactory(tracker=tracker)
t2 = TicketFactory(tracker=tracker)
t3 = TicketFactory(tracker=tracker)
u1 = UserFactory()
u2 = UserFactory()
p1 = ParticipantFactory()
p2 = ParticipantFactory()
subscriber = UserFactory()
TicketSubscription(user=subscriber, tracker=tracker)
subscriber = ParticipantFactory()
TicketSubscription(participant=subscriber, tracker=tracker)
db.session.flush()
tracker.next_ticket_id = t3.id + 1
@ -91,11 +88,11 @@ def test_mentions_in_ticket_description(mailbox):
description = f"""
Testing mentioning
---------------
myself: {submitter.canonical_name}
user one: {u1.canonical_name}
user two: {u2.canonical_name}
myself: {submitter.identifier}
user one: {p1.identifier}
user two: {p2.identifier}
nonexistant user: ~hopefullythisuserdoesnotexist
subscriber: {subscriber.canonical_name}
subscriber: {subscriber.identifier}
ticket one: #{t1.scoped_id}
ticket two: {tracker.name}#{t2.scoped_id}
ticket three: {owner.canonical_name}/{tracker.name}#{t3.scoped_id}
@ -113,42 +110,42 @@ def test_mentions_in_ticket_description(mailbox):
for event in chain(t1.events, t2.events, t3.events):
assert event.event_type == EventType.ticket_mentioned
assert event.user is None
assert event.participant is None
assert event.comment is None
assert event.from_ticket == ticket
assert event.by_user == submitter
assert event.by_participant == submitter
# Check user events, skip the ticket create event
submitter_events = [e for e in submitter.events
if e.event_type != EventType.created]
assert len(submitter_events) == 1
assert len(u1.events) == 1
assert len(u2.events) == 1
assert len(p1.events) == 1
assert len(p2.events) == 1
assert len(subscriber.events) == 1
for event in chain(
submitter_events, u1.events, u2.events, subscriber.events
submitter_events, p1.events, p2.events, subscriber.events
):
assert event.event_type == EventType.user_mentioned
assert event.ticket is None
assert event.comment is None
assert event.from_ticket == ticket
assert event.by_user == submitter
assert event.by_participant == submitter
# Check emails
# Submitter should not have been emailed since they mentioned themselves
assert len(mailbox) == 3
subscriber_email = next(e for e in mailbox if e.to == subscriber.email)
u1_email = next(e for e in mailbox if e.to == u1.email)
u2_email = next(e for e in mailbox if e.to == u2.email)
subscriber_email = next(e for e in mailbox if e.to == subscriber.user.email)
p1_email = next(e for e in mailbox if e.to == p1.user.email)
p2_email = next(e for e in mailbox if e.to == p2.user.email)
# Subscriber should receive a notification that the ticket was created so
# they're not sent a notification for the mention.
assert subscriber_email.body.strip().startswith("Testing mentioning")
# Other mentioned users who are not subscribed should get a mention email
expected = f"You were mentioned in {ticket.ref()} by {submitter}."
assert u1_email.body.startswith(expected)
assert u2_email.body.startswith(expected)
expected = f"You were mentioned in {ticket.ref()} by {submitter.name}."
assert p1_email.body.startswith(expected)
assert p2_email.body.startswith(expected)

View File

@ -1,5 +1,6 @@
from srht.database import db
from tests.factories import TrackerFactory, TicketFactory, UserFactory
from tests.factories import ParticipantFactory
from tests.utils import logged_in_as
from todosrht.tickets import get_or_create_subscription
from todosrht.types import TicketSeen, TicketSubscription
@ -32,15 +33,15 @@ def test_mark_seen(client):
assert second_time > first_time
def test_get_or_create_subscription():
user1 = UserFactory()
user2 = UserFactory()
user3 = UserFactory()
participant1 = ParticipantFactory()
participant2 = ParticipantFactory()
participant3 = ParticipantFactory()
tracker = TrackerFactory()
ticket = TicketFactory(tracker=tracker)
# Some existing subscriptions
ts1 = TicketSubscription(user=user1, ticket=ticket)
ts2 = TicketSubscription(user=user2, tracker=tracker)
ts1 = TicketSubscription(participant=participant1, ticket=ticket)
ts2 = TicketSubscription(participant=participant2, tracker=tracker)
db.session.add(ts1)
db.session.add(ts2)
db.session.commit()
@ -49,11 +50,11 @@ def test_get_or_create_subscription():
assert set(tracker.subscriptions) == set([ts2])
# Return existing subs if they exist
assert get_or_create_subscription(ticket, user1) == ts1
assert get_or_create_subscription(ticket, user2) == ts2
assert get_or_create_subscription(ticket, participant1) == ts1
assert get_or_create_subscription(ticket, participant2) == ts2
# Create new ticket sub if none exists
ts3 = get_or_create_subscription(ticket, user3)
ts3 = get_or_create_subscription(ticket, participant3)
db.session.commit()
assert set(ticket.subscriptions) == set([ts1, ts3])

View File

@ -11,7 +11,7 @@ from grp import getgrnam
from todosrht.access import get_tracker, get_ticket
from todosrht.types import TicketAccess, TicketResolution, Tracker, Ticket, User
from todosrht.types import Label, TicketLabel, Event, EventType
from todosrht.tickets import submit_ticket, add_comment
from todosrht.tickets import add_comment, get_participant_for_user, submit_ticket
from todosrht.webhooks import UserWebhook, TrackerWebhook, TicketWebhook
from srht.validation import Validation
import asyncio
@ -105,7 +105,8 @@ class MailHandler:
print("Rejecting email due to validation errors")
return "550 " + ", ".join([e["reason"] for e in valid.errors])
ticket = submit_ticket(tracker, sender, title, desc)
participant = get_participant_for_user(sender)
ticket = submit_ticket(tracker, participant, title, desc)
UserWebhook.deliver(UserWebhook.Events.ticket_create,
ticket.to_dict(),
UserWebhook.Subscription.user_id == sender.id)
@ -122,6 +123,7 @@ class MailHandler:
resolution = None
resolve = reopen = False
cmds = ["!resolve", "!reopen", "!assign", "!label", "!unlabel"]
participant = get_participant_for_user(sender)
if any(last_line.startswith(cmd) for cmd in cmds):
cmd = shlex.split(last_line)
body = body[:-len(last_line)-1].rstrip()
@ -139,7 +141,7 @@ class MailHandler:
return ("550 The label you requested does not exist on " +
"this tracker.")
if not TicketAccess.triage in access:
print(f"Rejected, {sender.canonical_name} has insufficient " +
print(f"Rejected, {participant.name} has insufficient " +
f"permissions (have {access}, want triage)")
return "550 You do not have permission to triage on this tracker."
for label in labels:
@ -147,10 +149,11 @@ class MailHandler:
.filter(TicketLabel.label_id == label.id)
.filter(TicketLabel.ticket_id == ticket.id)).first()
event = Event()
event.user_id = sender.id
event.participant_id = participant.id
event.ticket_id = ticket.id
event.label_id = label.id
if not ticket_label and cmd[0] == "!label":
# TODO: only supported for user participants
ticket_label = TicketLabel()
ticket_label.ticket_id = ticket.id
ticket_label.label_id = label.id
@ -171,7 +174,7 @@ class MailHandler:
# TODO: Remaining commands
if not required_access in access:
print(f"Rejected, {sender.canonical_name} has insufficient " +
print(f"Rejected, {participant.name} has insufficient " +
f"permissions (have {access}, want {required_access})")
return "550 You do not have permission to post on this tracker."
@ -180,7 +183,7 @@ class MailHandler:
return "550 Comment must be between 3 and 16384 characters."
if body:
event = add_comment(sender, ticket, text=body,
event = add_comment(participant, ticket, text=body,
resolution=resolution, resolve=resolve, reopen=reopen)
TicketWebhook.deliver(TicketWebhook.Events.event_create,
event.to_dict(),

View File

@ -10,6 +10,7 @@ def _get_permissions(tracker, ticket, name):
return getattr(ticket, f"{name}_perms")
return getattr(tracker, f"default_{name}_perms")
# TODO: get_access for any participant
def get_access(tracker, ticket, user=None):
user = user or current_user
@ -27,7 +28,7 @@ def get_access(tracker, ticket, user=None):
return user_access.permissions
# Submitter
if ticket and user.id == ticket.submitter_id:
if ticket and user.id == ticket.submitter.user_id:
return _get_permissions(tracker, ticket, "submitter")
# Any logged in user

View File

@ -0,0 +1,169 @@
"""Migrate users to participants
Revision ID: 4631a2317dd0
Revises: d54ed600c4bf
Create Date: 2019-08-21 11:09:46.626025
"""
# revision identifiers, used by Alembic.
revision = '4631a2317dd0'
down_revision = 'd54ed600c4bf'
from alembic import op
from enum import Enum
import sqlalchemy as sa
import sqlalchemy_utils as sau
class ParticipantType(Enum):
user = "user"
email = "email"
external = "external"
def upgrade():
op.create_table("participant",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("created", sa.DateTime, nullable=False),
sa.Column("participant_type",
sau.ChoiceType(ParticipantType, impl=sa.String()),
nullable=False),
sa.Column("user_id", sa.Integer,
sa.ForeignKey("user.id", ondelete="CASCADE"), unique=True),
sa.Column("email", sa.String, unique=True),
sa.Column("email_name", sa.String),
sa.Column("external_id", sa.String, unique=True),
sa.Column("external_url", sa.String))
op.execute("""
ALTER TABLE participant ALTER COLUMN created DROP NOT NULL;
ALTER TABLE participant ALTER COLUMN participant_type DROP NOT NULL;
INSERT INTO participant(user_id)
SELECT submitter_id AS user_id FROM ticket
UNION SELECT user_id FROM event
UNION SELECT by_user_id AS user_id FROM event
UNION SELECT submitter_id AS user_id FROM ticket_comment
UNION SELECT user_id FROM ticket_subscription;
UPDATE participant
SET created = now() at time zone 'utc', participant_type = 'user';
ALTER TABLE participant ALTER COLUMN created SET NOT NULL;
ALTER TABLE participant ALTER COLUMN participant_type SET NOT NULL;
""")
op.add_column("ticket", sa.Column(
"participant_id", sa.Integer, sa.ForeignKey("participant.id")))
op.execute("""
UPDATE ticket tk
SET participant_id = p.id
FROM participant p
WHERE p.user_id = tk.submitter_id;
""")
op.drop_column("ticket", "submitter_id")
op.alter_column("ticket", "participant_id",
new_column_name="submitter_id", nullable=False)
op.add_column("event", sa.Column(
"participant_id", sa.Integer, sa.ForeignKey("participant.id")))
op.add_column("event", sa.Column(
"by_participant_id", sa.Integer, sa.ForeignKey("participant.id")))
op.execute("""
UPDATE event ev
SET participant_id = p.id
FROM participant p
WHERE p.user_id = ev.user_id;
UPDATE event ev
SET by_participant_id = p.id
FROM participant p
WHERE p.user_id = ev.by_user_id;
""")
op.drop_column("event", "user_id")
op.drop_column("event", "by_user_id")
op.add_column("ticket_comment", sa.Column(
"participant_id", sa.Integer, sa.ForeignKey("participant.id")))
op.execute("""
UPDATE ticket_comment tc
SET participant_id = p.id
FROM participant p
WHERE p.user_id = tc.submitter_id;
""")
op.drop_column("ticket_comment", "submitter_id")
op.alter_column("ticket_comment", "participant_id",
new_column_name="submitter_id", nullable=False)
op.add_column("ticket_subscription", sa.Column(
"participant_id", sa.Integer, sa.ForeignKey("participant.id")))
op.execute("""
UPDATE ticket_subscription ts
SET participant_id = p.id
FROM participant p
WHERE p.user_id = ts.user_id;
""")
op.drop_column("ticket_subscription", "user_id")
op.drop_column("ticket_subscription", "email")
op.drop_column("ticket_subscription", "webhook")
def downgrade():
op.add_column("ticket", sa.Column(
"submitter_user_id", sa.Integer, sa.ForeignKey("user.id")))
op.execute("""
UPDATE ticket tk
SET submitter_user_id = p.user_id
FROM participant p
WHERE p.id = tk.submitter_id;
""")
op.drop_column("ticket", "submitter_id")
op.alter_column("ticket", "submitter_user_id",
new_column_name="submitter_id", nullable=False)
op.add_column("event", sa.Column(
"user_id", sa.Integer, sa.ForeignKey("user.id")))
op.add_column("event", sa.Column(
"by_user_id", sa.Integer, sa.ForeignKey("user.id")))
op.execute("""
UPDATE event ev
SET user_id = p.user_id
FROM participant p
WHERE p.id = ev.participant_id;
UPDATE event ev
SET by_user_id = p.user_id
FROM participant p
WHERE p.user_id = ev.by_participant_id;
""")
op.drop_column("event", "participant_id")
op.drop_column("event", "by_participant_id")
op.add_column("ticket_comment", sa.Column(
"user_id", sa.Integer, sa.ForeignKey("user.id")))
op.execute("""
UPDATE ticket_comment tc
SET user_id = p.user_id
FROM participant p
WHERE p.id = tc.submitter_id;
""")
op.drop_column("ticket_comment", "submitter_id")
op.alter_column("ticket_comment", "user_id",
new_column_name="submitter_id", nullable=False)
op.add_column("ticket_subscription", sa.Column(
"user_id", sa.Integer, sa.ForeignKey("user.id")))
op.execute("""
UPDATE ticket_subscription ts
SET user_id = p.user_id
FROM participant p
WHERE p.id = ts.participant_id;
""")
op.drop_column("ticket_subscription", "participant_id")
op.add_column("ticket_subscription",
sa.Column("email", sa.Column(sa.Unicode(512)))
op.add_column("ticket_subscription",
sa.Column("webhook", sa.Column(sa.Unicode(1024)))
op.drop_table("participant")

View File

@ -4,7 +4,7 @@ from srht.database import db
from srht.oauth import oauth, current_token
from srht.validation import Validation
from todosrht.access import get_tracker, get_ticket
from todosrht.tickets import submit_ticket, add_comment
from todosrht.tickets import add_comment, get_participant_for_user, submit_ticket
from todosrht.blueprints.api import get_user
from todosrht.types import Ticket, TicketAccess, TicketStatus, TicketResolution
from todosrht.types import Event, EventType, Label, TicketLabel
@ -53,7 +53,8 @@ def tracker_tickets_POST(username, tracker_name):
if not valid.ok:
return valid.response
ticket = submit_ticket(tracker, current_token.user, title, desc)
participant = get_participant_for_user(current_token.user)
ticket = submit_ticket(tracker, participant.id, title, desc)
TrackerWebhook.deliver(TrackerWebhook.Events.ticket_create,
ticket.to_dict(),
TrackerWebhook.Subscription.tracker_id == tracker.id)
@ -110,6 +111,7 @@ def tracker_ticket_by_id_PUT(username, tracker_name, ticket_id):
abort(404)
ticket, access = get_ticket(tracker, ticket_id, user=current_token.user)
participant = get_participant_for_user(current_token.user)
required_access = TicketAccess.none
valid = Validation(request)
comment = resolution = None
@ -152,7 +154,7 @@ def tracker_ticket_by_id_PUT(username, tracker_name, ticket_id):
.filter(TicketLabel.label_id == label.id)).delete()
event = Event()
event.event_type = EventType.label_removed
event.user_id = current_token.user_id
event.participant_id = participant.id
event.ticket_id = ticket.id
event.label_id = label.id
db.session.add(event)
@ -176,7 +178,7 @@ def tracker_ticket_by_id_PUT(username, tracker_name, ticket_id):
db.session.add(tl)
event = Event()
event.event_type = EventType.label_added
event.user_id = current_token.user_id
event.participant_id = participant.id
event.ticket_id = ticket.id
event.label_id = label.id
db.session.add(event)
@ -199,7 +201,7 @@ def tracker_ticket_by_id_PUT(username, tracker_name, ticket_id):
if comment or resolve or resolution or reopen:
event = add_comment(
user, ticket, comment, resolve, resolution, reopen)
participant, ticket, comment, resolve, resolution, reopen)
db.session.add(event)
db.session.flush()
events.append(events)

View File

@ -1,8 +1,9 @@
from flask import Blueprint, render_template, request, abort
from flask_login import current_user
from todosrht.access import get_tracker, get_access
from todosrht.tickets import get_participant_for_user
from todosrht.types import Tracker, Ticket, Event, EventNotification, EventType
from todosrht.types import User
from todosrht.types import User, Participant
from srht.config import cfg
from srht.flask import paginate_query, session
from sqlalchemy import and_, or_
@ -14,11 +15,12 @@ def filter_authorized_events(events):
.join(Ticket, Ticket.id == Event.ticket_id)
.join(Tracker, Tracker.id == Ticket.tracker_id))
if current_user:
participant = get_participant_for_user(current_user)
events = (events.filter(
or_(
and_(
Ticket.submitter_perms != None,
Ticket.submitter_id == current_user.id,
Ticket.submitter_id == participant.id,
Ticket.submitter_perms > 0),
and_(
Ticket.user_perms != None,
@ -28,7 +30,7 @@ def filter_authorized_events(events):
Ticket.anonymous_perms > 0),
and_(
Ticket.submitter_perms == None,
Ticket.submitter_id == current_user.id,
Ticket.submitter_id == participant.id,
Tracker.default_submitter_perms > 0),
and_(
Ticket.user_perms == None,
@ -95,7 +97,8 @@ def user_GET(username):
# TODO: Join on stuff the user has explicitly been granted access to
events = (Event.query
.filter(Event.user_id == user.id)
.join(Participant, Event.participant_id == Participant.id)
.filter(Participant.user_id == user.id)
.order_by(Event.created.desc()))
if not current_user or current_user.id != user.id:
events = filter_authorized_events(events)

View File

@ -8,10 +8,11 @@ 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
from todosrht.types import TicketSubscription, User, Participant
from todosrht.urls import ticket_url
from todosrht.webhooks import TrackerWebhook, TicketWebhook
@ -24,10 +25,18 @@ def get_ticket_context(ticket, tracker, access):
ticket_sub = None
if current_user:
tracker_sub = TicketSubscription.query.filter_by(
ticket=None, tracker=tracker, user=current_user).one_or_none()
ticket_sub = TicketSubscription.query.filter_by(
ticket=ticket, tracker=None, user=current_user).one_or_none()
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()
return {
"tracker": tracker,
@ -76,9 +85,10 @@ def enable_notifications(owner, name, ticket_id):
if sub:
return redirect(ticket_url(ticket))
participant = get_participant_for_user(current_user)
sub = TicketSubscription()
sub.ticket_id = ticket.id
sub.user_id = current_user.id
sub.participant_id = participant.id
db.session.add(sub)
db.session.commit()
return redirect(ticket_url(ticket))
@ -142,7 +152,8 @@ def ticket_comment_POST(owner, name, ticket_id):
ctx = get_ticket_context(ticket, tracker, access)
return render_template("ticket.html", **ctx, **valid.kwargs)
event = add_comment(current_user, ticket,
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,
@ -241,9 +252,10 @@ def ticket_add_label(owner, name, 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.user_id = current_user.id
event.participant_id = participant.id
event.ticket_id = ticket.id
event.label_id = label.id
@ -281,9 +293,10 @@ def ticket_remove_label(owner, name, ticket_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.user_id = current_user.id
event.participant_id = participant.id
event.ticket_id = ticket.id
event.label_id = label.id

View File

@ -4,11 +4,11 @@ from todosrht import color
from todosrht.access import get_tracker
from todosrht.search import apply_search
from todosrht.tickets import get_last_seen_times, get_comment_counts
from todosrht.tickets import submit_ticket
from todosrht.tickets import get_participant_for_user, submit_ticket
from todosrht.trackers import get_recent_users
from todosrht.types import Event, UserAccess
from todosrht.types import Label, TicketLabel
from todosrht.types import TicketSubscription, User
from todosrht.types import TicketSubscription, User, Participant
from todosrht.types import Tracker, Ticket, TicketAccess
from todosrht.urls import tracker_url, ticket_url
from todosrht.webhooks import TrackerWebhook, UserWebhook
@ -43,9 +43,10 @@ def create_POST():
tracker.to_dict(),
UserWebhook.Subscription.user_id == tracker.owner_id)
participant = get_participant_for_user(current_user)
sub = TicketSubscription()
sub.tracker_id = tracker.id
sub.user_id = current_user.id
sub.participant_id = participant.id
db.session.add(sub)
db.session.commit()
@ -63,9 +64,10 @@ def return_tracker(tracker, access, **kwargs):
is_subscribed = False
if current_user:
sub = (TicketSubscription.query
.join(Participant)
.filter(TicketSubscription.tracker_id == tracker.id)
.filter(TicketSubscription.ticket_id == None)
.filter(TicketSubscription.user_id == current_user.id)
.filter(Participant.user_id == current_user.id)
).one_or_none()
is_subscribed = bool(sub)
@ -122,9 +124,10 @@ def enable_notifications(owner, name):
if sub:
return redirect(tracker_url(tracker))
participant = get_participant_for_user(current_user)
sub = TicketSubscription()
sub.tracker_id = tracker.id
sub.user_id = current_user.id
sub.participant_id = participant.id
db.session.add(sub)
db.session.commit()
return redirect(tracker_url(tracker))
@ -370,7 +373,8 @@ def tracker_submit_POST(owner, name):
return return_tracker(tracker, access, **valid.kwargs), 400
# TODO: Handle unique constraint failure (contention) and retry?
ticket = submit_ticket(tracker, current_user, title, desc)
participant = get_participant_for_user(current_user)
ticket = submit_ticket(tracker, participant, title, desc)
UserWebhook.deliver(UserWebhook.Events.ticket_create,
ticket.to_dict(),

View File

@ -5,6 +5,7 @@ import textwrap
from flask_login import current_user
from srht.config import cfg, cfgi
from srht.email import send_email, lookup_key
from todosrht.types import ParticipantType
origin = cfg("todo.sr.ht", "origin")
@ -15,13 +16,15 @@ def format_lines(text, quote=False):
def notify(sub, template, subject, headers, **kwargs):
encrypt_key = None
if sub.email:
to = sub.email
elif sub.user:
to = sub.user.email
encrypt_key = lookup_key(sub.user.username, sub.user.oauth_token)
to = sub.participant
if to.participant_type == ParticipantType.email:
to = to.email
elif to.participant_type == ParticipantType.user:
user = to.user
to = user.email
encrypt_key = lookup_key(user.username, user.oauth_token)
else:
return # TODO
return
with open(os.path.join(os.path.dirname(__file__), "emails", template)) as f:
body = html.unescape(
pystache.render(f.read(), {

View File

@ -27,10 +27,11 @@ def render_markup(tracker, text):
users = find_mentioned_users(text)
tickets = find_mentioned_tickets(tracker, text)
users_map = {str(u): u for u in users}
users_map = {u.identifier: u for u in users}
tickets_map = {t.ref(): t for t in tickets}
def urlize_user(match):
# TODO: Handle mentions for non-user participants
username = match.group(0)
if username in users_map:
url = urls.user_url(users_map[username])

View File

@ -29,6 +29,7 @@ class TodoApp(SrhtFlask):
self.add_template_filter(filters.render_ticket_description)
self.add_template_filter(urls.label_add_url)
self.add_template_filter(urls.label_search_url)
self.add_template_filter(urls.participant_url)
self.add_template_filter(urls.ticket_assign_url)
self.add_template_filter(urls.ticket_edit_url)
self.add_template_filter(urls.ticket_unassign_url)

View File

@ -10,35 +10,35 @@
</h4>
<p>
{% if EventType.created in event.event_type %}
Ticket created by <a href="{{ event.ticket.submitter|user_url }}">
Ticket created by <a href="{{ event.ticket.submitter|participant_url }}">
{{ event.ticket.submitter }}
</a>
{% elif EventType.comment in event.event_type %}
Comment by <a href="{{ event.comment.submitter|user_url }}">
Comment by <a href="{{ event.comment.submitter|participant_url }}">
{{ event.comment.submitter }}
</a>
{% elif EventType.label_added in event.event_type %}
{{ event.label|label_badge(cls="small") }} added by
<a href="{{ event.user|user_url }}">{{ event.user }}</a>
<a href="{{ event.participant|participant_url }}">{{ event.participant }}</a>
{% elif EventType.label_removed in event.event_type %}
{{ event.label|label_badge(cls="small") }} removed by
<a href="{{ event.user|user_url }}">{{ event.user }}</a>
<a href="{{ event.participant|participant_url }}">{{ event.participant }}</a>
{% elif EventType.assigned_user in event.event_type %}
<a href="{{event.by_user|user_url}}">
{{event.by_user}}
<a href="{{event.by_participant|participant_url}}">
{{event.by_participant}}
</a>
assigned
<a href="{{event.user|user_url}}">
{{event.user}}
<a href="{{event.participant|participant_url}}">
{{event.participant}}
</a>
to #{{event.ticket.scoped_id}}
{% elif EventType.unassigned_user in event.event_type %}
<a href="{{event.by_user|user_url}}">
{{event.by_user}}
<a href="{{event.by_user|participant_url}}">
{{event.by_participant}}
</a>
unassigned
<a href="{{event.user|user_url}}">
{{event.user}}
<a href="{{event.participant|participant_url}}">
{{event.participant}}
</a>
from #{{event.ticket.scoped_id}}
{% endif %}

View File

@ -101,7 +101,7 @@
</dd>
<dt class="col-md-3">Submitter</dt>
<dd class="col-md-9">
<a id="submitter-field" href="{{ ticket.submitter|user_url }}">
<a id="submitter-field" href="{{ ticket.submitter|participant_url }}">
{{ ticket.submitter }}
</a>
</dd>
@ -241,8 +241,8 @@
EventType.unassigned_user,
EventType.ticket_mentioned,
] %}
<a href="{{ event.user|user_url }}">
{{ event.user }}
<a href="{{ event.participant|participant_url }}">
{{ event.participant }}
</a>
{% endif %}
{% if EventType.status_change in event.event_type %}
@ -269,26 +269,26 @@
removed {{ event.label|label_badge(cls="small") }}
{% endif %}
{% if EventType.assigned_user in event.event_type %}
<a href="{{event.by_user|user_url}}">
{{event.by_user}}
<a href="{{event.by_participant|participant_url}}">
{{event.by_participant}}
</a>
assigned
<a href="{{event.user|user_url}}">
{{event.user}}
<a href="{{event.participant|participant_url}}">
{{event.participant}}
</a>
{% endif %}
{% if EventType.unassigned_user in event.event_type %}
<a href="{{event.by_user|user_url}}">
{{event.by_user}}
<a href="{{event.by_participant|participant_url}}">
{{event.by_participant}}
</a>
unassigned
<a href="{{event.user|user_url}}">
{{event.user}}
<a href="{{event.participant|participant_url}}">
{{event.participant}}
</a>
{% endif %}
{% if EventType.ticket_mentioned in event.event_type %}
<a href="{{ event.by_user|user_url }}">
{{ event.by_user }}
<a href="{{ event.by_user|participant_url }}">
{{ event.by_participant }}
</a>
{% set relation = event.from_ticket %}
{% if relation.status == TicketStatus.resolved and

View File

@ -157,7 +157,7 @@
</div>
<div class="updated">{{ ticket.updated | date }}</div>
<div class="submitter">
<a href="{{ ticket.submitter|user_url }}">
<a href="{{ ticket.submitter|participant_url }}">
{{ ticket.submitter }}
</a>
</div>

View File

@ -8,6 +8,7 @@ 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.types import Participant, ParticipantType
from todosrht.urls import ticket_url
from sqlalchemy import func, or_, and_
@ -52,11 +53,23 @@ TICKET_URL_PATTERN = re.compile(f"""
\\b # Word boundary
""", re.VERBOSE)
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 find_mentioned_users(text):
# TODO: Find mentioned email addresses as well
usernames = re.findall(USER_MENTION_PATTERN, text)
users = User.query.filter(User.username.in_(usernames)).all()
return set(users)
participants = set([get_participant_for_user(u) for u in set(users)])
return participants
def find_mentioned_tickets(tracker, text):
filters = or_()
@ -85,21 +98,20 @@ def find_mentioned_tickets(tracker, text):
.filter(filters)
.all())
def _create_comment(ticket, user, text):
def _create_comment(ticket, participant, text):
comment = TicketComment()
comment.text = text
# TODO: anonymous comments (when configured appropriately)
comment.submitter_id = user.id
comment.submitter_id = participant.id
comment.ticket_id = ticket.id
db.session.add(comment)
db.session.flush()
return comment
def _create_comment_event(ticket, user, comment, status_change):
def _create_comment_event(ticket, participant, comment, status_change):
event = Event()
event.event_type = 0
event.user_id = user.id
event.participant_id = participant.id
event.ticket_id = ticket.id
if comment:
@ -117,17 +129,20 @@ def _create_comment_event(ticket, user, comment, status_change):
db.session.flush()
return event
def _create_event_notification(user, event):
def _create_event_notification(participant, event):
if participant.participant_type != ParticipantType.user:
return None # We only record notifications for registered users
notification = EventNotification()
notification.user_id = user.id
notification.user_id = participant.user.id
notification.event_id = event.id
db.session.add(notification)
return notification
def _send_comment_notification(subscription, ticket, user, comment, resolution):
def _send_comment_notification(subscription, ticket,
participant, comment, resolution):
subject = "Re: {}: {}".format(ticket.ref(), ticket.title)
headers = {
"From": "~{} <{}>".format(user.username, notify_from),
"From": "{} <{}>".format(participant.name, notify_from),
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
"Sender": smtp_user,
}
@ -156,45 +171,46 @@ def _change_ticket_status(ticket, resolve, resolution, reopen):
if reopen:
ticket.status = TicketStatus.reported
return StatusChange(
old_status, ticket.status, old_resolution, ticket.resolution)
return StatusChange(old_status, ticket.status,
old_resolution, ticket.resolution)
def _send_comment_notifications(user, ticket, event, comment, resolution):
def _send_comment_notifications(
participant, 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
subscriptions = {sub.participant: sub
for sub in ticket.tracker.subscriptions + ticket.subscriptions}
# Subscribe commenter if not already subscribed
if user not in subscriptions:
if participant not in subscriptions:
subscription = TicketSubscription()
subscription.ticket_id = ticket.id
subscription.user_id = user.id
subscription.participant_id = participant.id
db.session.add(subscription)
subscriptions[user] = subscription
subscriptions[participant] = subscription
for subscriber, subscription in subscriptions.items():
_create_event_notification(subscriber, event)
if subscriber != user:
if subscriber != participant:
_send_comment_notification(
subscription, ticket, user, comment, resolution)
subscription, ticket, participant, 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),
"From": "{} <{}>".format(submitter.name, 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,
"submitter": submitter.name,
"ticket_ref": ticket.ref(),
"ticket_url": ticket_url(ticket, comment),
}
@ -206,15 +222,15 @@ 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_participants = find_mentioned_users(text)
mentioned_tickets = find_mentioned_tickets(ticket.tracker, text)
for user in mentioned_users:
for participant in mentioned_participants:
db.session.add(Event(
event_type=EventType.user_mentioned,
user=user,
participant=participant,
from_ticket=ticket,
by_user=submitter,
by_participant=submitter,
comment=comment,
))
@ -223,20 +239,20 @@ def _handle_mentions(ticket, submitter, text, notified_users, comment=None):
event_type=EventType.ticket_mentioned,
ticket=mentioned_ticket,
from_ticket=ticket,
by_user=submitter,
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
# 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)
to_notify = mentioned_participants - set(notified_users) - set([submitter])
for target in to_notify:
sub = get_or_create_subscription(ticket, target)
_send_mention_notification(sub, submitter, text, ticket, comment)
def add_comment(user, ticket,
def add_comment(submitter, ticket,
text=None, resolve=False, resolution=None, reopen=False):
"""
Comment on a ticket, optionally resolve or reopen the ticket.
@ -247,18 +263,18 @@ def add_comment(user, ticket,
if resolve:
assert resolution is not None
comment = _create_comment(ticket, user, text) if text else None
comment = _create_comment(ticket, submitter, 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)
event = _create_comment_event(ticket, submitter, comment, status_change)
notified_participants = _send_comment_notifications(
submitter, ticket, event, comment, resolution)
if comment and comment.text:
_handle_mentions(
ticket,
comment.submitter,
comment.text,
notified_users,
notified_participants,
comment,
)
@ -279,24 +295,27 @@ def mark_seen(ticket, user):
return seen
def get_or_create_subscription(ticket, user):
def get_or_create_subscription(ticket, participant):
"""
If user is subscribed to ticket or tracker, returns that subscription,
otherwise subscribes the user to the ticket and returns that one.
If participant 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.participant == participant) & (
(TicketSubscription.ticket == ticket) |
(TicketSubscription.tracker == ticket.tracker)
)
).first()
if not subscription:
subscription = TicketSubscription(ticket=ticket, user=user)
subscription = TicketSubscription(
ticket=ticket, participant=participant)
db.session.add(subscription)
return subscription
# 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.
@ -334,15 +353,18 @@ def assign(ticket, assignee, assigner):
)
db.session.add(ticket_assignee)
subscription = get_or_create_subscription(ticket, assignee)
assignee_participant = get_participant_for_user(assignee)
assigner_participant = get_participant_for_user(assigner)
subscription = get_or_create_subscription(ticket, assignee_participant)
if assigner != assignee:
notify_assignee(subscription, ticket, assigner, assignee)
event = Event()
event.event_type = EventType.assigned_user
event.user_id = assignee.id
event.participant_id = assignee_participant.id
event.ticket_id = ticket.id
event.by_user_id = assigner.id
event.by_user_id = assigner_participant.id
db.session.add(event)
return ticket_assignee
@ -357,11 +379,14 @@ def unassign(ticket, assignee, assigner):
db.session.delete(ticket_assignee)
assignee_participant = get_participant_for_user(assignee)
assigner_participant = get_participant_for_user(assigner)
event = Event()
event.event_type = EventType.unassigned_user
event.user_id = assignee.id
event.participant_id = assignee_participant.id
event.ticket_id = ticket.id
event.by_user_id = assigner.id
event.by_user_id = assigner_participant.id
db.session.add(event)
def get_last_seen_times(user, tickets):
@ -381,7 +406,7 @@ def get_comment_counts(tickets):
def _send_new_ticket_notification(subscription, ticket):
subject = f"{ticket.ref()}: {ticket.title}"
headers = {
"From": "~{} <{}>".format(ticket.submitter.username, notify_from),
"From": "{} <{}>".format(ticket.submitter.name, notify_from),
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
"Sender": smtp_user,
}
@ -403,7 +428,8 @@ def submit_ticket(tracker, submitter, title, description):
tracker.next_ticket_id += 1
tracker.updated = datetime.utcnow()
event = Event(event_type=EventType.created, user=submitter, ticket=ticket)
event = Event(event_type=EventType.created,
participant=submitter, ticket=ticket)
db.session.add(event)
db.session.flush()
@ -412,11 +438,11 @@ def submit_ticket(tracker, submitter, title, description):
# Send notifications
for sub in tracker.subscriptions:
_create_event_notification(sub.user, event)
if sub.user != submitter:
_create_event_notification(sub.participant, event)
if sub.participant != submitter:
_send_new_ticket_notification(sub, ticket)
notified_users = [sub.user for sub in tracker.subscriptions]
notified_users = [sub.participant for sub in tracker.subscriptions]
_handle_mentions(
ticket,
ticket.submitter,

View File

@ -1,12 +1,14 @@
from srht.database import db
from todosrht.types import Event, Ticket, User
from todosrht.types import Event, Ticket, User, Participant
def get_recent_users(tracker, limit=20):
"""Find users who recently interacted with a tracker."""
recent_user_events = (db.session.query(Event.id, User.username)
.join(User, User.id == Event.user_id)
recent_user_events = (db.session
.query(Event.id, Participant.id, User.username)
.join(Participant, Participant.id == Event.participant_id)
.join(User, User.id == Participant.user_id)
.join(Ticket, Ticket.id == Event.ticket_id)
.filter(Ticket.tracker_id == tracker.id)
.order_by(Event.created.desc())

View File

@ -10,12 +10,14 @@ class OAuthToken(Base, ExternalOAuthTokenMixin):
from todosrht.types.ticketaccess import TicketAccess
from todosrht.types.ticketstatus import TicketStatus, TicketResolution
from todosrht.types.tracker import Tracker
from todosrht.types.ticketseen import TicketSeen
from todosrht.types.ticket import Ticket
from todosrht.types.ticketsubscription import TicketSubscription
from todosrht.types.ticketcomment import TicketComment
from todosrht.types.ticketassignee import TicketAssignee
from todosrht.types.event import Event, EventType, EventNotification
from todosrht.types.label import Label, TicketLabel
from todosrht.types.participant import Participant, ParticipantType
from todosrht.types.ticket import Ticket
from todosrht.types.ticketassignee import TicketAssignee
from todosrht.types.ticketcomment import TicketComment
from todosrht.types.ticketseen import TicketSeen
from todosrht.types.ticketsubscription import TicketSubscription
from todosrht.types.tracker import Tracker
from todosrht.types.useraccess import UserAccess

View File

@ -31,9 +31,9 @@ class Event(Base):
new_status = sa.Column(FlagType(TicketStatus), default=0)
new_resolution = sa.Column(FlagType(TicketResolution), default=0)
user_id = sa.Column(sa.Integer, sa.ForeignKey("user.id"))
user = sa.orm.relationship("User",
backref=sa.orm.backref("events"), foreign_keys=[user_id])
participant_id = sa.Column(sa.Integer, sa.ForeignKey("participant.id"))
participant = sa.orm.relationship("Participant",
backref=sa.orm.backref("events"), foreign_keys=[participant_id])
ticket_id = sa.Column(sa.Integer,
sa.ForeignKey("ticket.id", ondelete="CASCADE"))
@ -50,8 +50,9 @@ class Event(Base):
label = sa.orm.relationship("Label",
backref=sa.orm.backref("events", cascade="all, delete-orphan"))
by_user_id = sa.Column(sa.Integer, sa.ForeignKey("user.id"))
by_user = sa.orm.relationship("User", foreign_keys=[by_user_id])
by_participant_id = sa.Column(sa.Integer, sa.ForeignKey("participant.id"))
by_participant = sa.orm.relationship(
"Participant", foreign_keys=[by_participant_id])
from_ticket_id = sa.Column(sa.Integer,
sa.ForeignKey("ticket.id", ondelete="CASCADE"))
@ -71,15 +72,15 @@ class Event(Base):
"new_status": self.new_status.name if self.new_status else None,
"new_resolution": self.new_resolution.name
if self.new_resolution else None,
"user": self.user.to_dict(short=True)
if self.user else None,
"user": self.participant.to_dict(short=True)
if self.participant else None,
"ticket": self.ticket.to_dict(short=True)
if self.ticket else None,
"comment": self.comment.to_dict(short=True)
if self.comment else None,
"label": self.label.name if self.label else None,
"by_user": self.by_user.to_dict(short=True)
if self.by_user else None,
"by_user": self.by_participant.to_dict(short=True)
if self.by_participant else None,
"from_ticket": self.from_ticket.to_dict(short=True)
if self.from_ticket else None,
}

View File

@ -0,0 +1,79 @@
import sqlalchemy as sa
import sqlalchemy_utils as sau
from srht.database import Base
from enum import Enum
class ParticipantType(Enum):
user = "user"
email = "email"
external = "external"
class Participant(Base):
__tablename__ = "participant"
id = sa.Column(sa.Integer, primary_key=True)
created = sa.Column(sa.DateTime, nullable=False)
participant_type = sa.Column(sau.ChoiceType(
ParticipantType, impl=sa.String()), nullable=False)
# ParticipantType.user
user_id = sa.Column(sa.Integer,
sa.ForeignKey("user.id", ondelete="CASCADE"),
unique=True)
user = sa.orm.relationship('User')
# ParticipantType.email
email = sa.Column(sa.String, unique=True)
email_name = sa.Column(sa.String)
# ParticipantType.external
external_id = sa.Column(sa.String, unique=True)
external_url = sa.Column(sa.String)
@property
def name(self):
"""Returns a human-friendly display name for this participant"""
if self.participant_type == ParticipantType.user:
return self.user.canonical_name
elif self.participant_type == ParticipantType.email:
return self.email_name or self.email
elif self.participant_type == ParticipantType.external:
return self.external_id
assert False
@property
def identifier(self):
"""Returns a human-friendly unique identifier for this participant"""
if self.participant_type == ParticipantType.user:
return self.user.canonical_name
elif self.participant_type == ParticipantType.email:
return self.email
elif self.participant_type == ParticipantType.external:
return self.external_id
assert False
def __str__(self):
return self.name
def __repr__(self):
return f"<Participant {self.id} [{self.participant_type.value}]>"
def to_dict(self, short=False):
if self.participant_type == ParticipantType.user:
return {
"type": "user",
**self.user.to_dict(short),
}
elif self.participant_type == ParticipantType.email:
return {
"type": "email",
"address": self.email,
"name": self.email_name,
}
elif self.participant_type == ParticipantType.external:
return {
"type": "external",
"external_id": self.external_id,
"external_url": self.external_url,
}
assert False

View File

@ -28,8 +28,8 @@ class Ticket(Base):
remote_side=[id])
submitter_id = sa.Column(sa.Integer,
sa.ForeignKey("user.id"), nullable=False)
submitter = sa.orm.relationship("User",
sa.ForeignKey("participant.id"), nullable=False)
submitter = sa.orm.relationship("Participant",
backref=sa.orm.backref("submitted", cascade="all, delete-orphan"))
title = sa.Column(sa.Unicode(2048), nullable=False)

View File

@ -10,8 +10,8 @@ class TicketComment(Base):
updated = sa.Column(sa.DateTime, nullable=False)
submitter_id = sa.Column(sa.Integer,
sa.ForeignKey("user.id"), nullable=False)
submitter = sa.orm.relationship("User")
sa.ForeignKey("participant.id"), nullable=False)
submitter = sa.orm.relationship("Participant")
ticket_id = sa.Column(sa.Integer,
sa.ForeignKey("ticket.id", ondelete="CASCADE"),

View File

@ -1,8 +1,6 @@
import sqlalchemy as sa
import sqlalchemy_utils as sau
from srht.database import Base
from srht.flagtype import FlagType
from enum import Enum
class TicketSubscription(Base):
"""One of user, email, or webhook will be valid. The rest will be null."""
@ -25,11 +23,11 @@ class TicketSubscription(Base):
cascade="all, delete-orphan"))
"""Used for subscriptions to specific tickets"""
user_id = sa.Column(sa.Integer,
sa.ForeignKey("user.id"))
user = sa.orm.relationship("User",
participant_id = sa.Column(sa.Integer,
sa.ForeignKey("participant.id"))
participant = sa.orm.relationship("Participant",
backref=sa.orm.backref("subscriptions"))
email = sa.Column(sa.Unicode(512))
webhook = sa.Column(sa.Unicode(1024))
def __repr__(self):
return (f"<TicketSubscription {self.id} {self.participant}; " +
f"tk: {self.ticket_id}; tr: {self.tracker_id}>")

View File

@ -1,5 +1,6 @@
from flask import has_app_context, url_for
from jinja2.utils import unicode_urlencode
from todosrht.types import ParticipantType
def tracker_url(tracker):
return url_for("tracker.tracker_GET",
@ -68,5 +69,16 @@ def label_remove_url(label, ticket):
ticket_id=ticket.scoped_id,
label_id=label.id)
def participant_url(participant):
if participant.participant_type == ParticipantType.user:
return url_for("html.user_GET", username=participant.user.username)
elif participant.participant_type == ParticipantType.email:
if participant.email_name:
return f"mailto:{participant.email_name} <{participant.email}>"
else:
return f"mailto:{participant.email}"
elif participant.participant_type == ParticipantType.external:
return participant.external_url
def user_url(user):
return url_for("html.user_GET", username=user.username)