Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .TRACFREEZE.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# generated by traccheck.py on 2024-04-18 14:36:14 with Trac version 1.6
# generated by traccheck.py on 2026-04-27 09:42:14 with Trac version 1.6
trac.admin.api.admincommandmanager
trac.admin.console.tracadminhelpmacro
trac.admin.web_ui.adminmodule
Expand Down Expand Up @@ -108,6 +108,7 @@ tracdjangoplugin.plugins.customwikimodule
tracdjangoplugin.plugins.githubbrowserwithsvnchangesets
tracdjangoplugin.plugins.plainlogincomponent
tracdjangoplugin.plugins.reservedusernamescomponent
tracdjangoplugin.plugins.timelineticketcomponentfilter
tracdragdrop.web_ui.tracdragdropmodule
tracext.github.githubloginmodule
tracext.github.githubpostcommithook
Expand Down
38 changes: 38 additions & 0 deletions DjangoPlugin/tracdjangoplugin/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,44 @@ def _format_changeset_link(self, formatter, ns, chgset, label, fullmatch=None):
return super()._format_changeset_link(formatter, ns, chgset, label, fullmatch)


class TimelineTicketComponentFilter(Component):
"""Filter timeline events to only show tickets for a given ticket component.

Activated when the request includes a ``component`` query argument, e.g.
/timeline?component=contrib.staticfiles&format=rss

Non-ticket events (wiki edits, commits, milestones, attachments) are
hidden when ``component`` is set.

``batchmodify`` events are excluded because a single batch operation may
span multiple ticket components, making it ambiguous which filter to apply.
"""

implements(IRequestFilter)

_TICKET_COMPONENT_INDEX = 8 # see trac/ticket/web_ui.py produce_event()
_TICKET_KINDS = frozenset(
["newticket", "editedticket", "closedticket", "reopenedticket"]
)

def pre_process_request(self, req, handler):
return handler

def post_process_request(self, req, template, data, metadata):
if req.path_info != "/timeline" or data is None:
return template, data, metadata
components = set(req.args.getlist("component"))
if not components:
return template, data, metadata
data["events"] = [
event
for event in data["events"]
if event["kind"] in self._TICKET_KINDS
and event["data"][self._TICKET_COMPONENT_INDEX] in components
]
return template, data, metadata


class PlainLoginComponent(Component):
"""
Enable login through a plain HTML form (no more HTTP basic auth)
Expand Down
72 changes: 71 additions & 1 deletion DjangoPlugin/tracdjangoplugin/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
from trac.web.api import RequestDone

from tracdjangoplugin.middlewares import DjangoDBManagementMiddleware
from tracdjangoplugin.plugins import PlainLoginComponent, ReservedUsernamesComponent
from tracdjangoplugin.plugins import (
PlainLoginComponent,
ReservedUsernamesComponent,
TimelineTicketComponentFilter,
)


class PlainLoginComponentTestCase(TestCase):
Expand Down Expand Up @@ -248,6 +252,72 @@ def test_anonymous_goes_through(self):
self.assertIs(retval, handler)


class TimelineTicketComponentFilterTestCase(SimpleTestCase):
def setUp(self):
self.env = EnvironmentStub()
self.filter = TimelineTicketComponentFilter(self.env)
self.request_factory = partial(MockRequest, self.env)

def make_ticket_event(self, kind, component):
data = [None] * 11
data[TimelineTicketComponentFilter._TICKET_COMPONENT_INDEX] = component
return {"kind": kind, "data": tuple(data)}

def make_non_ticket_event(self, kind="wiki"):
return {"kind": kind, "data": ()}

def post_process(self, req, events):
return self.filter.post_process_request(
req, "timeline.rss", {"events": events}, {}
)

def test_keeps_matching_component(self):
req = self.request_factory(
path_info="/timeline", args={"component": "contrib.staticfiles"}
)
matching = self.make_ticket_event("newticket", "contrib.staticfiles")
non_matching = self.make_ticket_event("newticket", "contrib.auth")
_, data, _ = self.filter.post_process_request(
req, "timeline.rss", {"events": [matching, non_matching]}, {}
)
self.assertEqual(data["events"], [matching])

def test_filters_out_non_ticket_events(self):
req = self.request_factory(
path_info="/timeline", args={"component": "contrib.staticfiles"}
)
ticket_event = self.make_ticket_event("newticket", "contrib.staticfiles")
wiki_event = self.make_non_ticket_event("wiki")
_, data, _ = self.post_process(req, [ticket_event, wiki_event])
self.assertEqual(data["events"], [ticket_event])

def test_all_ticket_kinds_match(self):
req = self.request_factory(
path_info="/timeline", args={"component": "contrib.staticfiles"}
)
events = [
self.make_ticket_event(kind, "contrib.staticfiles")
for kind in ("newticket", "editedticket", "closedticket", "reopenedticket")
]
_, data, _ = self.post_process(req, events)
self.assertEqual(len(data["events"]), 4)

def test_multiple_components(self):
req = self.request_factory(
path_info="/timeline",
args={"component": ["contrib.staticfiles", "contrib.auth"]},
)
matching_staticfiles = self.make_ticket_event(
"newticket", "contrib.staticfiles"
)
matching_auth = self.make_ticket_event("newticket", "contrib.auth")
non_matching = self.make_ticket_event("newticket", "contrib.admin")
_, data, _ = self.post_process(
req, [matching_staticfiles, matching_auth, non_matching]
)
self.assertEqual(data["events"], [matching_staticfiles, matching_auth])


class RSTWikiTestCase(SimpleTestCase):
def test_wiki_can_render_rst(self):
renderer = Mimeview(EnvironmentStub())
Expand Down
Loading