feat: display stats about total & active TUs on proposals

This patch brings in two new features:
- when viewing proposal listings, there is a new Statistics section,
  containing the total and active number of Trusted Users found in the
  database.
- when viewing a proposal directly, the number of active trusted users
  assigned when the proposal was added is now displayed in the details
  section.

Closes #323

Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2022-03-08 20:28:09 -08:00
parent 0afa07ed3b
commit 49c5a3facf
No known key found for this signature in database
GPG key ID: F7E46DED420788F3
6 changed files with 107 additions and 0 deletions

View file

@ -2,6 +2,7 @@ import html
import typing import typing
from http import HTTPStatus from http import HTTPStatus
from typing import Any, Dict
from fastapi import APIRouter, Form, HTTPException, Request from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, Response from fastapi.responses import RedirectResponse, Response
@ -33,6 +34,21 @@ ADDVOTE_SPECIFICS = {
} }
def populate_trusted_user_counts(context: Dict[str, Any]) -> None:
tu_query = db.query(User).filter(
or_(User.AccountTypeID == TRUSTED_USER_ID,
User.AccountTypeID == TRUSTED_USER_AND_DEV_ID)
)
context["trusted_user_count"] = tu_query.count()
# In case any records have a None InactivityTS.
active_tu_query = tu_query.filter(
or_(User.InactivityTS.is_(None),
User.InactivityTS == 0)
)
context["active_trusted_user_count"] = active_tu_query.count()
@router.get("/tu") @router.get("/tu")
@requires_auth @requires_auth
async def trusted_user(request: Request, async def trusted_user(request: Request,
@ -40,6 +56,8 @@ async def trusted_user(request: Request,
cby: str = "desc", # current by cby: str = "desc", # current by
poff: int = 0, # past offset poff: int = 0, # past offset
pby: str = "desc"): # past by pby: str = "desc"): # past by
""" Proposal listings. """
if not request.user.has_credential(creds.TU_LIST_VOTES): if not request.user.has_credential(creds.TU_LIST_VOTES):
return RedirectResponse("/", status_code=HTTPStatus.SEE_OTHER) return RedirectResponse("/", status_code=HTTPStatus.SEE_OTHER)
@ -102,6 +120,8 @@ async def trusted_user(request: Request,
context["current_by_next"] = "asc" if current_by == "desc" else "desc" context["current_by_next"] = "asc" if current_by == "desc" else "desc"
context["past_by_next"] = "asc" if past_by == "desc" else "desc" context["past_by_next"] = "asc" if past_by == "desc" else "desc"
populate_trusted_user_counts(context)
context["q"] = { context["q"] = {
"coff": current_off, "coff": current_off,
"cby": current_by, "cby": current_by,

View file

@ -2334,3 +2334,7 @@ msgid "This action will close any pending package requests "
"related to it. If %sComments%s are omitted, a closure " "related to it. If %sComments%s are omitted, a closure "
"comment will be autogenerated." "comment will be autogenerated."
msgstr "" msgstr ""
#: templates/partials/tu/proposal/details.html
msgid "assigned"
msgstr ""

View file

@ -21,6 +21,11 @@
</strong> </strong>
</div> </div>
<div class="field">
{{ "Active" | tr }} {{ "Trusted Users" | tr }} {{ "assigned" | tr }}:
{{ voteinfo.ActiveTUs }}
</div>
{% set submitter = voteinfo.Submitter.Username %} {% set submitter = voteinfo.Submitter.Username %}
{% set submitter_uri = "/account/%s" | format(submitter) %} {% set submitter_uri = "/account/%s" | format(submitter) %}
{% set submitter = '<a href="%s">%s</a>' | format(submitter_uri, submitter) %} {% set submitter = '<a href="%s">%s</a>' | format(submitter_uri, submitter) %}

View file

@ -1,6 +1,22 @@
{% extends "partials/layout.html" %} {% extends "partials/layout.html" %}
{% block pageContent %} {% block pageContent %}
<div class="box">
<h2>{{ "Statistics" | tr }}</h2>
<table class="no-width">
<tbody>
<tr>
<td class="text-right">{{ "Total" | tr }} {{ "Trusted Users" | tr }}:</td>
<td>{{ trusted_user_count }}</td>
</tr>
<tr>
<td class="text-right">{{ "Active" | tr }} {{ "Trusted Users" | tr }}:</td>
<td>{{ active_trusted_user_count }}</td>
</tr>
</tbody>
</table>
</div>
{% {%
with table_class = "current-votes", with table_class = "current-votes",
total_votes = current_votes_count, total_votes = current_votes_count,

View file

@ -267,6 +267,48 @@ def test_tu_index(client, tu_user):
assert int(vote_id.text.strip()) == vote_records[1].ID assert int(vote_id.text.strip()) == vote_records[1].ID
def test_tu_stats(client: TestClient, tu_user: User):
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
response = request.get("/tu", cookies=cookies, allow_redirects=False)
assert response.status_code == HTTPStatus.OK
root = parse_root(response.text)
stats = root.xpath('//table[@class="no-width"]')[0]
rows = stats.xpath("./tbody/tr")
# We have one trusted user.
total = rows[0]
label, count = total.xpath("./td")
assert int(count.text.strip()) == 1
# And we have one active TU.
active = rows[1]
label, count = active.xpath("./td")
assert int(count.text.strip()) == 1
with db.begin():
tu_user.InactivityTS = time.utcnow()
with client as request:
response = request.get("/tu", cookies=cookies, allow_redirects=False)
assert response.status_code == HTTPStatus.OK
root = parse_root(response.text)
stats = root.xpath('//table[@class="no-width"]')[0]
rows = stats.xpath("./tbody/tr")
# We have one trusted user.
total = rows[0]
label, count = total.xpath("./td")
assert int(count.text.strip()) == 1
# But we have no more active TUs.
active = rows[1]
label, count = active.xpath("./td")
assert int(count.text.strip()) == 0
def test_tu_index_table_paging(client, tu_user): def test_tu_index_table_paging(client, tu_user):
ts = time.utcnow() ts = time.utcnow()
@ -515,6 +557,8 @@ def test_tu_proposal_unauthorized(client: TestClient, user: User,
def test_tu_running_proposal(client: TestClient, def test_tu_running_proposal(client: TestClient,
proposal: Tuple[User, User, TUVoteInfo]): proposal: Tuple[User, User, TUVoteInfo]):
tu_user, user, voteinfo = proposal tu_user, user, voteinfo = proposal
with db.begin():
voteinfo.ActiveTUs = 1
# Initiate an authenticated GET request to /tu/{proposal_id}. # Initiate an authenticated GET request to /tu/{proposal_id}.
proposal_id = voteinfo.ID proposal_id = voteinfo.ID
@ -536,6 +580,11 @@ def test_tu_running_proposal(client: TestClient,
'./div[contains(@class, "user")]/strong/a/text()')[0] './div[contains(@class, "user")]/strong/a/text()')[0]
assert username.strip() == user.Username assert username.strip() == user.Username
active = details.xpath('./div[contains(@class, "field")]')[1]
content = active.text.strip()
assert "Active Trusted Users assigned:" in content
assert "1" in content
submitted = details.xpath( submitted = details.xpath(
'./div[contains(@class, "submitted")]/text()')[0] './div[contains(@class, "submitted")]/text()')[0]
assert re.match(r'^Submitted: \d{4}-\d{2}-\d{2} \d{2}:\d{2} \(.+\) by$', assert re.match(r'^Submitted: \d{4}-\d{2}-\d{2} \d{2}:\d{2} \(.+\) by$',

View file

@ -282,3 +282,16 @@ pre.traceback {
white-space: -o-pre-wrap; white-space: -o-pre-wrap;
word-wrap: break-all; word-wrap: break-all;
} }
/* A text aligning alias. */
.text-right {
text-align: right;
}
/* By default, tables use 100% width, which we do not always want. */
table.no-width {
width: auto;
}
table.no-width > tbody > tr > td {
padding-right: 2px;
}