Add simple ticket API

This commit is contained in:
Drew DeVault 2019-04-30 16:17:43 -04:00
parent 785574871c
commit d6986aed72
11 changed files with 207 additions and 49 deletions

View File

@ -2,6 +2,7 @@ import factory
from datetime import datetime, timedelta from datetime import datetime, timedelta
from factory.fuzzy import FuzzyText from factory.fuzzy import FuzzyText
from srht.database import db from srht.database import db
from srht.validation import Validation
from todosrht.types import Tracker, User, Ticket from todosrht.types import Tracker, User, Ticket
future_datetime = datetime.now() + timedelta(days=10) future_datetime = datetime.now() + timedelta(days=10)
@ -21,8 +22,8 @@ class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
class TrackerFactory(factory.alchemy.SQLAlchemyModelFactory): class TrackerFactory(factory.alchemy.SQLAlchemyModelFactory):
owner = factory.SubFactory(UserFactory) user = factory.SubFactory(UserFactory)
name = factory.Sequence(lambda n: f"tracker{n}") valid = factory.Sequence(lambda n: Validation({ "name": f"tracker{n}" }))
class Meta: class Meta:
model = Tracker model = Tracker

View File

@ -58,45 +58,41 @@ def test_ticket_comment(mailbox):
assert ticket.resolution == TicketResolution.unresolved assert ticket.resolution == TicketResolution.unresolved
# Comment without status change # Comment without status change
comment = add_comment(user, ticket, text="how do you do, i") event = add_comment(user, ticket, text="how do you do, i")
# Submitter gets automatically subscribed # Submitter gets automatically subscribed
assert TicketSubscription.query.filter_by(ticket=ticket, user=user).first() assert TicketSubscription.query.filter_by(ticket=ticket, user=user).first()
assert comment.submitter == user assert event.comment.submitter == user
assert comment.ticket == ticket assert event.comment.ticket == ticket
assert comment.text == "how do you do, i" assert event.comment.text == "how do you do, i"
assert ticket.status == TicketStatus.reported assert ticket.status == TicketStatus.reported
assert ticket.resolution == TicketResolution.unresolved assert ticket.resolution == TicketResolution.unresolved
assert len(ticket.comments) == 1 assert len(ticket.comments) == 1
assert len(ticket.events) == 1 assert len(ticket.events) == 1
event = ticket.events[0]
assert event.ticket == ticket assert event.ticket == ticket
assert event.comment == comment
assert event.event_type == EventType.comment assert event.event_type == EventType.comment
assert len(mailbox) == 3 assert len(mailbox) == 3
assert_notifications_sent(comment.text) assert_notifications_sent(event.comment.text)
assert_event_notifications_created(event) assert_event_notifications_created(event)
# Comment and resolve issue # Comment and resolve issue
comment = add_comment(user, ticket, text="see you've met my", event = add_comment(user, ticket, text="see you've met my",
resolve=True, resolution=TicketResolution.fixed) resolve=True, resolution=TicketResolution.fixed)
assert comment.submitter == user assert event.comment.submitter == user
assert comment.ticket == ticket assert event.comment.ticket == ticket
assert comment.text == "see you've met my" assert event.comment.text == "see you've met my"
assert ticket.status == TicketStatus.resolved assert ticket.status == TicketStatus.resolved
assert ticket.resolution == TicketResolution.fixed assert ticket.resolution == TicketResolution.fixed
assert len(ticket.comments) == 2 assert len(ticket.comments) == 2
assert len(ticket.events) == 2 assert len(ticket.events) == 2
event = ticket.events[1]
assert event.ticket == ticket assert event.ticket == ticket
assert event.comment == comment
assert event.event_type == EventType.status_change | EventType.comment assert event.event_type == EventType.status_change | EventType.comment
assert event.old_status == TicketStatus.reported assert event.old_status == TicketStatus.reported
assert event.new_status == TicketStatus.resolved assert event.new_status == TicketStatus.resolved
@ -108,31 +104,27 @@ def test_ticket_comment(mailbox):
assert_event_notifications_created(event) assert_event_notifications_created(event)
# Comment and reopen issue # Comment and reopen issue
comment = add_comment(user, ticket, text="faithful handyman", reopen=True) event = add_comment(user, ticket, text="faithful handyman", reopen=True)
assert comment.submitter == user assert event.comment.submitter == user
assert comment.ticket == ticket assert event.comment.ticket == ticket
assert comment.text == "faithful handyman" assert event.comment.text == "faithful handyman"
assert ticket.status == TicketStatus.reported assert ticket.status == TicketStatus.reported
assert ticket.resolution == TicketResolution.fixed assert ticket.resolution == TicketResolution.fixed
assert len(ticket.comments) == 3 assert len(ticket.comments) == 3
assert len(ticket.events) == 3 assert len(ticket.events) == 3
event = ticket.events[2]
assert event.ticket == ticket assert event.ticket == ticket
assert event.comment == comment
assert len(mailbox) == 9 assert len(mailbox) == 9
assert_notifications_sent(comment.text) assert_notifications_sent(event.comment.text)
assert_event_notifications_created(event) assert_event_notifications_created(event)
# Resolve without commenting # Resolve without commenting
comment = add_comment(user, ticket, event = add_comment(user, ticket,
resolve=True, resolution=TicketResolution.wont_fix) resolve=True, resolution=TicketResolution.wont_fix)
assert comment is None
assert ticket.status == TicketStatus.resolved assert ticket.status == TicketStatus.resolved
assert ticket.resolution == TicketResolution.wont_fix assert ticket.resolution == TicketResolution.wont_fix
assert len(ticket.comments) == 3 assert len(ticket.comments) == 3
@ -140,25 +132,20 @@ def test_ticket_comment(mailbox):
event = ticket.events[3] event = ticket.events[3]
assert event.ticket == ticket assert event.ticket == ticket
assert event.comment == comment
assert len(mailbox) == 12 assert len(mailbox) == 12
assert_notifications_sent("Ticket resolved: wont_fix") assert_notifications_sent("Ticket resolved: wont_fix")
assert_event_notifications_created(event) assert_event_notifications_created(event)
# Reopen without commenting # Reopen without commenting
comment = add_comment(user, ticket, reopen=True) event = add_comment(user, ticket, reopen=True)
assert comment is None
assert ticket.status == TicketStatus.reported assert ticket.status == TicketStatus.reported
assert ticket.resolution == TicketResolution.wont_fix assert ticket.resolution == TicketResolution.wont_fix
assert len(ticket.comments) == 3 assert len(ticket.comments) == 3
assert len(ticket.events) == 5 assert len(ticket.events) == 5
event = ticket.events[4]
assert event.ticket == ticket assert event.ticket == ticket
assert event.comment == comment
assert len(mailbox) == 15 assert len(mailbox) == 15
assert_notifications_sent() assert_notifications_sent()
@ -234,7 +221,7 @@ def test_notifications_and_events(mailbox):
f"and {u2.canonical_name} " f"and {u2.canonical_name} "
f"also mentioning tickets #{t1.scoped_id}, and #{t2.scoped_id} and #999999" f"also mentioning tickets #{t1.scoped_id}, and #{t2.scoped_id} and #999999"
) )
comment = add_comment(commenter, ticket, text) event = add_comment(commenter, ticket, text)
assert len(mailbox) == 2 assert len(mailbox) == 2
@ -265,11 +252,11 @@ def test_notifications_and_events(mailbox):
u1_mention = u1_events.pop() u1_mention = u1_events.pop()
u2_mention = u2_events.pop() u2_mention = u2_events.pop()
assert u1_mention.comment == comment assert u1_mention.comment == event.comment
assert u1_mention.from_ticket == ticket assert u1_mention.from_ticket == ticket
assert u1_mention.by_user == commenter assert u1_mention.by_user == commenter
assert u2_mention.comment == comment assert u2_mention.comment == event.comment
assert u2_mention.from_ticket == ticket assert u2_mention.from_ticket == ticket
assert u2_mention.by_user == commenter assert u2_mention.by_user == commenter
@ -280,11 +267,11 @@ def test_notifications_and_events(mailbox):
t1_mention = t1.events[0] t1_mention = t1.events[0]
t2_mention = t2.events[0] t2_mention = t2.events[0]
assert t1_mention.comment == comment assert t1_mention.comment == event.comment
assert t1_mention.from_ticket == ticket assert t1_mention.from_ticket == ticket
assert t1_mention.by_user == commenter assert t1_mention.by_user == commenter
assert t2_mention.comment == comment assert t2_mention.comment == event.comment
assert t2_mention.from_ticket == ticket assert t2_mention.from_ticket == ticket
assert t2_mention.by_user == commenter assert t2_mention.by_user == commenter

