add /tu/ (get) index

This commit implements the '/tu' Trusted User index page.

In addition to this functionality, this commit introduces
the following jinja2 filters:

- dt: util.timestamp_to_datetime
- as_timezone: util.as_timezone
- dedupe_qs: util.dedupe_qs
- urlencode: urllib.parse.quote_plus

There's also a new decorator that can be used to enforce
permissions: `account_type_required`. If a user does not
meet account type requirements, they are redirected to '/'.

```
@auth_required(True)
@account_type_required({"Trusted User"})
async def some_route(request: fastapi.Request):
    return Response("You are a Trusted User!")
```

Routes added:

- `GET /tu`: aurweb.routers.trusted_user.trusted_user

Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2021-06-18 04:33:48 -07:00
parent a6bba601a9
commit d674aaf736
10 changed files with 808 additions and 3 deletions

View file

@ -16,7 +16,7 @@ from aurweb.auth import BasicAuthBackend
from aurweb.db import get_engine, query from aurweb.db import get_engine, query
from aurweb.models.accepted_term import AcceptedTerm from aurweb.models.accepted_term import AcceptedTerm
from aurweb.models.term import Term from aurweb.models.term import Term
from aurweb.routers import accounts, auth, errors, html, sso from aurweb.routers import accounts, auth, errors, html, sso, trusted_user
# Setup the FastAPI app. # Setup the FastAPI app.
app = FastAPI(exception_handlers=errors.exceptions) app = FastAPI(exception_handlers=errors.exceptions)
@ -47,6 +47,7 @@ async def app_startup():
app.include_router(html.router) app.include_router(html.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(accounts.router) app.include_router(accounts.router)
app.include_router(trusted_user.router)
# Initialize the database engine and ORM. # Initialize the database engine and ORM.
get_engine() get_engine()

View file

@ -3,6 +3,8 @@ import functools
from datetime import datetime from datetime import datetime
from http import HTTPStatus from http import HTTPStatus
import fastapi
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import and_ from sqlalchemy import and_
from starlette.authentication import AuthCredentials, AuthenticationBackend from starlette.authentication import AuthCredentials, AuthenticationBackend
@ -11,6 +13,7 @@ from starlette.requests import HTTPConnection
import aurweb.config import aurweb.config
from aurweb import l10n, util from aurweb import l10n, util
from aurweb.models.account_type import ACCOUNT_TYPE_ID
from aurweb.models.session import Session from aurweb.models.session import Session
from aurweb.models.user import User from aurweb.models.user import User
from aurweb.templates import make_variable_context, render_template from aurweb.templates import make_variable_context, render_template
@ -152,6 +155,42 @@ def auth_required(is_required: bool = True,
return decorator return decorator
def account_type_required(one_of: set):
""" A decorator that can be used on FastAPI routes to dictate
that a user belongs to one of the types defined in one_of.
This decorator should be run after an @auth_required(True) is
dictated.
- Example code:
@router.get('/some_route')
@auth_required(True)
@account_type_required({"Trusted User", "Trusted User & Developer"})
async def some_route(request: fastapi.Request):
return Response()
:param one_of: A set consisting of strings to match against AccountType.
:return: Return the FastAPI function this decorator wraps.
"""
# Convert any account type string constants to their integer IDs.
one_of = {
ACCOUNT_TYPE_ID[atype]
for atype in one_of
if isinstance(atype, str)
}
def decorator(func):
@functools.wraps(func)
async def wrapper(request: fastapi.Request, *args, **kwargs):
if request.user.AccountType.ID not in one_of:
return RedirectResponse("/",
status_code=int(HTTPStatus.SEE_OTHER))
return await func(request, *args, **kwargs)
return wrapper
return decorator
CRED_ACCOUNT_CHANGE_TYPE = 1 CRED_ACCOUNT_CHANGE_TYPE = 1
CRED_ACCOUNT_EDIT = 2 CRED_ACCOUNT_EDIT = 2
CRED_ACCOUNT_EDIT_DEV = 3 CRED_ACCOUNT_EDIT_DEV = 3

View file

@ -3,6 +3,11 @@ from sqlalchemy import Column, Integer
from aurweb import db from aurweb import db
from aurweb.models.declarative import Base from aurweb.models.declarative import Base
USER = "User"
TRUSTED_USER = "Trusted User"
DEVELOPER = "Developer"
TRUSTED_USER_AND_DEV = "Trusted User & Developer"
class AccountType(Base): class AccountType(Base):
""" An ORM model of a single AccountTypes record. """ """ An ORM model of a single AccountTypes record. """

View file

@ -0,0 +1,97 @@
from datetime import datetime
from urllib.parse import quote_plus
from fastapi import APIRouter, Request
from sqlalchemy import and_, or_
from aurweb import db
from aurweb.auth import account_type_required, auth_required
from aurweb.models.account_type import DEVELOPER, TRUSTED_USER, TRUSTED_USER_AND_DEV
from aurweb.models.tu_vote import TUVote
from aurweb.models.tu_voteinfo import TUVoteInfo
from aurweb.models.user import User
from aurweb.templates import make_context, render_template
router = APIRouter()
# Some TU route specific constants.
ITEMS_PER_PAGE = 10 # Paged table size.
MAX_AGENDA_LENGTH = 75 # Agenda table column length.
# A set of account types that will approve a user for TU actions.
REQUIRED_TYPES = {
TRUSTED_USER,
DEVELOPER,
TRUSTED_USER_AND_DEV
}
@router.get("/tu")
@auth_required(True, redirect="/")
@account_type_required(REQUIRED_TYPES)
async def trusted_user(request: Request,
coff: int = 0, # current offset
cby: str = "desc", # current by
poff: int = 0, # past offset
pby: str = "desc"): # past by
context = make_context(request, "Trusted User")
current_by, past_by = cby, pby
current_off, past_off = coff, poff
context["pp"] = pp = ITEMS_PER_PAGE
context["prev_len"] = MAX_AGENDA_LENGTH
ts = int(datetime.utcnow().timestamp())
if current_by not in {"asc", "desc"}:
# If a malicious by was given, default to desc.
current_by = "desc"
context["current_by"] = current_by
if past_by not in {"asc", "desc"}:
# If a malicious by was given, default to desc.
past_by = "desc"
context["past_by"] = past_by
current_votes = db.query(TUVoteInfo, TUVoteInfo.End > ts).order_by(
TUVoteInfo.Submitted.desc())
context["current_votes_count"] = current_votes.count()
current_votes = current_votes.limit(pp).offset(current_off)
context["current_votes"] = reversed(current_votes.all()) \
if current_by == "asc" else current_votes.all()
context["current_off"] = current_off
past_votes = db.query(TUVoteInfo, TUVoteInfo.End <= ts).order_by(
TUVoteInfo.Submitted.desc())
context["past_votes_count"] = past_votes.count()
past_votes = past_votes.limit(pp).offset(past_off)
context["past_votes"] = reversed(past_votes.all()) \
if past_by == "asc" else past_votes.all()
context["past_off"] = past_off
# TODO
# We order last votes by TUVote.VoteID and User.Username.
# This is really bad. We should add a Created column to
# TUVote of type Timestamp and order by that instead.
last_votes_by_tu = db.query(TUVote).filter(
and_(TUVote.VoteID == TUVoteInfo.ID,
TUVoteInfo.End <= ts,
TUVote.UserID == User.ID,
or_(User.AccountTypeID == 2,
User.AccountTypeID == 4))
).group_by(User.ID).order_by(
TUVote.VoteID.desc(), User.Username.asc())
context["last_votes_by_tu"] = last_votes_by_tu.all()
context["current_by_next"] = "asc" if current_by == "desc" else "desc"
context["past_by_next"] = "asc" if past_by == "desc" else "desc"
context["q"] = '&'.join([
f"coff={current_off}",
f"cby={quote_plus(current_by)}",
f"poff={past_off}",
f"pby={quote_plus(past_by)}"
])
return render_template(request, "tu/index.html", context)

View file

@ -7,11 +7,29 @@
{% endif %} {% endif %}
<li><a href="/packages/">{% trans %}Packages{% endtrans %}</a></li> <li><a href="/packages/">{% trans %}Packages{% endtrans %}</a></li>
{% if request.user.is_authenticated() %} {% if request.user.is_authenticated() %}
{% if request.user.is_trusted_user() or request.user.is_developer() %}
<li>
<a href="/requests/">{% trans %}Requests{% endtrans %}</a>
</li>
<li>
<a href="/accounts/">{% trans %}Accounts{% endtrans %}</a>
</li>
{% endif %}
<li> <li>
<a href="/account/{{ request.user.Username }}/edit"> <a href="/account/{{ request.user.Username }}/edit">
{% trans %}My Account{% endtrans %} {% trans %}My Account{% endtrans %}
</a> </a>
</li> </li>
{% if request.user.is_trusted_user() %}
<li>
<a href="/tu">{% trans %}Trusted User{% endtrans %}</a>
</li>
{% endif %}
<li> <li>
<a href="/logout/?next={{ next }}"> <a href="/logout/?next={{ next }}">
{% trans %}Logout{% endtrans %} {% trans %}Logout{% endtrans %}

View file

@ -0,0 +1,33 @@
<div class="box">
<h2>{% trans %}{{ title }}{% endtrans %}</h2>
<table class="results last-votes">
<thead>
<th>{{ "User" | tr }}</th>
<th>{{ "Last vote" | tr }}</th>
</thead>
<tbody>
{% if not votes %}
<tr>
<td align="center" colspan="0">
{{ "No results found." | tr }}
</td>
<td></td>
</tr>
{% else %}
{% for vote in votes %}
<tr>
<td>{{ vote.User.Username }}</td>
<td>
<a href="/tu/{{ vote.VoteID }}">
{{ vote.VoteID }}
</a>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>

View file

@ -0,0 +1,120 @@
<div class="box">
<h2>{% trans %}{{ title }}{% endtrans %}</h2>
{% if title == "Current Votes" %}
<ul class="admin-actions">
<li>
<a href="/addvote/">
{% trans %}Add Proposal{% endtrans %}
</a>
</li>
</ul>
{% endif %}
{% if not results %}
<p>
{% trans %}No results found.{% endtrans %}
</p>
{% else %}
<table class="results {{ table_class }}">
<thead>
<tr>
<th>{{ "Proposal" | tr }}</th>
<th>
{% set off_qs = "%s=%d" | format(off_param, off) %}
{% set by_qs = "%s=%s" | format(by_param, by_next | urlencode) %}
<a href="?{{ q | dedupe_qs(off_qs, by_qs) }}">
{{ "Start" | tr }}
</a>
</th>
<th>{{ "End" | tr }}</th>
<th>{{ "User" | tr }}</th>
{% if title != "Current Votes" %}
<th>{{ "Yes" | tr }}</th>
<th>{{ "No" | tr }}</th>
{% endif %}
<th>{{ "Voted" | tr }}</th>
</tr>
</thead>
<tbody>
{% for result in results %}
<tr>
<td>
<!-- Truncate the agenda back to prev_len. -->
{% set agenda = result.Agenda[:prev_len] %}
<a href="/tu/{{ result.ID }}">{{ agenda }}</a>
</td>
<!-- Convert result.Submitted (timestamp) to datetime,
then apply the request's timezone to it. -->
{% set submitted = result.Submitted | dt | as_timezone(timezone) %}
<td>{{ submitted.strftime("%Y-%m-%d") }}</td>
<!-- Convert result.End (timestamp) to datetime,
then apply the request's timezone to it. -->
{% set end = result.End | dt | as_timezone(timezone) %}
<td>{{ end.strftime("%Y-%m-%d") }}</td>
<td>
{% if not result.User %}
N/A
{% else %}
<a href="/packages/?K={{ result.User }}&amp;SeB=m">
{{ result.User }}
</a>
{% endif %}
</td>
{% if title != "Current Votes" %}
<td>{{ result.Yes }}</td>
<td>{{ result.No }}</td>
{% endif %}
{% set vote = (result | get_vote(request)) %}
<td>
{% if vote %}
<span style="color: green; font-weight: bold">
{{ "Yes" | tr }}
</span>
{% else %}
<span style="color: red; font-weight: bold">
{{ "No" | tr }}
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pkglist-stats">
<p class="pkglist-nav">
{% if total_votes > pp %}
{% if off > 0 %}
{% set off_qs = "%s=%d" | format(off_param, off - 10) %}
{% set by_qs = "%s=%s" | format(by_param, by | urlencode) %}
<a class="page"
href="?{{ q | dedupe_qs(off_qs, by_qs) }}">
Back
</a>
{% endif %}
{% if off < total_votes - pp %}
{% set off_qs = "%s=%d" | format(off_param, off + 10) %}
{% set by_qs = "%s=%s" | format(by_param, by | urlencode) %}
<a class="page"
href="?{{ q | dedupe_qs(off_qs, by_qs) }}">
Next
</a>
{% endif %}
{% endif %}
</p>
</div>
{% endif %}
</div>

35
templates/tu/index.html Normal file
View file

@ -0,0 +1,35 @@
{% extends "partials/layout.html" %}
{% block pageContent %}
{%
with table_class = "current-votes",
total_votes = current_votes_count,
results = current_votes,
off_param = "coff",
by_param = "cby",
by_next = current_by_next,
title = "Current Votes",
off = current_off,
by = current_by
%}
{% include "partials/tu/proposals.html" %}
{% endwith %}
{%
with table_class = "past-votes",
total_votes = past_votes_count,
results = past_votes,
off_param = "poff",
by_param = "pby",
by_next = past_by_next,
title = "Past Votes",
off = past_off,
by = past_by
%}
{% include "partials/tu/proposals.html" %}
{% endwith %}
{% with title = "Last Votes by TU", votes = last_votes_by_tu %}
{% include "partials/tu/last_votes.html" %}
{% endwith %}
{% endblock %}

View file

@ -4,9 +4,9 @@ import pytest
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from aurweb.auth import BasicAuthBackend, has_credential from aurweb.auth import BasicAuthBackend, account_type_required, has_credential
from aurweb.db import create, query from aurweb.db import create, query
from aurweb.models.account_type import AccountType from aurweb.models.account_type import USER, USER_ID, AccountType
from aurweb.models.session import Session from aurweb.models.session import Session
from aurweb.models.user import User from aurweb.models.user import User
from aurweb.testing import setup_test_db from aurweb.testing import setup_test_db
@ -76,3 +76,17 @@ async def test_basic_auth_backend():
def test_has_fake_credential_fails(): def test_has_fake_credential_fails():
# Fake credential 666 does not exist. # Fake credential 666 does not exist.
assert not has_credential(user, 666) assert not has_credential(user, 666)
def test_account_type_required():
""" This test merely asserts that a few different paths
do not raise exceptions. """
# This one shouldn't raise.
account_type_required({USER})
# This one also shouldn't raise.
account_type_required({USER_ID})
# But this one should! We have no "FAKE" key.
with pytest.raises(KeyError):
account_type_required({'FAKE'})

View file

@ -0,0 +1,443 @@
import re
from datetime import datetime
from http import HTTPStatus
from io import StringIO
import lxml.etree
import pytest
from fastapi.testclient import TestClient
from aurweb import db
from aurweb.models.account_type import AccountType
from aurweb.models.tu_vote import TUVote
from aurweb.models.tu_voteinfo import TUVoteInfo
from aurweb.models.user import User
from aurweb.testing import setup_test_db
from aurweb.testing.requests import Request
DATETIME_REGEX = r'^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
def parse_root(html):
parser = lxml.etree.HTMLParser(recover=True)
tree = lxml.etree.parse(StringIO(html), parser)
return tree.getroot()
def get_table(root, class_name):
table = root.xpath(f'//table[contains(@class, "{class_name}")]')[0]
return table
def get_table_rows(table):
tbody = table.xpath("./tbody")[0]
return tbody.xpath("./tr")
def get_pkglist_directions(table):
stats = table.getparent().xpath("./div[@class='pkglist-stats']")[0]
nav = stats.xpath("./p[@class='pkglist-nav']")[0]
return nav.xpath("./a")
def get_a(node):
return node.xpath('./a')[0].text.strip()
def get_span(node):
return node.xpath('./span')[0].text.strip()
def assert_current_vote_html(row, expected):
columns = row.xpath("./td")
proposal, start, end, user, voted = columns
p, s, e, u, v = expected # Column expectations.
assert re.match(p, get_a(proposal)) is not None
assert re.match(s, start.text) is not None
assert re.match(e, end.text) is not None
assert re.match(u, get_a(user)) is not None
assert re.match(v, get_span(voted)) is not None
def assert_past_vote_html(row, expected):
columns = row.xpath("./td")
proposal, start, end, user, yes, no, voted = columns # Real columns.
p, s, e, u, y, n, v = expected # Column expectations.
assert re.match(p, get_a(proposal)) is not None
assert re.match(s, start.text) is not None
assert re.match(e, end.text) is not None
assert re.match(u, get_a(user)) is not None
assert re.match(y, yes.text) is not None
assert re.match(n, no.text) is not None
assert re.match(v, get_span(voted)) is not None
@pytest.fixture(autouse=True)
def setup():
setup_test_db("TU_Votes", "TU_VoteInfo", "Users")
@pytest.fixture
def client():
from aurweb.asgi import app
yield TestClient(app=app)
@pytest.fixture
def tu_user():
tu_type = db.query(AccountType,
AccountType.AccountType == "Trusted User").first()
yield db.create(User, Username="test_tu", Email="test_tu@example.org",
RealName="Test TU", Passwd="testPassword",
AccountType=tu_type)
@pytest.fixture
def user():
user_type = db.query(AccountType,
AccountType.AccountType == "User").first()
yield db.create(User, Username="test", Email="test@example.org",
RealName="Test User", Passwd="testPassword",
AccountType=user_type)
def test_tu_index_guest(client):
with client as request:
response = request.get("/tu", allow_redirects=False)
assert response.status_code == int(HTTPStatus.SEE_OTHER)
assert response.headers.get("location") == "/"
def test_tu_index_unauthorized(client, user):
cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request:
# Login as a normal user, not a TU.
response = request.get("/tu", cookies=cookies, allow_redirects=False)
assert response.status_code == int(HTTPStatus.SEE_OTHER)
assert response.headers.get("location") == "/"
def test_tu_empty_index(client, tu_user):
""" Check an empty index when we don't create any records. """
# Make a default get request to /tu.
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
response = request.get("/tu", cookies=cookies, allow_redirects=False)
assert response.status_code == int(HTTPStatus.OK)
# Parse lxml root.
root = parse_root(response.text)
# Check that .current-votes does not exist.
tables = root.xpath('//table[contains(@class, "current-votes")]')
assert len(tables) == 0
# Check that .past-votes has does not exist.
tables = root.xpath('//table[contains(@class, "current-votes")]')
assert len(tables) == 0
def test_tu_index(client, tu_user):
ts = int(datetime.utcnow().timestamp())
# Create some test votes: (Agenda, Start, End).
votes = [
("Test agenda 1", ts - 5, ts + 1000), # Still running.
("Test agenda 2", ts - 1000, ts - 5) # Not running anymore.
]
vote_records = []
for vote in votes:
agenda, start, end = vote
vote_records.append(
db.create(TUVoteInfo, Agenda=agenda,
User=tu_user.Username,
Submitted=start, End=end,
Quorum=0.0,
Submitter=tu_user))
# Vote on an ended proposal.
vote_record = vote_records[1]
vote_record.Yes += 1
vote_record.ActiveTUs += 1
db.create(TUVote, VoteInfo=vote_record, User=tu_user)
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
# Pass an invalid cby and pby; let them default to "desc".
response = request.get("/tu", cookies=cookies, params={
"cby": "BAD!",
"pby": "blah"
}, allow_redirects=False)
assert response.status_code == int(HTTPStatus.OK)
# Rows we expect to exist in HTML produced by /tu for current votes.
expected_rows = [
(
r'Test agenda 1',
DATETIME_REGEX,
DATETIME_REGEX,
tu_user.Username,
r'^(Yes|No)$'
)
]
# Assert that we are matching the number of current votes.
current_votes = [c for c in votes if c[2] > ts]
assert len(current_votes) == len(expected_rows)
# Parse lxml.etree root.
root = parse_root(response.text)
table = get_table(root, "current-votes")
rows = get_table_rows(table)
for i, row in enumerate(rows):
assert_current_vote_html(row, expected_rows[i])
# Assert that we are matching the number of past votes.
past_votes = [c for c in votes if c[2] <= ts]
assert len(past_votes) == len(expected_rows)
# Rows we expect to exist in HTML produced by /tu for past votes.
expected_rows = [
(
r'Test agenda 2',
DATETIME_REGEX,
DATETIME_REGEX,
tu_user.Username,
r'^\d+$',
r'^\d+$',
r'^(Yes|No)$'
)
]
table = get_table(root, "past-votes")
rows = get_table_rows(table)
for i, row in enumerate(rows):
assert_past_vote_html(row, expected_rows[i])
# Get the .last-votes table and check that our vote shows up.
table = get_table(root, "last-votes")
rows = get_table_rows(table)
assert len(rows) == 1
# Check to see the rows match up to our user and related vote.
username, vote_id = rows[0]
vote_id = vote_id.xpath("./a")[0]
assert username.text.strip() == tu_user.Username
assert int(vote_id.text.strip()) == vote_records[1].ID
def test_tu_index_table_paging(client, tu_user):
ts = int(datetime.utcnow().timestamp())
for i in range(25):
# Create 25 current votes.
db.create(TUVoteInfo, Agenda=f"Agenda #{i}",
User=tu_user.Username,
Submitted=(ts - 5), End=(ts + 1000),
Quorum=0.0,
Submitter=tu_user, autocommit=False)
for i in range(25):
# Create 25 past votes.
db.create(TUVoteInfo, Agenda=f"Agenda #{25 + i}",
User=tu_user.Username,
Submitted=(ts - 1000), End=(ts - 5),
Quorum=0.0,
Submitter=tu_user, autocommit=False)
db.commit()
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
response = request.get("/tu", cookies=cookies, allow_redirects=False)
assert response.status_code == int(HTTPStatus.OK)
# Parse lxml.etree root.
root = parse_root(response.text)
table = get_table(root, "current-votes")
rows = get_table_rows(table)
assert len(rows) == 10
def make_expectation(offset, i):
return [
f"Agenda #{offset + i}",
DATETIME_REGEX,
DATETIME_REGEX,
tu_user.Username,
r'^(Yes|No)$'
]
for i, row in enumerate(rows):
assert_current_vote_html(row, make_expectation(0, i))
# Parse out Back/Next buttons.
directions = get_pkglist_directions(table)
assert len(directions) == 1
assert "Next" in directions[0].text
# Now, get the next page of current votes.
offset = 10 # Specify coff=10
with client as request:
response = request.get("/tu", cookies=cookies, params={
"coff": offset
}, allow_redirects=False)
assert response.status_code == int(HTTPStatus.OK)
old_rows = rows
root = parse_root(response.text)
table = get_table(root, "current-votes")
rows = get_table_rows(table)
assert rows != old_rows
for i, row in enumerate(rows):
assert_current_vote_html(row, make_expectation(offset, i))
# Parse out Back/Next buttons.
directions = get_pkglist_directions(table)
assert len(directions) == 2
assert "Back" in directions[0].text
assert "Next" in directions[1].text
# Make sure past-votes' Back/Next were not affected.
past_votes = get_table(root, "past-votes")
past_directions = get_pkglist_directions(past_votes)
assert len(past_directions) == 1
assert "Next" in past_directions[0].text
offset = 20 # Specify coff=10
with client as request:
response = request.get("/tu", cookies=cookies, params={
"coff": offset
}, allow_redirects=False)
assert response.status_code == int(HTTPStatus.OK)
# Do it again, we only have five left.
old_rows = rows
root = parse_root(response.text)
table = get_table(root, "current-votes")
rows = get_table_rows(table)
assert rows != old_rows
for i, row in enumerate(rows):
assert_current_vote_html(row, make_expectation(offset, i))
# Parse out Back/Next buttons.
directions = get_pkglist_directions(table)
assert len(directions) == 1
assert "Back" in directions[0].text
# Make sure past-votes' Back/Next were not affected.
past_votes = get_table(root, "past-votes")
past_directions = get_pkglist_directions(past_votes)
assert len(past_directions) == 1
assert "Next" in past_directions[0].text
def test_tu_index_sorting(client, tu_user):
ts = int(datetime.utcnow().timestamp())
for i in range(2):
# Create 'Agenda #1' and 'Agenda #2'.
db.create(TUVoteInfo, Agenda=f"Agenda #{i + 1}",
User=tu_user.Username,
Submitted=(ts + 5), End=(ts + 1000),
Quorum=0.0,
Submitter=tu_user, autocommit=False)
# Let's order each vote one day after the other.
# This will allow us to test the sorting nature
# of the tables.
ts += 86405
# Make a default request to /tu.
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
response = request.get("/tu", cookies=cookies, allow_redirects=False)
assert response.status_code == int(HTTPStatus.OK)
# Get lxml handles of the document.
root = parse_root(response.text)
table = get_table(root, "current-votes")
rows = get_table_rows(table)
# The latest Agenda is at the top by default.
expected = [
"Agenda #2",
"Agenda #1"
]
assert len(rows) == len(expected)
for i, row in enumerate(rows):
assert_current_vote_html(row, [
expected[i],
DATETIME_REGEX,
DATETIME_REGEX,
tu_user.Username,
r'^(Yes|No)$'
])
# Make another request; one that sorts the current votes
# in ascending order instead of the default descending order.
with client as request:
response = request.get("/tu", cookies=cookies, params={
"cby": "asc"
}, allow_redirects=False)
assert response.status_code == int(HTTPStatus.OK)
# Get lxml handles of the document.
root = parse_root(response.text)
table = get_table(root, "current-votes")
rows = get_table_rows(table)
# Reverse our expectations and assert that the proposals got flipped.
rev_expected = list(reversed(expected))
assert len(rows) == len(rev_expected)
for i, row in enumerate(rows):
assert_current_vote_html(row, [
rev_expected[i],
DATETIME_REGEX,
DATETIME_REGEX,
tu_user.Username,
r'^(Yes|No)$'
])
def test_tu_index_last_votes(client, tu_user, user):
ts = int(datetime.utcnow().timestamp())
# Create a proposal which has ended.
voteinfo = db.create(TUVoteInfo, Agenda="Test agenda",
User=user.Username,
Submitted=(ts - 1000),
End=(ts - 5),
Yes=1,
ActiveTUs=1,
Quorum=0.0,
Submitter=tu_user)
# Create a vote on it from tu_user.
db.create(TUVote, VoteInfo=voteinfo, User=tu_user)
# Now, check that tu_user got populated in the .last-votes table.
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
response = request.get("/tu", cookies=cookies)
assert response.status_code == int(HTTPStatus.OK)
root = parse_root(response.text)
table = get_table(root, "last-votes")
rows = get_table_rows(table)
assert len(rows) == 1
last_vote = rows[0]
user, vote_id = last_vote.xpath("./td")
vote_id = vote_id.xpath("./a")[0]
assert user.text.strip() == tu_user.Username
assert int(vote_id.text.strip()) == voteinfo.ID