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

View File

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

View File

@ -134,9 +134,9 @@ class MailHandler:
print("Rejected, invalid comment length")
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)
print(f"Added comment to {comment.ticket.ref()}")
print(f"Added comment to {ticket.ref()}")
return "250 Message accepted for delivery"
async def handle_DATA(self, server, session, envelope):

View File

@ -15,10 +15,13 @@ def get_user(username):
def register_api(app):
from todosrht.blueprints.api.trackers import trackers
from todosrht.blueprints.api.tickets import tickets
trackers = csrf_bypass(trackers)
tickets = csrf_bypass(tickets)
app.register_blueprint(trackers)
app.register_blueprint(tickets)
@app.route("/api/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)
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)
return redirect(ticket_url(ticket, comment))
return redirect(ticket_url(ticket, event.comment))
@ticket.route("/<owner>/<name>/<int:ticket_id>/edit")
@loginrequired

View File

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

View File

@ -60,6 +60,28 @@ class Event(Base):
def __repr__(self):
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):
__tablename__ = 'event_notification'
id = sa.Column(sa.Integer, primary_key=True)

View File

@ -77,3 +77,32 @@ class Ticket(Base):
def __repr__(self):
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"))
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
self.owner_id = user.id
self.owner = user
self.name = name
self.description = desc
def __repr__(self):
return '<Tracker {} {}>'.format(self.id, self.name)
def to_dict(self):
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.id,
"owner": self.owner.to_dict(short=True),
"created": self.created,
"updated": self.updated,
"name": self.name,
"description": self.description,
"default_permissions": {
"anonymous": permissions(self.default_anonymous_perms),
"submitter": permissions(self.default_submitter_perms),
"user": permissions(self.default_user_perms),
},
**({
"description": self.description,
"default_permissions": {
"anonymous": permissions(self.default_anonymous_perms),
"submitter": permissions(self.default_submitter_perms),
"user": permissions(self.default_user_perms),
},
} if not short else {})
}
def update(self, valid):