diff --git a/temba/tickets/models.py b/temba/tickets/models.py index 03b25f35f3..f1f32bf006 100644 --- a/temba/tickets/models.py +++ b/temba/tickets/models.py @@ -301,7 +301,7 @@ class TicketFolder(metaclass=ABCMeta): icon = None verbose_name = None - def get_queryset(self, org, user, ordered): + def get_queryset(self, org, user, *, ordered: bool): qs = org.tickets.all() if ordered: @@ -325,20 +325,20 @@ def all(cls): class MineFolder(TicketFolder): """ - Tickets assigned to the current user + Tickets assigned to the current user. """ slug = "mine" name = _("My Tickets") icon = "tickets_mine" - def get_queryset(self, org, user, ordered): - return super().get_queryset(org, user, ordered).filter(assignee=user) + def get_queryset(self, org, user, *, ordered: bool): + return super().get_queryset(org, user, ordered=ordered).filter(assignee=user) class UnassignedFolder(TicketFolder): """ - Tickets not assigned to any user + Tickets not assigned to any user. """ slug = "unassigned" @@ -346,13 +346,13 @@ class UnassignedFolder(TicketFolder): verbose_name = _("Unassigned Tickets") icon = "tickets_unassigned" - def get_queryset(self, org, user, ordered): - return super().get_queryset(org, user, ordered).filter(assignee=None) + def get_queryset(self, org, user, *, ordered: bool): + return super().get_queryset(org, user, ordered=ordered).filter(assignee=None) class AllFolder(TicketFolder): """ - All tickets + All tickets the user can access. """ slug = "all" @@ -360,16 +360,13 @@ class AllFolder(TicketFolder): verbose_name = _("All Tickets") icon = "tickets_all" - def get_queryset(self, org, user, ordered): - return super().get_queryset(org, user, ordered) - FOLDERS = {f.slug: f() for f in TicketFolder.__subclasses__()} class TopicFolder(TicketFolder): """ - Tickets with a specific topic + Wraps a topic so we can use it like a folder. """ def __init__(self, topic: Topic): @@ -377,8 +374,8 @@ def __init__(self, topic: Topic): self.name = topic.name self.topic = topic - def get_queryset(self, org, user, ordered): - return super().get_queryset(org, user, ordered).filter(topic=self.topic) + def get_queryset(self, org, user, *, ordered: bool): + return super().get_queryset(org, user, ordered=ordered).filter(topic=self.topic) class TicketCount(SquashableModel): diff --git a/temba/tickets/tests.py b/temba/tickets/tests.py index 5dbc393a32..54f2c8cc34 100644 --- a/temba/tickets/tests.py +++ b/temba/tickets/tests.py @@ -590,34 +590,35 @@ def test_list(self): self.assertRequestDisallowed(list_url, [None]) self.assertListFetch(list_url, [self.user, self.editor, self.admin, self.agent], context_objects=[]) - # can hit this page with a uuid - # TODO: work out reverse for deep link - # deep_link = reverse( - # "tickets.ticket_list", kwargs={"folder": "all", "status": "open", "uuid": str(ticket.uuid)} - # ) - + # link to our ticket within the All folder deep_link = f"{list_url}all/open/{str(ticket.uuid)}/" - self.assertContentMenu(deep_link, self.admin, ["Edit", "Add Note", "Start Flow"]) + response = self.assertListFetch(deep_link, [self.user, self.editor, self.admin, self.agent], context_objects=[]) + self.assertEqual("all", response.context["folder"]) + self.assertEqual("open", response.context["status"]) # our ticket exists on the first page, so it'll get flagged to be focused self.assertEqual(str(ticket.uuid), response.context["nextUUID"]) - # deep link into a page that doesn't have our ticket - deep_link = f"{list_url}all/closed/{str(ticket.uuid)}/" + # we have a specific ticket so we should show context menu for it + self.assertContentMenu(deep_link, self.admin, ["Edit", "Add Note", "Start Flow"]) - self.login(self.admin) + # try to link to our ticket but with mismatched status + deep_link = f"{list_url}all/closed/{str(ticket.uuid)}/" - response = self.client.get(deep_link) + response = self.assertListFetch(deep_link, [self.agent], context_objects=[]) - # now our ticket is listed as the uuid and we were redirected to all/open + # now our ticket is listed as the uuid and we were redirected to All folder with Open status self.assertEqual("all", response.context["folder"]) self.assertEqual("open", response.context["status"]) self.assertEqual(str(ticket.uuid), response.context["uuid"]) - # bad topic should give a 404 + # and again we have a specific ticket so we should show context menu for it + self.assertContentMenu(deep_link, self.admin, ["Edit", "Add Note", "Start Flow"]) + + # non-existent topic should give a 404 bad_topic_link = f"{list_url}{uuid4()}/open/{str(ticket.uuid)}/" - response = self.client.get(bad_topic_link) + response = self.requestView(bad_topic_link, self.agent) self.assertEqual(404, response.status_code) response = self.client.get( @@ -637,7 +638,7 @@ def test_list(self): # closed our tickets don't get extra menu options ticket.status = Ticket.STATUS_CLOSED - ticket.save() + ticket.save(update_fields=("status",)) deep_link = f"{list_url}all/closed/{str(ticket.uuid)}/" self.assertContentMenu(deep_link, self.admin, []) diff --git a/temba/tickets/views.py b/temba/tickets/views.py index 8be95f82f7..088ec65f5d 100644 --- a/temba/tickets/views.py +++ b/temba/tickets/views.py @@ -217,7 +217,7 @@ def derive_menu(self): class List(SpaMixin, ContextMenuMixin, OrgPermsMixin, NotificationTargetMixin, SmartListView): """ - A placeholder view for the ticket handling frontend components which fetch tickets from the endpoint below + Placeholder view for the ticketing frontend components which fetch tickets from the folders view below. """ @classmethod @@ -226,105 +226,107 @@ def derive_url_pattern(cls, path, action): return rf"^ticket/((?P{folders}|{UUID_REGEX.pattern})/((?Popen|closed)/((?P[a-z0-9\-]+)/)?)?)?$" def get_notification_scope(self) -> tuple: - folder, status, _, _ = self.tickets_path - if folder == UnassignedFolder.slug and status == "open": + folder, status, ticket, in_page = self.tickets_path + + if folder.slug == UnassignedFolder.slug and status == Ticket.STATUS_OPEN: return "tickets:opened", "" - elif folder == MineFolder.slug and status == "open": + elif folder.slug == MineFolder.slug and status == Ticket.STATUS_OPEN: return "tickets:activity", "" return "", "" def derive_menu_path(self): - return f"/ticket/{self.kwargs.get('folder', 'mine')}/" + folder, status, ticket, in_page = self.tickets_path + + return f"/ticket/{folder.slug}/" @cached_property - def tickets_path(self) -> tuple: + def tickets_path(self) -> tuple[TicketFolder, str, Ticket, bool]: """ - Returns tuple of folder, status, ticket uuid, and whether that ticket exists in first page of tickets + Returns tuple of folder, status, ticket, and whether that ticket exists in first page of tickets """ - folder = self.kwargs.get("folder") - status = self.kwargs.get("status") - uuid = self.kwargs.get("uuid") + + # get requested folder, defaulting to Mine + folder = TicketFolder.from_slug(self.request.org, self.kwargs.get("folder", MineFolder.slug)) + if not folder: + raise Http404() + + status = Ticket.STATUS_OPEN if self.kwargs.get("status", "open") == "open" else Ticket.STATUS_CLOSED + ticket = None in_page = False - # if we have a uuid make sure it is in our first page of tickets - if uuid: - status_code = Ticket.STATUS_OPEN if status == "open" else Ticket.STATUS_CLOSED + # is the request for a specific ticket? + if uuid := self.kwargs.get("uuid"): org = self.request.org user = self.request.user - ticket_folder = TicketFolder.from_slug(org, folder) - if not ticket_folder: - raise Http404() + # is the ticket in the first page from of current folder? + for t in list(folder.get_queryset(org, user, ordered=True).filter(status=status)[:25]): + if str(t.uuid) == uuid: + ticket = t + in_page = True + break - tickets = list(ticket_folder.get_queryset(org, user, True).filter(status=status_code)[:25]) + # if not, see if we can access it in the All tickets folder and if so switch to that + if not in_page: + all_folder = TicketFolder.from_slug(self.request.org, AllFolder.slug) + ticket = all_folder.get_queryset(org, user, ordered=False).filter(uuid=uuid).first() - found = list(filter(lambda t: str(t.uuid) == uuid, tickets)) - if found: - in_page = True - else: - # if it's not, switch our folder to everything with that ticket's state - ticket = org.tickets.filter(uuid=uuid).first() if ticket: - folder = AllFolder.slug - status = "open" if ticket.status == Ticket.STATUS_OPEN else "closed" + folder = all_folder + status = ticket.status - return folder or MineFolder.slug, status or "open", uuid, in_page + return folder, status, ticket, in_page def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - folder, status, uuid, in_page = self.tickets_path - context["folder"] = folder - context["status"] = status - context["has_tickets"] = self.request.org.tickets.exists() + folder, status, ticket, in_page = self.tickets_path - folder = TicketFolder.from_slug(self.request.org, folder) context["title"] = folder.name + context["folder"] = folder.slug + context["status"] = "open" if status == Ticket.STATUS_OPEN else "closed" + context["has_tickets"] = self.request.org.tickets.exists() - if uuid: - context["nextUUID" if in_page else "uuid"] = uuid + if ticket: + context["nextUUID" if in_page else "uuid"] = str(ticket.uuid) return context def build_context_menu(self, menu): - uuid = self.kwargs.get("uuid") - if uuid: - ticket = self.request.org.tickets.filter(uuid=uuid).first() - if ticket: - if ticket.status == Ticket.STATUS_OPEN: - if self.has_org_perm("tickets.ticket_update"): - menu.add_modax( - _("Edit"), - "edit-ticket", - f"{reverse('tickets.ticket_update', args=[ticket.uuid])}", - title=_("Edit Ticket"), - on_submit="handleTicketEditComplete()", - ) - - if self.has_org_perm("tickets.ticket_note"): - menu.add_modax( - _("Add Note"), - "add-note", - f"{reverse('tickets.ticket_note', args=[ticket.uuid])}", - on_submit="handleNoteAdded()", - ) - - # we don't want to show start flow if interrupt was given as an option - interrupt_added = False - if self.has_org_perm("contacts.contact_interrupt") and ticket.contact.current_flow: - menu.add_url_post( - _("Interrupt"), reverse("contacts.contact_interrupt", args=(ticket.contact.id,)) - ) - interrupt_added = True - - if not interrupt_added and self.has_org_perm("flows.flow_start"): - menu.add_modax( - _("Start Flow"), - "start-flow", - f"{reverse('flows.flow_start')}?c={ticket.contact.uuid}", - disabled=True, - on_submit="handleFlowStarted()", - ) + folder, status, ticket, in_page = self.tickets_path + + if ticket and ticket.status == Ticket.STATUS_OPEN: + if self.has_org_perm("tickets.ticket_update"): + menu.add_modax( + _("Edit"), + "edit-ticket", + f"{reverse('tickets.ticket_update', args=[ticket.uuid])}", + title=_("Edit Ticket"), + on_submit="handleTicketEditComplete()", + ) + + if self.has_org_perm("tickets.ticket_note"): + menu.add_modax( + _("Add Note"), + "add-note", + f"{reverse('tickets.ticket_note', args=[ticket.uuid])}", + on_submit="handleNoteAdded()", + ) + + # we don't want to show start flow if interrupt was given as an option + interrupt_added = False + if self.has_org_perm("contacts.contact_interrupt") and ticket.contact.current_flow: + menu.add_url_post(_("Interrupt"), reverse("contacts.contact_interrupt", args=(ticket.contact.id,))) + interrupt_added = True + + if not interrupt_added and self.has_org_perm("flows.flow_start"): + menu.add_modax( + _("Start Flow"), + "start-flow", + f"{reverse('flows.flow_start')}?c={ticket.contact.uuid}", + disabled=True, + on_submit="handleFlowStarted()", + ) def get_queryset(self, **kwargs): return super().get_queryset(**kwargs).none() @@ -374,7 +376,7 @@ def get_queryset(self, **kwargs): # fetching new activity gets a different order later ordered = False if after else True - qs = self.folder.get_queryset(org, user, ordered).filter(status=status) + qs = self.folder.get_queryset(org, user, ordered=ordered).filter(status=status) # all new activity after = int(self.request.GET.get("after", 0)) @@ -395,7 +397,7 @@ def get_queryset(self, **kwargs): if count == self.paginate_by: last_ticket = qs[len(qs) - 1] - qs = self.folder.get_queryset(org, user, ordered).filter( + qs = self.folder.get_queryset(org, user, ordered=ordered).filter( status=status, last_activity_on__gte=last_ticket.last_activity_on )