diff --git a/tests/factories.py b/tests/factories.py index 60efbdf..e73f8dd 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -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 diff --git a/tests/test_comments.py b/tests/test_comments.py index b85d751..9c010f7 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -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 diff --git a/todosrht-lmtp b/todosrht-lmtp index 1e3b6ed..e8a7b5b 100755 --- a/todosrht-lmtp +++ b/todosrht-lmtp @@ -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): diff --git a/todosrht/blueprints/api/__init__.py b/todosrht/blueprints/api/__init__.py index 23f7ea0..bc0e4f7 100644 --- a/todosrht/blueprints/api/__init__.py +++ b/todosrht/blueprints/api/__init__.py @@ -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(): diff --git a/todosrht/blueprints/api/tickets.py b/todosrht/blueprints/api/tickets.py new file mode 100644 index 0000000..a62f043 --- /dev/null +++ b/todosrht/blueprints/api/tickets.py @@ -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//trackers//tickets") +@tickets.route("/api/trackers//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//trackers//tickets", + methods=["POST"]) +@tickets.route("/api/trackers//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//trackers//tickets/") +@tickets.route("/api/trackers//tickets/", + 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//trackers//tickets/", + methods=["PUT"]) +@tickets.route("/api/trackers//tickets/", + 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() diff --git a/todosrht/blueprints/ticket.py b/todosrht/blueprints/ticket.py index 412effc..57a67f0 100644 --- a/todosrht/blueprints/ticket.py +++ b/todosrht/blueprints/ticket.py @@ -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("////edit") @loginrequired diff --git a/todosrht/tickets.py b/todosrht/tickets.py index fa3ae3a..ce4f9d5 100644 --- a/todosrht/tickets.py +++ b/todosrht/tickets.py @@ -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.""" diff --git a/todosrht/types/event.py b/todosrht/types/event.py index 3cb4d52..6efeda0 100644 --- a/todosrht/types/event.py +++ b/todosrht/types/event.py @@ -60,6 +60,28 @@ class Event(Base): def __repr__(self): return ''.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) diff --git a/todosrht/types/ticket.py b/todosrht/types/ticket.py index c10f4c8..38e8dc9 100644 --- a/todosrht/types/ticket.py +++ b/todosrht/types/ticket.py @@ -77,3 +77,32 @@ class Ticket(Base): def __repr__(self): return f"" + + 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 {}), + } diff --git a/todosrht/types/ticketcomment.py b/todosrht/types/ticketcomment.py index d4fed95..1efc2b3 100644 --- a/todosrht/types/ticketcomment.py +++ b/todosrht/types/ticketcomment.py @@ -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 {}) + } diff --git a/todosrht/types/tracker.py b/todosrht/types/tracker.py index 80c8db9..edc3537 100644 --- a/todosrht/types/tracker.py +++ b/todosrht/types/tracker.py @@ -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 ''.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):