View File

@ -134,9 +134,9 @@ class MailHandler:
print("Rejected, invalid comment length") print("Rejected, invalid comment length")
return "550 Comment must be between 3 and 16384 characters." return "550 Comment must be between 3 and 16384 characters."
comment = add_comment(sender, ticket, text=body, event = add_comment(sender, ticket, text=body,
resolution=resolution, resolve=resolve, reopen=reopen) resolution=resolution, resolve=resolve, reopen=reopen)
print(f"Added comment to {comment.ticket.ref()}") print(f"Added comment to {ticket.ref()}")
return "250 Message accepted for delivery" return "250 Message accepted for delivery"
async def handle_DATA(self, server, session, envelope): async def handle_DATA(self, server, session, envelope):

View File

@ -15,10 +15,13 @@ def get_user(username):
def register_api(app): def register_api(app):
from todosrht.blueprints.api.trackers import trackers from todosrht.blueprints.api.trackers import trackers
from todosrht.blueprints.api.tickets import tickets
trackers = csrf_bypass(trackers) trackers = csrf_bypass(trackers)
tickets = csrf_bypass(tickets)
app.register_blueprint(trackers) app.register_blueprint(trackers)
app.register_blueprint(tickets)
@app.route("/api/version") @app.route("/api/version")
def version(): def version():

