mirror of
https://gitlab.archlinux.org/archlinux/aurweb.git
synced 2025-02-03 10:43:03 +01:00
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:
parent
a6bba601a9
commit
d674aaf736
10 changed files with 808 additions and 3 deletions
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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. """
|
||||||
|
|
97
aurweb/routers/trusted_user.py
Normal file
97
aurweb/routers/trusted_user.py
Normal 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)
|
|
@ -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 %}
|
||||||
|
|
33
templates/partials/tu/last_votes.html
Normal file
33
templates/partials/tu/last_votes.html
Normal 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>
|
120
templates/partials/tu/proposals.html
Normal file
120
templates/partials/tu/proposals.html
Normal 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 }}&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
35
templates/tu/index.html
Normal 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 %}
|
|
@ -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'})
|
||||||
|
|
443
test/test_trusted_user_routes.py
Normal file
443
test/test_trusted_user_routes.py
Normal 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
|
Loading…
Add table
Reference in a new issue