add /tu/{proposal_id} (get, post) routes

This commit ports the `/tu/?id={proposal_id}` PHP routes to
FastAPI into two individual GET and POST routes.

With this port of the single proposal view and POST logic,
several things have changed.

- The only parameter used is now `decision`, which
  must contain `Yes`, `No`, or `Abstain` as a string.
  When an invalid value is given, a BAD_REQUEST response
  is returned in plaintext: Invalid 'decision' value.
- The `doVote` parameter has been removed.
- The details section has been rearranged into a set
  of divs with specific classes that can be used for
  testing. CSS has been added to persist the layout with
  the element changes.
- Several errors that can be discovered in the POST path
  now trigger their own non-200 HTTPStatus codes.

Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2021-06-19 05:08:25 -07:00
parent 83c038a42a
commit 85ba4a33a8
7 changed files with 571 additions and 2 deletions

View file

@ -1,7 +1,11 @@
import typing
from datetime import datetime from datetime import datetime
from http import HTTPStatus
from urllib.parse import quote_plus from urllib.parse import quote_plus
from fastapi import APIRouter, Request from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
from aurweb import db from aurweb import db
@ -10,7 +14,7 @@ from aurweb.models.account_type import DEVELOPER, TRUSTED_USER, TRUSTED_USER_AND
from aurweb.models.tu_vote import TUVote from aurweb.models.tu_vote import TUVote
from aurweb.models.tu_voteinfo import TUVoteInfo from aurweb.models.tu_voteinfo import TUVoteInfo
from aurweb.models.user import User from aurweb.models.user import User
from aurweb.templates import make_context, render_template from aurweb.templates import make_context, make_variable_context, render_template
router = APIRouter() router = APIRouter()
@ -95,3 +99,122 @@ async def trusted_user(request: Request,
]) ])
return render_template(request, "tu/index.html", context) return render_template(request, "tu/index.html", context)
def render_proposal(request: Request,
context: dict,
proposal: int,
voteinfo: TUVoteInfo,
voters: typing.Iterable[User],
vote: TUVote,
status_code: HTTPStatus = HTTPStatus.OK):
""" Render a single TU proposal. """
context["proposal"] = proposal
context["voteinfo"] = voteinfo
context["voters"] = voters
participation = voteinfo.ActiveTUs / voteinfo.total_votes() \
if voteinfo.total_votes() else 0
context["participation"] = participation
accepted = (voteinfo.Yes > voteinfo.ActiveTUs / 2) or \
(participation > voteinfo.Quorum and voteinfo.Yes > voteinfo.No)
context["accepted"] = accepted
can_vote = voters.filter(TUVote.User == request.user).first() is None
context["can_vote"] = can_vote
if not voteinfo.is_running():
context["error"] = "Voting is closed for this proposal."
context["vote"] = vote
context["has_voted"] = vote is not None
return render_template(request, "tu/show.html", context,
status_code=status_code)
@router.get("/tu/{proposal}")
@auth_required(True, redirect="/")
@account_type_required(REQUIRED_TYPES)
async def trusted_user_proposal(request: Request, proposal: int):
context = await make_variable_context(request, "Trusted User")
proposal = int(proposal)
voteinfo = db.query(TUVoteInfo, TUVoteInfo.ID == proposal).first()
if not voteinfo:
raise HTTPException(status_code=int(HTTPStatus.NOT_FOUND))
voters = db.query(User).join(TUVote).filter(TUVote.VoteID == voteinfo.ID)
vote = db.query(TUVote, and_(TUVote.UserID == request.user.ID,
TUVote.VoteID == voteinfo.ID)).first()
if not request.user.is_trusted_user():
context["error"] = "Only Trusted Users are allowed to vote."
elif voteinfo.User == request.user.Username:
context["error"] = "You cannot vote in an proposal about you."
elif vote is not None:
context["error"] = "You've already voted for this proposal."
context["vote"] = vote
return render_proposal(request, context, proposal, voteinfo, voters, vote)
@router.post("/tu/{proposal}")
@auth_required(True, redirect="/")
@account_type_required(REQUIRED_TYPES)
async def trusted_user_proposal_post(request: Request,
proposal: int,
decision: str = Form(...)):
context = await make_variable_context(request, "Trusted User")
proposal = int(proposal) # Make sure it's an int.
voteinfo = db.query(TUVoteInfo, TUVoteInfo.ID == proposal).first()
if not voteinfo:
raise HTTPException(status_code=int(HTTPStatus.NOT_FOUND))
voters = db.query(User).join(TUVote).filter(TUVote.VoteID == voteinfo.ID)
# status_code we'll use for responses later.
status_code = HTTPStatus.OK
if not request.user.is_trusted_user():
# Test: Create a proposal and view it as a "Developer". It
# should give us this error.
context["error"] = "Only Trusted Users are allowed to vote."
status_code = HTTPStatus.UNAUTHORIZED
elif voteinfo.User == request.user.Username:
context["error"] = "You cannot vote in an proposal about you."
status_code = HTTPStatus.BAD_REQUEST
vote = db.query(TUVote, and_(TUVote.UserID == request.user.ID,
TUVote.VoteID == voteinfo.ID)).first()
if status_code != HTTPStatus.OK:
return render_proposal(request, context, proposal,
voteinfo, voters, vote,
status_code=status_code)
if vote is not None:
context["error"] = "You've already voted for this proposal."
status_code = HTTPStatus.BAD_REQUEST
if status_code != HTTPStatus.OK:
return render_proposal(request, context, proposal,
voteinfo, voters, vote,
status_code=status_code)
if decision in {"Yes", "No", "Abstain"}:
# Increment whichever decision was given to us.
setattr(voteinfo, decision, getattr(voteinfo, decision) + 1)
else:
return Response("Invalid 'decision' value.",
status_code=int(HTTPStatus.BAD_REQUEST))
vote = db.create(TUVote, User=request.user, VoteInfo=voteinfo,
autocommit=False)
voteinfo.ActiveTUs += 1
db.commit()
context["error"] = "You've already voted for this proposal."
return render_proposal(request, context, proposal, voteinfo, voters, vote)

