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 %}
+
+
+
+ {{ "User" | tr }} |
+ {{ "Last vote" | tr }} |
+
+
+
+ {% if not votes %}
+
+
+ {{ "No results found." | tr }}
+ |
+ |
+
+ {% else %}
+ {% for vote in votes %}
+
+ {{ vote.User.Username }} |
+
+
+ {{ vote.VoteID }}
+
+ |
+
+ {% endfor %}
+ {% endif %}
+
+
+
+
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 %}
+
+
+
+
+ {{ "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 }} |
+ {% if title != "Current Votes" %}
+ {{ "Yes" | tr }} |
+ {{ "No" | tr }} |
+ {% endif %}
+ {{ "Voted" | tr }} |
+
+
+
+
+ {% for result in results %}
+
+
+
+ {% set agenda = result.Agenda[:prev_len] %}
+ {{ agenda }}
+ |
+
+
+ {% set submitted = result.Submitted | dt | as_timezone(timezone) %}
+ {{ submitted.strftime("%Y-%m-%d") }} |
+
+
+ {% set end = result.End | dt | as_timezone(timezone) %}
+ {{ end.strftime("%Y-%m-%d") }} |
+
+
+ {% if not result.User %}
+ N/A
+ {% else %}
+
+ {{ result.User }}
+
+ {% endif %}
+ |
+
+ {% if title != "Current Votes" %}
+ {{ result.Yes }} |
+ {{ result.No }} |
+ {% endif %}
+
+ {% set vote = (result | get_vote(request)) %}
+
+ {% if vote %}
+
+ {{ "Yes" | tr }}
+
+ {% else %}
+
+ {{ "No" | tr }}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% 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