View File

@ -0,0 +1,103 @@
from flask import Blueprint, abort, request
from srht.api import paginated_response
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.blueprints.api import get_user
from todosrht.types import Ticket, TicketAccess, TicketStatus, TicketResolution
tickets = Blueprint("api.tickets", __name__)
@tickets.route("/api/user/<username>/trackers/<tracker_name>/tickets")
@tickets.route("/api/trackers/<tracker_name>/tickets",
defaults={"username": None})
@oauth("tickets:read")
def tracker_tickets_GET(username, tracker_name):
user = get_user(username)
tracker, access = get_tracker(user, tracker_name, user=current_token.user)
if not tracker:
abort(404)
if not TicketAccess.browse in access:
abort(401)
tickets = (Ticket.query
.filter(Ticket.tracker_id == tracker.id)
.order_by(Ticket.scoped_id.desc()))
return paginated_response(Ticket.scoped_id, tickets)
@tickets.route("/api/user/<username>/trackers/<tracker_name>/tickets",
methods=["POST"])
@tickets.route("/api/trackers/<tracker_name>/tickets",
defaults={"username": None}, methods=["POST"])
@oauth("tickets:write")
def tracker_tickets_POST(username, tracker_name):
user = get_user(username)
tracker, access = get_tracker(user, tracker_name, user=current_token.user)
if not tracker:
abort(404)
if not TicketAccess.submit in access:
abort(401)
valid = Validation(request)
title = valid.require("title")
desc = valid.require("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 valid.response
ticket = submit_ticket(tracker, current_token.user, title, desc)
return ticket.to_dict(), 201
@tickets.route("/api/user/<username>/trackers/<tracker_name>/tickets/<ticket_id>")
@tickets.route("/api/trackers/<tracker_name>/tickets/<ticket_id>",
defaults={"username": None})
@oauth("tickets:read")
def tracker_ticket_by_id_GET(username, tracker_name, ticket_id):
user = get_user(username)
tracker, _ = get_tracker(user, tracker_name, user=current_token.user)
if not tracker:
abort(404)
ticket, access = get_ticket(tracker, ticket_id, user=current_token.user)
if not TicketAccess.browse in access:
abort(401)
return ticket.to_dict()
@tickets.route("/api/user/<username>/trackers/<tracker_name>/tickets/<ticket_id>",
methods=["PUT"])
@tickets.route("/api/trackers/<tracker_name>/tickets/<ticket_id>",
defaults={"username": None}, methods=["PUT"])
@oauth("tickets:write")
def tracker_ticket_by_id_PUT(username, tracker_name, ticket_id):
user = get_user(username)
tracker, _ = get_tracker(user, tracker_name, user=current_token.user)
if not tracker:
abort(404)
ticket, access = get_ticket(tracker, ticket_id, user=current_token.user)
required_access = TicketAccess.none
valid = Validation(request)
comment = resolution = None
resolve = reopen = False
if "comment" in valid:
comment = valid.optional("comment")
valid.expect(not comment or 3 <= len(comment) <= 16384,
"Comment must be between 3 and 16384 characters.",
field="comment")
if "status" in valid:
status = valid.optional("status",
cls=TicketStatus, default=valid.status)
if status != ticket.status:
if status != TicketStatus.open:
resolve = True
resolution = valid.require("resolution", cls=TicketResolution)
else:
reopen = True
event = add_comment(user, ticket, comment, resolve, resolution, reopen)
return event.to_dict()

View File

@ -150,10 +150,10 @@ def ticket_comment_POST(owner, name, ticket_id):
ctx = get_ticket_context(ticket, tracker, access) ctx = get_ticket_context(ticket, tracker, access)
return render_template("ticket.html", **ctx, **valid.kwargs) return render_template("ticket.html", **ctx, **valid.kwargs)
comment = add_comment(current_user, ticket, event = add_comment(current_user, ticket,
text=text, resolve=resolve, resolution=resolution, reopen=reopen) text=text, resolve=resolve, resolution=resolution, reopen=reopen)
return redirect(ticket_url(ticket, comment)) return redirect(ticket_url(ticket, event.comment))
@ticket.route("/<owner>/<name>/<int:ticket_id>/edit") @ticket.route("/<owner>/<name>/<int:ticket_id>/edit")
@loginrequired @loginrequired

View File

@ -249,7 +249,7 @@ def add_comment(user, ticket,
ticket.tracker.updated = datetime.utcnow() ticket.tracker.updated = datetime.utcnow()
db.session.commit() db.session.commit()
return comment return event
def mark_seen(ticket, user): def mark_seen(ticket, user):
"""Mark the ticket as seen by user.""" """Mark the ticket as seen by user."""

View File

@ -60,6 +60,28 @@ class Event(Base):
def __repr__(self): def __repr__(self):
return '<Event {}>'.format(self.id) return '<Event {}>'.format(self.id)
def to_dict(self):
return {
"id": self.id,
"created": self.created,
"event_type": [t.name for t in EventType if t in self.event_type],
"old_status": self.old_status.name,
"old_resolution": self.old_resolution.name,
"new_status": self.new_status.name,
"new_resolution": self.new_resolution.name,
"user": self.user.to_dict(short=True)
if self.user 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,
"from_ticket": self.from_ticket.to_dict(short=True)
if self.from_ticket else None,
}
class EventNotification(Base): class EventNotification(Base):
__tablename__ = 'event_notification' __tablename__ = 'event_notification'
id = sa.Column(sa.Integer, primary_key=True) id = sa.Column(sa.Integer, primary_key=True)

View File

@ -77,3 +77,32 @@ class Ticket(Base):
def __repr__(self): def __repr__(self):
return f"<Ticket {self.id}>" return f"<Ticket {self.id}>"
def to_dict(self, short=False):
def permissions(w):
return [p.name for p in TicketAccess
if p in w and p not in [TicketAccess.none, TicketAccess.all]]
return {
"id": self.scoped_id,
"ref": self.ref(),
"tracker": self.tracker.to_dict(short=True),
**({
"title": self.title,
"created": self.created,
"updated": self.updated,
"submitter": self.submitter.to_dict(short=True),
"description": self.description,
"status": self.status.name,
"resolution": self.resolution.name,
"permissions": {
"anonymous": permissions(self.anonymous_perms)
if self.anonymous_perms else None,
"submitter": permissions(self.submitter_perms)
if self.submitter_perms else None,
"user": permissions(self.user_perms)
if self.user_perms else None,
},
"labels": [l.name for l in self.labels],
"assignees": [u.to_dict(short=True) for u in self.assigned_users],
} if not short else {}),
}

View File

@ -20,3 +20,14 @@ class TicketComment(Base):
backref=sa.orm.backref("comments", cascade="all, delete-orphan")) backref=sa.orm.backref("comments", cascade="all, delete-orphan"))
text = sa.Column(sa.Unicode(16384)) text = sa.Column(sa.Unicode(16384))
def to_dict(self, short=False):
return {
"id": self.id,
"created": self.created,
"submitter": self.submitter.to_dict(short=True),
"text": self.text,
**({
"ticket": self.ticket.to_dict(short=True),
} if not short else {})
}