View file

@ -0,0 +1,106 @@
<h2>{% trans %}Proposal Details{% endtrans %}</h2>
{% if voteinfo.is_running() %}
<p class="vote-running" style="font-weight: bold; color: red">
{% trans %}This vote is still running.{% endtrans %}
</p>
{% endif %}
<!-- The margin style here mimics the margin on the old <p> element. -->
<div class="proposal details">
<div class="field user">
{{ "User" | tr }}:
<strong>
{% if voteinfo.User %}
<a href="{{ '/packages/?K=%s&SeB=m' | format(voteinfo.User)}}">
{{ voteinfo.User }}
</a>
{% else %}
N/A
{% endif %}
</strong>
</div>
{% set submitted = voteinfo.Submitted | dt | as_timezone(timezone) %}
{% set end = voteinfo.End | dt | as_timezone(timezone) %}
<div class="field submitted">
{{
"Submitted: %s by %s" | tr
| format(submitted.strftime("%Y-%m-%d %H:%M"),
voteinfo.Submitter.Username | e)
}}
</div>
<div class="field end">
{{ "End" | tr }}:
<strong>
{{ end.strftime("%Y-%m-%d %H:%M") }}
</strong>
</div>
{% if not voteinfo.is_running() %}
<div class="field result">
{{ "Result" | tr }}:
{% if not voteinfo.ActiveTUs %}
<span>{{ "unknown" | tr }}</span>
{% elif accepted %}
<span style="color: green; font-weight: bold">
{{ "Accepted" | tr }}
</span>
{% else %}
<span style="color: red; font-weight: bold">
{{ "Rejected" | tr }}
</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="proposal agenda">
<p class="field agenda">
<!-- The `e` filter manually escapes content. -->
{{ voteinfo.Agenda | replace("\n", "<br />\n") | safe | e }}
</p>
</div>
<table class="vote-status">
<tr>
{% if not voteinfo.is_running() %}
<th>{{ "Yes" | tr }}</th>
<th>{{ "No" | tr }}</th>
<th>{{ "Abstain" | tr }}</th>
{% endif %}
<th>{{ "Total" | tr }}</th>
<th>{{ "Voted" | tr }}</th>
<th>{{ "Participation" | tr }}</th>
</tr>
<tr>
{% if not voteinfo.is_running() %}
<td>{{ voteinfo.Yes }}</td>
<td>{{ voteinfo.No }}</td>
<td>{{ voteinfo.Abstain }}</td>
{% endif %}
<td>{{ voteinfo.total_votes() }}</td>
<td>
{% if not has_voted %}
<span style="color: red; font-weight: bold">
{{ "No" | tr }}
</span>
{% else %}
<span style="color: green; font-weight: bold">
{{ "Yes" | tr }}
</span>
{% endif %}
</td>
<td>
{% if voteinfo.ActiveTUs %}
{{ (participation * 100) | number_format(2) }}%
{% else %}
{{ "unknown" | tr }}
{% endif %}
</td>
</tr>
</table>

