diff --git a/.TRACFREEZE.txt b/.TRACFREEZE.txt index ba2d703..0640603 100644 --- a/.TRACFREEZE.txt +++ b/.TRACFREEZE.txt @@ -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 @@ -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 diff --git a/DjangoPlugin/tracdjangoplugin/plugins.py b/DjangoPlugin/tracdjangoplugin/plugins.py index cb00f3d..14a86cb 100644 --- a/DjangoPlugin/tracdjangoplugin/plugins.py +++ b/DjangoPlugin/tracdjangoplugin/plugins.py @@ -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) diff --git a/DjangoPlugin/tracdjangoplugin/tests.py b/DjangoPlugin/tracdjangoplugin/tests.py index 04d8c8f..12d6209 100644 --- a/DjangoPlugin/tracdjangoplugin/tests.py +++ b/DjangoPlugin/tracdjangoplugin/tests.py @@ -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): @@ -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())