diff --git a/aurweb/asgi.py b/aurweb/asgi.py index 65318907..a674fec6 100644 --- a/aurweb/asgi.py +++ b/aurweb/asgi.py @@ -16,7 +16,7 @@ from aurweb.auth import BasicAuthBackend from aurweb.db import get_engine, query from aurweb.models.accepted_term import AcceptedTerm 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. app = FastAPI(exception_handlers=errors.exceptions) @@ -47,6 +47,7 @@ async def app_startup(): app.include_router(html.router) app.include_router(auth.router) app.include_router(accounts.router) + app.include_router(trusted_user.router) # Initialize the database engine and ORM. get_engine() diff --git a/aurweb/auth.py b/aurweb/auth.py index ba5f0fea..316e7293 100644 --- a/aurweb/auth.py +++ b/aurweb/auth.py @@ -3,6 +3,8 @@ import functools from datetime import datetime from http import HTTPStatus +import fastapi + from fastapi.responses import RedirectResponse from sqlalchemy import and_ from starlette.authentication import AuthCredentials, AuthenticationBackend @@ -11,6 +13,7 @@ from starlette.requests import HTTPConnection import aurweb.config from aurweb import l10n, util +from aurweb.models.account_type import ACCOUNT_TYPE_ID from aurweb.models.session import Session from aurweb.models.user import User from aurweb.templates import make_variable_context, render_template @@ -152,6 +155,42 @@ def auth_required(is_required: bool = True, 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_EDIT = 2 CRED_ACCOUNT_EDIT_DEV = 3 diff --git a/aurweb/models/account_type.py b/aurweb/models/account_type.py index ca302e5b..0db37ced 100644 --- a/aurweb/models/account_type.py +++ b/aurweb/models/account_type.py @@ -3,6 +3,11 @@ from sqlalchemy import Column, Integer from aurweb import db from aurweb.models.declarative import Base +USER = "User" +TRUSTED_USER = "Trusted User" +DEVELOPER = "Developer" +TRUSTED_USER_AND_DEV = "Trusted User & Developer" + class AccountType(Base): """ An ORM model of a single AccountTypes record. """ diff --git a/aurweb/routers/trusted_user.py b/aurweb/routers/trusted_user.py new file mode 100644 index 00000000..c027f67d --- /dev/null +++ b/aurweb/routers/trusted_user.py @@ -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) diff --git a/templates/partials/archdev-navbar.html b/templates/partials/archdev-navbar.html index c935fd41..c6cd3f19 100644 --- a/templates/partials/archdev-navbar.html +++ b/templates/partials/archdev-navbar.html @@ -7,11 +7,29 @@ {% endif %}
  • {% trans %}Packages{% endtrans %}
  • {% if request.user.is_authenticated() %} + + {% if request.user.is_trusted_user() or request.user.is_developer() %} +
  • + {% trans %}Requests{% endtrans %} +
  • + +
  • + {% trans %}Accounts{% endtrans %} +
  • + {% endif %} +
  • {% trans %}My Account{% endtrans %}
  • + + {% if request.user.is_trusted_user() %} +
  • + {% trans %}Trusted User{% endtrans %} +
  • + {% endif %} +
  • {% trans %}Logout{% endtrans %} diff --git a/templates/partials/tu/last_votes.html b/templates/partials/tu/last_votes.html new file mode 100644 index 00000000..94b9c1e8 --- /dev/null +++ b/templates/partials/tu/last_votes.html @@ -0,0 +1,33 @@ +
    +

    {% trans %}{{ title }}{% endtrans %}

    + + + + + + + + + {% if not votes %} + + + + + {% else %} + {% for vote in votes %} + + + + + {% endfor %} + {% endif %} + +
    {{ "User" | tr }}{{ "Last vote" | tr }}
    + {{ "No results found." | tr }} +
    {{ vote.User.Username }} + + {{ vote.VoteID }} + +
    + +
    diff --git a/templates/partials/tu/proposals.html b/templates/partials/tu/proposals.html new file mode 100644 index 00000000..13e705fc --- /dev/null +++ b/templates/partials/tu/proposals.html @@ -0,0 +1,120 @@ +
    +

    {% trans %}{{ title }}{% endtrans %}

    + + {% if title == "Current Votes" %} +
    + {% endif %} + + {% if not results %} +

    + {% trans %}No results found.{% endtrans %} +

    + {% else %} + + + + + + + + + {% if title != "Current Votes" %} + + + {% endif %} + + + + + + {% for result in results %} + + + + + {% set submitted = result.Submitted | dt | as_timezone(timezone) %} + + + + {% set end = result.End | dt | as_timezone(timezone) %} + + + + + {% if title != "Current Votes" %} + + + {% endif %} + + {% set vote = (result | get_vote(request)) %} + + + {% endfor %} + +
    {{ "Proposal" | tr }} + {% set off_qs = "%s=%d" | format(off_param, off) %} + {% set by_qs = "%s=%s" | format(by_param, by_next | urlencode) %} + + {{ "Start" | tr }} + + {{ "End" | tr }}{{ "User" | tr }}{{ "Yes" | tr }}{{ "No" | tr }}{{ "Voted" | tr }}
    + + {% set agenda = result.Agenda[:prev_len] %} + {{ agenda }} + {{ submitted.strftime("%Y-%m-%d") }}{{ end.strftime("%Y-%m-%d") }} + {% if not result.User %} + N/A + {% else %} + + {{ result.User }} + + {% endif %} + {{ result.Yes }}{{ result.No }} + {% if vote %} + + {{ "Yes" | tr }} + + {% else %} + + {{ "No" | tr }} + + {% endif %} +
    + +
    +

    + {% 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) %} + + ‹ Back + + {% 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) %} + + Next › + + {% endif %} + + {% endif %} +

    +
    + + {% endif %} + +
    diff --git a/templates/tu/index.html b/templates/tu/index.html new file mode 100644 index 00000000..5060e1f7 --- /dev/null +++ b/templates/tu/index.html @@ -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 %} diff --git a/test/test_auth.py b/test/test_auth.py index e5e1de11..b386bea1 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -4,9 +4,9 @@ import pytest 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.models.account_type import AccountType +from aurweb.models.account_type import USER, USER_ID, AccountType from aurweb.models.session import Session from aurweb.models.user import User from aurweb.testing import setup_test_db @@ -76,3 +76,17 @@ async def test_basic_auth_backend(): def test_has_fake_credential_fails(): # Fake credential 666 does not exist. 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'}) diff --git a/test/test_trusted_user_routes.py b/test/test_trusted_user_routes.py new file mode 100644 index 00000000..a6527e6f --- /dev/null +++ b/test/test_trusted_user_routes.py @@ -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