View file

@ -0,0 +1,14 @@
<form class="action-form" action="/tu/{{ proposal }}" method="POST">
<!-- Translate each button's text but leave the value alone. -->
<fieldset>
<button type="submit" class="button" name="decision" value="Yes">
{{ "Yes" | tr }}
</button>
<button type="submit" class="button" name="decision" value="No">
{{ "No" | tr }}
</button>
<button type="submit" class="button" name="decision" value="Abstain">
{{ "Abstain" | tr }}
</button>
</fieldset>
</form>

View file

@ -0,0 +1,10 @@
<h2>{{ "Voters" | tr }}</h2>
<ul id="voters">
{% for voter in voters %}
<li>
<a href="/account/{{ voter.Username | urlencode }}">
{{ voter.Username | e }}
</a>
</li>
{% endfor %}
</ul>

20
templates/tu/show.html Normal file
View file

@ -0,0 +1,20 @@
{% extends "partials/layout.html" %}
{% block pageContent %}
<div class="box">
{% include "partials/tu/proposal/details.html" %}
</div>
<div class="box">
{% include "partials/tu/proposal/voters.html" %}
</div>
<div class="box">
{% if error %}
<span class="status">{{ error | tr }}</span>
{% else %}
{% include "partials/tu/proposal/form.html" %}
{% endif %}
</div>
{% endblock %}

View file

