Skip to content

Commit c8307ae

Browse files
authored
Refs #202 -- Added timeline filter based on request "component" query argument. (#294)
Co-authored-by: James Bligh <blighj@users.noreply.github.com>
1 parent 3fb4013 commit c8307ae

3 files changed

Lines changed: 111 additions & 2 deletions

File tree

.TRACFREEZE.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# generated by traccheck.py on 2024-04-18 14:36:14 with Trac version 1.6
1+
# generated by traccheck.py on 2026-04-27 09:42:14 with Trac version 1.6
22
trac.admin.api.admincommandmanager
33
trac.admin.console.tracadminhelpmacro
44
trac.admin.web_ui.adminmodule
@@ -108,6 +108,7 @@ tracdjangoplugin.plugins.customwikimodule
108108
tracdjangoplugin.plugins.githubbrowserwithsvnchangesets
109109
tracdjangoplugin.plugins.plainlogincomponent
110110
tracdjangoplugin.plugins.reservedusernamescomponent
111+
tracdjangoplugin.plugins.timelineticketcomponentfilter
111112
tracdragdrop.web_ui.tracdragdropmodule
112113
tracext.github.githubloginmodule
113114
tracext.github.githubpostcommithook

DjangoPlugin/tracdjangoplugin/plugins.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,44 @@ def _format_changeset_link(self, formatter, ns, chgset, label, fullmatch=None):
177177
return super()._format_changeset_link(formatter, ns, chgset, label, fullmatch)
178178

179179

180+
class TimelineTicketComponentFilter(Component):
181+
"""Filter timeline events to only show tickets for a given ticket component.
182+
183+
Activated when the request includes a ``component`` query argument, e.g.
184+
/timeline?component=contrib.staticfiles&format=rss
185+
186+
Non-ticket events (wiki edits, commits, milestones, attachments) are
187+
hidden when ``component`` is set.
188+
189+
``batchmodify`` events are excluded because a single batch operation may
190+
span multiple ticket components, making it ambiguous which filter to apply.
191+
"""
192+
193+
implements(IRequestFilter)
194+
195+
_TICKET_COMPONENT_INDEX = 8 # see trac/ticket/web_ui.py produce_event()
196+
_TICKET_KINDS = frozenset(
197+
["newticket", "editedticket", "closedticket", "reopenedticket"]
198+
)
199+
200+
def pre_process_request(self, req, handler):
201+
return handler
202+
203+
def post_process_request(self, req, template, data, metadata):
204+
if req.path_info != "/timeline" or data is None:
205+
return template, data, metadata
206+
components = set(req.args.getlist("component"))
207+
if not components:
208+
return template, data, metadata
209+
data["events"] = [
210+
event
211+
for event in data["events"]
212+
if event["kind"] in self._TICKET_KINDS
213+
and event["data"][self._TICKET_COMPONENT_INDEX] in components
214+
]
215+
return template, data, metadata
216+
217+
180218
class PlainLoginComponent(Component):
181219
"""
182220
Enable login through a plain HTML form (no more HTTP basic auth)

DjangoPlugin/tracdjangoplugin/tests.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
from trac.web.api import RequestDone
1616

1717
from tracdjangoplugin.middlewares import DjangoDBManagementMiddleware
18-
from tracdjangoplugin.plugins import PlainLoginComponent, ReservedUsernamesComponent
18+
from tracdjangoplugin.plugins import (
19+
PlainLoginComponent,
20+
ReservedUsernamesComponent,
21+
TimelineTicketComponentFilter,
22+
)
1923

2024

2125
class PlainLoginComponentTestCase(TestCase):
@@ -248,6 +252,72 @@ def test_anonymous_goes_through(self):
248252
self.assertIs(retval, handler)
249253

250254

255+
class TimelineTicketComponentFilterTestCase(SimpleTestCase):
256+
def setUp(self):
257+
self.env = EnvironmentStub()
258+
self.filter = TimelineTicketComponentFilter(self.env)
259+
self.request_factory = partial(MockRequest, self.env)
260+
261+
def make_ticket_event(self, kind, component):
262+
data = [None] * 11
263+
data[TimelineTicketComponentFilter._TICKET_COMPONENT_INDEX] = component
264+
return {"kind": kind, "data": tuple(data)}
265+
266+
def make_non_ticket_event(self, kind="wiki"):
267+
return {"kind": kind, "data": ()}
268+
269+
def post_process(self, req, events):
270+
return self.filter.post_process_request(
271+
req, "timeline.rss", {"events": events}, {}
272+
)
273+
274+
def test_keeps_matching_component(self):
275+
req = self.request_factory(
276+
path_info="/timeline", args={"component": "contrib.staticfiles"}
277+
)
278+
matching = self.make_ticket_event("newticket", "contrib.staticfiles")
279+
non_matching = self.make_ticket_event("newticket", "contrib.auth")
280+
_, data, _ = self.filter.post_process_request(
281+
req, "timeline.rss", {"events": [matching, non_matching]}, {}
282+
)
283+
self.assertEqual(data["events"], [matching])
284+
285+
def test_filters_out_non_ticket_events(self):
286+
req = self.request_factory(
287+
path_info="/timeline", args={"component": "contrib.staticfiles"}
288+
)
289+
ticket_event = self.make_ticket_event("newticket", "contrib.staticfiles")
290+
wiki_event = self.make_non_ticket_event("wiki")
291+
_, data, _ = self.post_process(req, [ticket_event, wiki_event])
292+
self.assertEqual(data["events"], [ticket_event])
293+
294+
def test_all_ticket_kinds_match(self):
295+
req = self.request_factory(
296+
path_info="/timeline", args={"component": "contrib.staticfiles"}
297+
)
298+
events = [
299+
self.make_ticket_event(kind, "contrib.staticfiles")
300+
for kind in ("newticket", "editedticket", "closedticket", "reopenedticket")
301+
]
302+
_, data, _ = self.post_process(req, events)
303+
self.assertEqual(len(data["events"]), 4)
304+
305+
def test_multiple_components(self):
306+
req = self.request_factory(
307+
path_info="/timeline",
308+
args={"component": ["contrib.staticfiles", "contrib.auth"]},
309+
)
310+
matching_staticfiles = self.make_ticket_event(
311+
"newticket", "contrib.staticfiles"
312+
)
313+
matching_auth = self.make_ticket_event("newticket", "contrib.auth")
314+
non_matching = self.make_ticket_event("newticket", "contrib.admin")
315+
_, data, _ = self.post_process(
316+
req, [matching_staticfiles, matching_auth, non_matching]
317+
)
318+
self.assertEqual(data["events"], [matching_staticfiles, matching_auth])
319+
320+
251321
class RSTWikiTestCase(SimpleTestCase):
252322
def test_wiki_can_render_rst(self):
253323
renderer = Mimeview(EnvironmentStub())

0 commit comments

Comments
 (0)