PR#4387: repo requests web ui

Merges #4387
https://pagure.io/koji/pull-request/4387

Fixes: #4289
https://pagure.io/koji/issue/4289
repo requests web ui
This commit is contained in:
Tomas Kopecek 2025-06-12 13:24:20 +02:00
commit 45c290dd74
13 changed files with 1422 additions and 27 deletions

View file

@ -44,6 +44,8 @@ class FakeClientSession(BaseFakeClientSession):
super(FakeClientSession, self).__init__(*a, **kw)
self._calldata = {}
self._offsets = {}
self._missing_rsession = None
# caller can set _missing_rsession to a Recording session to handle missing calls
def load_calls(self, data):
"""Load call data
@ -75,6 +77,9 @@ class FakeClientSession(BaseFakeClientSession):
# we may have a series of calls for each key
calls = self._calldata.get(key)
ofs = self._offsets.get(key, 0)
if calls is None:
# we don't have it
return self._handle_missing(name, args, kwargs)
call = calls[ofs]
ofs += 1
if ofs < len(calls):
@ -90,6 +95,15 @@ class FakeClientSession(BaseFakeClientSession):
else:
return mock.MagicMock()
def _handle_missing(self, name, args, kwargs):
print('Missing call data for: %s %r %r' % (name, args, kwargs))
rsession = self._missing_rsession
if rsession is None:
return mock.MagicMock()
# otherwise use the recording session
return rsession._callMethod(name, args, kwargs)
def _munge(self, data):
def callback(value):
if isinstance(value, list):

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,9 @@ class TestPages(unittest.TestCase):
def setUpClass(cls):
# recording session used across tests in recording mode
cls.cfile = os.path.dirname(__file__) + f'/data/pages_calls.json'
cls.cfile2 = os.path.dirname(__file__) + f'/data/pages_calls_updates.json'
cls.recording = False
cls.updating = False
cls.rsession = RecordingClientSession('http://localhost/kojihub', {})
@classmethod
@ -35,6 +37,8 @@ class TestPages(unittest.TestCase):
if cls.recording:
# save recorded calls
cls.rsession.dump(cls.cfile)
elif cls.updating:
cls.rsession.dump(cls.cfile2)
def setUp(self):
self.environ = {
@ -66,6 +70,8 @@ class TestPages(unittest.TestCase):
self.time.return_value = 1735707600.0
def __get_server(env):
# this is replacing the call to _getServer
env['koji.session'] = self.server
return self.server
self.get_server.side_effect = __get_server
@ -77,6 +83,8 @@ class TestPages(unittest.TestCase):
else:
self.server = FakeClientSession('SERVER', {})
self.server.load(self.cfile)
if self.updating:
self.server._missing_rsession = self.rsession
return self.server
def tearDown(self):
@ -166,17 +174,27 @@ class TestPages(unittest.TestCase):
['buildroots', ''],
['buildroots', 'start=50&order=id'],
#['builds', 'start=50&order=id'],
['reporequests', ''],
['reporequests', 'active=all&order=tag_name'],
['reporequests', 'active=false&order=-id'],
['reporequests', 'tag=1&active=all'],
['reporequest', 'reqID=127'],
['reporequest', 'reqID=128'],
['reporequest', 'reqID=132'],
['reporequest', 'reqID=133'],
['reporequest', 'reqID=134'],
]
def prep_handler(self, method, query):
"""Takes method name and query string, returns handler and data"""
# based loosely on publisher prep_handler
self.environ['QUERY_STRING'] = query
self.environ['koji.method'] = method
self.environ['SCRIPT_NAME'] = method
environ = self.environ.copy()
environ['QUERY_STRING'] = query
environ['koji.method'] = method
environ['SCRIPT_NAME'] = method
handler = getattr(webidx, method)
fs = FieldStorageCompat(self.environ)
self.environ['koji.form'] = fs
fs = FieldStorageCompat(environ)
environ['koji.form'] = fs
# even though we have curated urls, we need to filter args for some cases, e.g. search
args, varargs, varkw, defaults, kwonlyargs, kwonlydefaults, ann = \
inspect.getfullargspec(handler)
@ -184,14 +202,14 @@ class TestPages(unittest.TestCase):
data = dslice(fs.data, args, strict=False)
else:
data = fs.data.copy()
return handler, data
return handler, data, environ
def test_web_handlers(self):
"""Test a bunch of web handlers"""
for method, query in self.CALLS:
handler, data = self.prep_handler(method, query)
handler, data, environ = self.prep_handler(method, query)
result = handler(self.environ, **data)
result = handler(environ, **data)
# result should be a string containing the rendered template
self.assertIsInstance(result, str)

View file

@ -33,7 +33,7 @@ class TestRepoInfo(unittest.TestCase):
webidx.repoinfo(self.environ, self.repo_id)
self.server.repoInfo.assert_called_once_with(int(self.repo_id), strict=False)
self.server.listBuildroots.assert_called_once_with(repoID=int(self.repo_id))
self.server.listBuildroots.assert_called_once_with(repoID=int(self.repo_id), queryOpts={'countOnly': True})
def test_repoinfo_not_dist(self):
"""Test repoinfo function - not dist repo"""
@ -46,4 +46,7 @@ class TestRepoInfo(unittest.TestCase):
webidx.repoinfo(self.environ, self.repo_id)
self.server.repoInfo.assert_called_once_with(int(self.repo_id), strict=False)
self.server.listBuildroots.assert_called_once_with(repoID=int(self.repo_id))
self.server.listBuildroots.assert_called_once_with(repoID=int(self.repo_id), queryOpts={'countOnly': True})
# the end

View file

@ -797,6 +797,10 @@ def taskinfo(environ, taskID):
else:
values['perms'] = []
values['requests'] = []
if task['method'] == 'newRepo':
values['requests'] = server.repo.queryQueue([['task_id', '=', task['id']]], ['id'])
values['koji'] = koji
values['S'] = SafeValue
@ -1051,8 +1055,10 @@ def taginfo(environ, tagID, all='0', packageOrder='package_name', packageStart=N
values['srcTargets'] = srcTargets
values['destTargets'] = destTargets
values['all'] = all
values['repo'] = server.getRepo(tag['id'], state=koji.REPO_READY)
values['repo'] = server.repo.get(tag['id'])
values['external_repos'] = server.getExternalRepoList(tag['id'])
values['request_count'] = server.repo.queryQueue([['tag_id', '=', tag['id']]],
opts={'countOnly': True})
child = None
if childID is not None:
@ -2696,13 +2702,72 @@ def repoinfo(environ, repoID):
else:
values['repo_json'] = os.path.join(
pathinfo.repo(repo_info['id'], repo_info['tag_name']), 'repo.json')
num_buildroots = len(server.listBuildroots(repoID=repoID)) or 0
values['numBuildroots'] = num_buildroots
num_buildroots = server.listBuildroots(repoID=repoID, queryOpts={'countOnly': True})
values['numBuildroots'] = num_buildroots
values['requests'] = server.repo.queryQueue([['repo_id', '=', repoID]], ['id'])
values['state_name'] = kojiweb.util.repoState(repo_info['state'])
values['create_time'] = kojiweb.util.formatTimeLong(repo_info['create_ts'])
return _genHTML(environ, 'repoinfo.html.j2')
def reporequest(environ, reqID):
values = _initValues(environ, 'Repo Request', 'tags')
server = _getServer(environ)
req_id = int(reqID)
values['req_id'] = req_id
rows = server.repo.queryQueue([['id', '=', req_id]], '**')
if not rows:
raise koji.GenericError("No such repo request: %s" % req_id)
req = rows[0]
if req['at_event']:
values['at_event'] = server.getEvent(req['at_event'])
elif req['min_event']:
values['min_event'] = server.getEvent(req['min_event'])
else:
# invalid, but technically not blocked in db
values['min_event'] = None
values['req'] = req
return _genHTML(environ, 'reporequest.html.j2')
def reporequests(environ, active="true", tag=None, start=None, order=None):
values = _initValues(environ, 'Repo Requests', 'tags')
server = _getServer(environ)
clauses = []
desc_parts = []
if active.lower() == 'all':
desc_parts.append('Recent repo requests')
elif active.lower() in ('false', 'no', '0'):
desc_parts.append('Inactive repo requests')
clauses.append(["active", False])
active = 'false'
else:
desc_parts.append('Active repo requests')
clauses.append(["active", True])
active = 'true'
if tag:
taginfo = server.getTag(_convert_if_int(tag), strict=True, event='auto')
clauses.append(["tag_id", taginfo['id']])
desc_parts.append('for tag %(name)s' % taginfo)
tag = taginfo['name']
else:
tag = None
if order is None:
order = '-id'
values['desc'] = ' '.join(desc_parts)
values['order'] = order
values['active'] = active
values['tag'] = tag
kojiweb.util.paginateMethod(server, values, 'repo.queryQueue',
args=(clauses, '**'),
start=start, dataName='reqs', prefix='req', order=order,
optsarg='opts')
return _genHTML(environ, 'reporequests.html.j2')
def activesession(environ, start=None, order=None):
values = _initValues(environ, 'Active sessions', 'activesession')
server = _getServer(environ)

View file

@ -7,16 +7,35 @@
<tr><th>ID</th><td>{{ repo.id }}</td><th></tr>
<tr><th>Tag</th><td><a href="taginfo?tagID={{ repo.tag_id }}">{{ repo.tag_name }}</a></td></tr>
{% if repo.task_id %}
<tr><th>Task ID</th><td><a href="taskinfo?taskID={{ repo.task_id }}">{{ repo.task_id }}</a></td></tr>
<tr><th>Task ID</th><td><a href="taskinfo?taskID={{ repo.task_id }}">{{ repo.task_id }}</a> ({{ util.taskState(repo.task_state) }})</td></tr>
{% endif %}
<tr><th>State</th><td class="repo{{ state_name }}">{{ state_name }}</td></tr>
<tr><th>Event</th><td>{{ repo.create_event }} ({{ create_time }})</td></tr>
<tr><th>Created</th><td>{{ util.formatTimeLong(repo.creation_ts) }}</td></tr>
<tr><th>State changed</th><td>{{ util.formatTimeLong(repo.state_ts) }}</td></tr>
<tr><th>Created from Event</th><td>{{ repo.create_event }} ({{ util.formatTimeLong(repo.create_ts) }})</td></tr>
{%- if repo.begin_event %}
{%- if repo.end_event %}
<tr><th>Event range</th><td>{{ repo.begin_event }} ... {{ repo.end_event }}</td></tr>
{%- else %}
<tr><th>Event range</th><td>{{ repo.begin_event }} ... </td></tr>
{%- endif %}
{%- endif %}
{%- if state_name != 'deleted' %}
<tr><th>URL</th><td><a href="{{ url }}">repodata</a></td></tr>
<tr><th>Repo json</th><td><a href="{{ repo_json }}">repo.json</a></td></tr>
{%- endif %}
#if repo.custom_opts
<th>Custom Opts</th><td class="usertext">{{ json.dumps(repo.custom_opts, indent=4) }}</td>
#endif
<tr><th>Dist repo?</th><td class="{{ repo.dist | lower }}">{{ 'yes' if repo.dist else 'no' }}</td></tr>
<tr><th>Number of buildroots: </th><td><a href="buildroots?repoID={{ repo.id }}">{{ numBuildroots }}</a></td></tr>
#if requests
<tr><th>Fulfills requests:</th><td>
#for req in requests
<a href="reporequest?reqID={{ req.id }}">{{ req.id }}</a>
#endfor
</td/></tr>
#endif
</table>
{% else %}
Repo {{ repo_id }} not found.

View file

@ -0,0 +1,38 @@
#include "header.html.j2"
<h4>Information for repo request {{ req_id }}</h4>
#if not req
Repo request {{ req_id }} not found.
#else
<table>
<tr><th>ID</th><td>{{ req.id }}</td></tr>
<tr><th>Active</th><td>{{ req.active }}</td></tr>
<tr><th>Priority</th><td>{{ req.priority }}</td></tr>
<tr><th>Tag</th><td><a href="taginfo?tagID={{ req.tag_id }}">{{ req.tag_name }}</a></td></tr>
#if req.at_event
<tr><th>At specific event</th><td>{{ at_event.id }} ({{ util.formatTimeLong(at_event.ts) }})</td></tr>
#elif req.min_event
<tr><th>Minimum event</th><td>{{ min_event.id }} ({{ util.formatTimeLong(min_event.ts) }})</td></tr>
#else
<tr><th>Invalid event</th><td>Unable to determine event for request</td></tr>
#endif
#if req.opts
<th>Options</th><td class="usertext">{{ req.opts | tojson(indent=4) }}</td>
#endif
#if req.repo_id
<tr><th>Fulfilled by repo</th><td><a href="repoinfo?repoID={{ req.repo_id }}">{{ req.repo_id }}</a></td></tr>
#endif
#if req.task_id
<tr><th>Task ID</th><td><a href="taskinfo?taskID={{ req.task_id }}">{{ req.task_id }}</a> ({{ util.taskState(req.task_state) }})</td></tr>
<tr><th>Tries</th><td>{{ req.tries }}</td></tr>
#endif
<tr><th>Owner</th><td><a href="userinfo?userID={{ req.owner }}">{{ req.owner_name }}</a></td></tr>
<tr><th>Created</th><td>{{ util.formatTimeLong(req.create_ts) }}</td></tr>
<tr><th>Updated</th><td>{{ util.formatTimeLong(req.update_ts) }}</td></tr>
</table>
#endif
#include "footer.html.j2"

View file

@ -0,0 +1,127 @@
#include "header.html.j2"
# from "macros.html.j2" import rowToggle
#set Pvars = ('active', 'tag', 'order')
#set P = util.passthrough
<h4>{{ desc }}</h4>
<table class="data-list">
<tr>
<td colspan="6">
<form action="">
<table class="nested">
<tr><td>
<strong>Active</strong>:
</td><td>
<select name="active" class="filterlist" onchange="javascript: window.location = 'reporequests?active=' + this.value + '{{ P('tag', 'order') }}';">
<option value="true" {{ 'selected' if active == 'true' else '' }}>true</option>
<option value="false" {{ 'selected' if active == 'false' else '' }}>false</option>
<option value="all" {{ 'selected' if active == 'all' else '' }}>all</option>
</select>
</td>
<td>
<strong>Tag</strong>:
</td><td>
<input type="text" name="tag" value="{{ tag if tag else '' }}"/>
</td>
</tr>
</table>
</form>
</td>
</tr>
<tr>
<td class="paginate" colspan="6">
#if (reqPages |length) > 1
<form class="pageJump" action="">
Page:
<select onchange="javascript: window.location = 'reporequests?start=' + this.value * {{ reqRange }} + '{{ P(*Pvars) }}';">
#for pageNum in reqPages
<option value="{{ pageNum }}"{{ ' selected' if pageNum == reqCurrentPage else '' }}>{{ pageNum + 1 }}</option>
#endfor
</select>
</form>
#endif
#if reqStart > 0
<a href="reporequests?start={{ reqStart - reqRange }}{{ P(*Pvars) }}">&lt;&lt;&lt;</a>
#endif
#if totalReqs != 0
<strong>Requests {{ reqStart + 1 }} through {{ reqStart + reqCount }} of {{ totalReqs }}</strong>
#endif
#if reqStart + reqCount < totalReqs
<a href="reporequests?start={{ reqStart + reqRange }}{{ P(*Pvars) }}">&gt;&gt;&gt;</a>
#endif
</td>
</tr>
<tr class="list-header">
<th><a href="reporequests?{{ P(*Pvars, toggleOrder='id', prefix='') }}">ID</a> {{ util.sortImage('id') }}</th>
<th><a href="reporequests?{{ P(*Pvars, toggleOrder='priority', prefix='') }}">Priority</a> {{ util.sortImage('priority') }}</th>
<th><a href="reporequests?{{ P(*Pvars, toggleOrder='tag_name', prefix='') }}">Tag</a> {{ util.sortImage('tag_name') }}</th>
<th>Task</th>
<th>Repo</th>
<th>Status</th>
</tr>
#if (reqs |length) > 0
#for req in reqs
<tr class="{{ rowToggle(loop) }}">
<td><a href="reporequest?reqID={{ req.id }}">{{ req.id }}</a></td>
<td>{{ req.priority }}</td>
<td>
<a href="taginfo?tagID={{ req.tag_id }}">{{ req.tag_name }}</a>
#if not tag
<a href="reporequests?tag={{ req.tag_id }}{{ P('active', 'order') }}" title="Filter by tag"><img src="{{ util.themePath('images/funnel.svg') }}" alt="^" /></a>
#endif
</td>
#if req.task_id
<td><a href="taskinfo?taskID={{ req.task_id }}">{{ req.task_id }}</a></td>
#else
<td>...</td>
#endif
#if req.repo_id
<td><a href="repoinfo?repoID={{ req.repo_id }}">{{ req.repo_id }}</a></td>
#else
<td>...</td>
#endif
<td>
## simulate a more helpful status
#if req.active
{{ util.imageTag('waiting') }}
#elif req.repo_id
{{ util.imageTag('yes') }}
#else
{{ util.imageTag('no') }}
#endif
</td>
</tr>
#endfor
#else
<tr class="row-odd">
<td colspan="2">No repo requests</td>
</tr>
#endif
<tr>
<td class="paginate" colspan="2">
#if (reqPages |length) > 1
<form class="pageJump" action="">
Page:
<select onchange="javascript: window.location = 'reporequests?start=' + this.value * {{ reqRange }} + '{{ P(*Pvars) }}';">
#for pageNum in reqPages
<option value="{{ pageNum }}"{{ ' selected' if pageNum == reqCurrentPage else '' }}>{{ pageNum + 1 }}</option>
#endfor
</select>
</form>
#endif
#if reqStart > 0
<a href="reporequests?start={{ reqStart - reqRange }}{{ P(*Pvars) }}">&lt;&lt;&lt;</a>
#endif
#if totalReqs != 0
<strong>Reqs {{ reqStart + 1 }} through {{ reqStart + reqCount }} of {{ totalReqs }}</strong>
#endif
#if reqStart + reqCount < totalReqs
<a href="reporequests?start={{ reqStart + reqRange }}{{ P(*Pvars) }}">&gt;&gt;&gt;</a>
#endif
</td>
</tr>
</table>
#include "footer.html.j2"

View file

@ -117,6 +117,10 @@
#endif
</td>
</tr>
<tr>
<th>Repo&nbsp;requests</th>
<td><a href="reporequests?active=all&tag={{ tag.id }}">{{ request_count }}</a></td>
</tr>
<tr>
<th>Packages</th>
<td><a href="packages?blocked=0&tagID={{ tag.id }}">{{ numPackages }}</a></td>

View file

@ -140,6 +140,14 @@ None
</tr>
#endfor
#endif
#if requests
<tr><th>For request:</th><td>
## we only expect one, but if we get more print them all
#for req in requests
<a href="reporequest?reqID={{ req.id }}">{{ req.id }}</a>
#endfor
</td/></tr>
#endif
<tr>
<th>Created</th><td>{{ util.formatTimeLong(task.create_ts) }}</td>
</tr>

View file

@ -353,17 +353,17 @@ def _sortImage(orderVal, sortKey, orderVar):
@safe_return
@pass_context
def passthrough(context, *varnames, prefix='&', invert=False):
def passthrough(context, *varnames, prefix='&', invert=False, toggleOrder=None):
if invert:
_PASSTHROUGH = context.get('_PASSTHROUGH', None)
if _PASSTHROUGH is None:
raise Exception('template does not define _PASSTHROUGH')
varnames = {n for n in _PASSTHROUGH if n not in varnames}
data = {n: context.get(n, default=None) for n in varnames}
return _passthrough(data, prefix)
return _passthrough(data, prefix, toggleOrder)
def _passthrough(data, prefix='&'):
def _passthrough(data, prefix='&', toggleOrder=None):
"""
Construct a url parameter string from template vars
@ -380,6 +380,11 @@ def _passthrough(data, prefix='&'):
result = []
for var in sorted(data):
value = data[var]
if var == 'order' and toggleOrder is not None:
if value == toggleOrder:
value = '-' + value
else:
value = toggleOrder
if value is not None:
if isinstance(value, str):
if value.isdigit():
@ -396,7 +401,7 @@ def _passthrough(data, prefix='&'):
@pass_context
def passthrough_except(context, *exclude, prefix='&'):
def passthrough_except(context, *exclude, prefix='&', toggleOrder=None):
"""
Construct a string suitable for use as URL
parameters. The template calling this method must have
@ -408,7 +413,7 @@ def passthrough_except(context, *exclude, prefix='&'):
"""
# note that we have to pass context ourselves here
# the decorator only works when called directly from the template
return passthrough(context, *exclude, prefix=prefix, invert=True)
return passthrough(context, *exclude, prefix=prefix, invert=True, toggleOrder=toggleOrder)
def sortByKeyFuncNoneGreatest(key):
@ -459,7 +464,7 @@ def paginateList(values, data, start, dataName, prefix=None, order=None, noneGre
def paginateMethod(server, values, methodName, args=None, kw=None,
start=None, dataName=None, prefix=None, order=None, pageSize=50,
first_page_count=True):
first_page_count=True, optsarg='queryOpts'):
"""Paginate the results of the method with the given name when called with the given args and
kws. The method must support the queryOpts keyword parameter, and pagination is done in the
database.
@ -483,12 +488,12 @@ def paginateMethod(server, values, methodName, args=None, kw=None,
if start == 0 and not first_page_count:
totalRows = None
else:
kw['queryOpts'] = {'countOnly': True}
kw[optsarg] = {'countOnly': True}
totalRows = getattr(server, methodName)(*args, **kw)
kw['queryOpts'] = {'order': order,
'offset': start,
'limit': pageSize}
kw[optsarg] = {'order': order,
'offset': start,
'limit': pageSize}
data = getattr(server, methodName)(*args, **kw)
count = len(data)

View file

@ -1,5 +1,5 @@
SERVERDIR = /images
FILES = $(wildcard *.gif *.png *.ico)
FILES = $(wildcard *.gif *.png *.ico *.svg)
_default:
@echo "nothing to make. try make install"

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="16px" width="16px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 485.008 485.008" xml:space="preserve">
<g>
<g>
<path d="M171.501,464.698v-237.9l-166.3-192.6c-8.9-10.9-7.9-33.3,15.1-33.3h443.6c21.6,0,26.6,19.8,15.1,33.3l-162.3,187.5v147.2
c0,6-2,11.1-7.1,15.1l-103.8,95.8C193.801,488.698,171.501,483.898,171.501,464.698z M64.701,41.298l142.2,164.3c3,4,5,8.1,5,13.1
v200.6l64.5-58.5v-146.1c0-5,2-9.1,5-13.1l138.1-160.3L64.701,41.298L64.701,41.298z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 716 B