@ -18,6 +18,7 @@ from aurweb.testing import setup_test_db
from aurweb.testing.requests import Request from aurweb.testing.requests import Request
DATETIME_REGEX = r'^[0-9]{4}-[0-9]{2}-[0-9]{2}$' DATETIME_REGEX = r'^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
PARTICIPATION_REGEX = r'^1?[0-9]{2}[%]$' # 0% - 100%
def parse_root(html): def parse_root(html):
@ -103,6 +104,26 @@ def user():
AccountType=user_type) AccountType=user_type)
@pytest.fixture
def proposal(tu_user):
ts = int(datetime.utcnow().timestamp())
agenda = "Test proposal."
start = ts - 5
end = ts + 1000
user_type = db.query(AccountType,
AccountType.AccountType == "User").first()
user = db.create(User, Username="test", Email="test@example.org",
RealName="Test User", Passwd="testPassword",
AccountType=user_type)
voteinfo = db.create(TUVoteInfo,
Agenda=agenda, Quorum=0.0,
User=user.Username, Submitter=tu_user,
Submitted=start, End=end)
yield (tu_user, user, voteinfo)
def test_tu_index_guest(client): def test_tu_index_guest(client):
with client as request: with client as request:
response = request.get("/tu", allow_redirects=False) response = request.get("/tu", allow_redirects=False)
@ -441,3 +462,270 @@ def test_tu_index_last_votes(client, tu_user, user):
assert user.text.strip() == tu_user.Username assert user.text.strip() == tu_user.Username
assert int(vote_id.text.strip()) == voteinfo.ID assert int(vote_id.text.strip()) == voteinfo.ID
def test_tu_proposal_not_found(client, tu_user):
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
response = request.get("/tu", params={"id": 1}, cookies=cookies)
assert response.status_code == int(HTTPStatus.NOT_FOUND)
def test_tu_running_proposal(client, proposal):
tu_user, user, voteinfo = proposal
# Initiate an authenticated GET request to /tu/{proposal_id}.
proposal_id = voteinfo.ID
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
response = request.get(f"/tu/{proposal_id}", cookies=cookies)
assert response.status_code == int(HTTPStatus.OK)
# Alright, now let's continue on to verifying some markup.
# First, let's verify that the proposal details match.
root = parse_root(response.text)
details = root.xpath('//div[@class="proposal details"]')[0]
vote_running = root.xpath('//p[contains(@class, "vote-running")]')[0]
assert vote_running.text.strip() == "This vote is still running."
# Verify User field.
username = details.xpath(
'./div[contains(@class, "user")]/strong/a/text()')[0]
assert username.strip() == user.Username
submitted = details.xpath(
'./div[contains(@class, "submitted")]/text()')[0]
assert re.match(r'^Submitted: \d{4}-\d{2}-\d{2} \d{2}:\d{2} by .+$',
submitted.strip()) is not None
end = details.xpath('./div[contains(@class, "end")]')[0]
end_label = end.xpath("./text()")[0]
assert end_label.strip() == "End:"
end_datetime = end.xpath("./strong/text()")[0]
assert re.match(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$',
end_datetime.strip()) is not None
# We have not voted yet. Assert that our voting form is shown.
form = root.xpath('//form[contains(@class, "action-form")]')[0]
fields = form.xpath("./fieldset")[0]
buttons = fields.xpath('./button[@name="decision"]')
assert len(buttons) == 3
# Check the button names and values.
yes, no, abstain = buttons
# Yes
assert yes.attrib["name"] == "decision"
assert yes.attrib["value"] == "Yes"
# No
assert no.attrib["name"] == "decision"
assert no.attrib["value"] == "No"
# Abstain
assert abstain.attrib["name"] == "decision"
assert abstain.attrib["value"] == "Abstain"
# Create a vote.
db.create(TUVote, VoteInfo=voteinfo, User=tu_user)
voteinfo.ActiveTUs += 1
voteinfo.Yes += 1
db.commit()
# Make another request now that we've voted.
with client as request:
response = request.get(
"/tu", params={"id": voteinfo.ID}, cookies=cookies)
assert response.status_code == int(HTTPStatus.OK)
# Parse our new root.
root = parse_root(response.text)
# Check that we no longer have a voting form.
form = root.xpath('//form[contains(@class, "action-form")]')
assert not form
# Check that we're told we've voted.
status = root.xpath('//span[contains(@class, "status")]/text()')[0]
assert status == "You've already voted for this proposal."
def test_tu_ended_proposal(client, proposal):
tu_user, user, voteinfo = proposal
ts = int(datetime.utcnow().timestamp())
voteinfo.End = ts - 5 # 5 seconds ago.
db.commit()
# Initiate an authenticated GET request to /tu/{proposal_id}.
proposal_id = voteinfo.ID
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
response = request.get(f"/tu/{proposal_id}", cookies=cookies)
assert response.status_code == int(HTTPStatus.OK)
# Alright, now let's continue on to verifying some markup.
# First, let's verify that the proposal details match.
root = parse_root(response.text)
details = root.xpath('//div[@class="proposal details"]')[0]
vote_running = root.xpath('//p[contains(@class, "vote-running")]')
assert not vote_running
result_node = details.xpath('./div[contains(@class, "result")]')[0]
result_label = result_node.xpath("./text()")[0]
assert result_label.strip() == "Result:"
result = result_node.xpath("./span/text()")[0]
assert result.strip() == "unknown"
# Check that voting has ended.
form = root.xpath('//form[contains(@class, "action-form")]')
assert not form
# We should see a status about it.
status = root.xpath('//span[contains(@class, "status")]/text()')[0]
assert status == "Voting is closed for this proposal."
def test_tu_proposal_vote_not_found(client, tu_user):
""" Test POST request to a missing vote. """
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
data = {"decision": "Yes"}
response = request.post("/tu/1", cookies=cookies,
data=data, allow_redirects=False)
assert response.status_code == int(HTTPStatus.NOT_FOUND)
def test_tu_proposal_vote(client, proposal):
tu_user, user, voteinfo = proposal
# Store the current related values.
yes = voteinfo.Yes
active_tus = voteinfo.ActiveTUs
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
data = {"decision": "Yes"}
response = request.post(f"/tu/{voteinfo.ID}", cookies=cookies,
data=data)
assert response.status_code == int(HTTPStatus.OK)
# Check that the proposal record got updated.
assert voteinfo.Yes == yes + 1
assert voteinfo.ActiveTUs == active_tus + 1
# Check that the new TUVote exists.
vote = db.query(TUVote, TUVote.VoteInfo == voteinfo,
TUVote.User == tu_user).first()
assert vote is not None
root = parse_root(response.text)
# Check that we're told we've voted.
status = root.xpath('//span[contains(@class, "status")]/text()')[0]
assert status == "You've already voted for this proposal."
def test_tu_proposal_vote_unauthorized(client, proposal):
tu_user, user, voteinfo = proposal
dev_type = db.query(AccountType,
AccountType.AccountType == "Developer").first()
tu_user.AccountType = dev_type
db.commit()
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
data = {"decision": "Yes"}
response = request.post(f"/tu/{voteinfo.ID}", cookies=cookies,
data=data, allow_redirects=False)
assert response.status_code == int(HTTPStatus.UNAUTHORIZED)
root = parse_root(response.text)
status = root.xpath('//span[contains(@class, "status")]/text()')[0]
assert status == "Only Trusted Users are allowed to vote."
with client as request:
data = {"decision": "Yes"}
response = request.get(f"/tu/{voteinfo.ID}", cookies=cookies,
data=data, allow_redirects=False)
assert response.status_code == int(HTTPStatus.OK)
root = parse_root(response.text)
status = root.xpath('//span[contains(@class, "status")]/text()')[0]
assert status == "Only Trusted Users are allowed to vote."
def test_tu_proposal_vote_cant_self_vote(client, proposal):
tu_user, user, voteinfo = proposal
# Update voteinfo.User.
voteinfo.User = tu_user.Username
db.commit()
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
data = {"decision": "Yes"}
response = request.post(f"/tu/{voteinfo.ID}", cookies=cookies,
data=data, allow_redirects=False)
assert response.status_code == int(HTTPStatus.BAD_REQUEST)
root = parse_root(response.text)
status = root.xpath('//span[contains(@class, "status")]/text()')[0]
assert status == "You cannot vote in an proposal about you."
with client as request:
data = {"decision": "Yes"}
response = request.get(f"/tu/{voteinfo.ID}", cookies=cookies,
data=data, allow_redirects=False)
assert response.status_code == int(HTTPStatus.OK)
root = parse_root(response.text)
status = root.xpath('//span[contains(@class, "status")]/text()')[0]
assert status == "You cannot vote in an proposal about you."
def test_tu_proposal_vote_already_voted(client, proposal):
tu_user, user, voteinfo = proposal
db.create(TUVote, VoteInfo=voteinfo, User=tu_user)
voteinfo.Yes += 1
voteinfo.ActiveTUs += 1
db.commit()
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
data = {"decision": "Yes"}
response = request.post(f"/tu/{voteinfo.ID}", cookies=cookies,
data=data, allow_redirects=False)
assert response.status_code == int(HTTPStatus.BAD_REQUEST)
root = parse_root(response.text)
status = root.xpath('//span[contains(@class, "status")]/text()')[0]
assert status == "You've already voted for this proposal."
with client as request:
data = {"decision": "Yes"}
response = request.get(f"/tu/{voteinfo.ID}", cookies=cookies,
data=data, allow_redirects=False)
assert response.status_code == int(HTTPStatus.OK)
root = parse_root(response.text)
status = root.xpath('//span[contains(@class, "status")]/text()')[0]
assert status == "You've already voted for this proposal."
def test_tu_proposal_vote_invalid_decision(client, proposal):
tu_user, user, voteinfo = proposal
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
data = {"decision": "EVIL"}
response = request.post(f"/tu/{voteinfo.ID}", cookies=cookies,
data=data)
assert response.status_code == int(HTTPStatus.BAD_REQUEST)
assert response.text == "Invalid 'decision' value."

View file

@ -204,3 +204,11 @@ label.confirmation,
overflow: hidden; overflow: hidden;
transition: height 1s; transition: height 1s;
} }
.proposal.details {
margin: .33em 0 1em;
}
button[type="submit"] {
padding: 0 0.6em;
}