View File

@ -84,28 +84,30 @@ class Tracker(Base):
return return
self.owner_id = user.id self.owner_id = user.id
self.owner = user
self.name = name self.name = name
self.description = desc self.description = desc
def __repr__(self): def __repr__(self):
return '<Tracker {} {}>'.format(self.id, self.name) return '<Tracker {} {}>'.format(self.id, self.name)
def to_dict(self): def to_dict(self, short=False):
def permissions(w): def permissions(w):
return [p.name for p in TicketAccess return [p.name for p in TicketAccess
if p in w and p not in [TicketAccess.none, TicketAccess.all]] if p in w and p not in [TicketAccess.none, TicketAccess.all]]
return { return {
"id": self.id,
"owner": self.owner.to_dict(short=True), "owner": self.owner.to_dict(short=True),
"created": self.created, "created": self.created,
"updated": self.updated, "updated": self.updated,
"name": self.name, "name": self.name,
"description": self.description, **({
"default_permissions": { "description": self.description,
"anonymous": permissions(self.default_anonymous_perms), "default_permissions": {
"submitter": permissions(self.default_submitter_perms), "anonymous": permissions(self.default_anonymous_perms),
"user": permissions(self.default_user_perms), "submitter": permissions(self.default_submitter_perms),
}, "user": permissions(self.default_user_perms),
},
} if not short else {})
} }
def update(self, valid): def update(self, valid):