diff --git a/aurweb/routers/trusted_user.py b/aurweb/routers/trusted_user.py index c027f67d..efdcfc73 100644 --- a/aurweb/routers/trusted_user.py +++ b/aurweb/routers/trusted_user.py @@ -1,7 +1,11 @@ +import typing + from datetime import datetime +from http import HTTPStatus 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 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_voteinfo import TUVoteInfo 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() @@ -95,3 +99,122 @@ async def trusted_user(request: Request, ]) 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) diff --git a/templates/partials/tu/proposal/details.html b/templates/partials/tu/proposal/details.html new file mode 100644 index 00000000..3f15a6eb --- /dev/null +++ b/templates/partials/tu/proposal/details.html @@ -0,0 +1,106 @@ +

{% trans %}Proposal Details{% endtrans %}

+ +{% if voteinfo.is_running() %} +

+ {% trans %}This vote is still running.{% endtrans %} +

+{% endif %} + + +
+
+ {{ "User" | tr }}: + + {% if voteinfo.User %} + + {{ voteinfo.User }} + + {% else %} + N/A + {% endif %} + +
+ + {% set submitted = voteinfo.Submitted | dt | as_timezone(timezone) %} + {% set end = voteinfo.End | dt | as_timezone(timezone) %} +
+ {{ + "Submitted: %s by %s" | tr + | format(submitted.strftime("%Y-%m-%d %H:%M"), + voteinfo.Submitter.Username | e) + }} +
+ +
+ {{ "End" | tr }}: + + {{ end.strftime("%Y-%m-%d %H:%M") }} + +
+ + {% if not voteinfo.is_running() %} +
+ {{ "Result" | tr }}: + {% if not voteinfo.ActiveTUs %} + {{ "unknown" | tr }} + {% elif accepted %} + + {{ "Accepted" | tr }} + + {% else %} + + {{ "Rejected" | tr }} + + {% endif %} +
+ {% endif %} +
+ +
+

+ + {{ voteinfo.Agenda | replace("\n", "
\n") | safe | e }} +

+
+ + + + {% if not voteinfo.is_running() %} + + + + {% endif %} + + + + + + + + {% if not voteinfo.is_running() %} + + + + {% endif %} + + + + + +
{{ "Yes" | tr }}{{ "No" | tr }}{{ "Abstain" | tr }}{{ "Total" | tr }}{{ "Voted" | tr }}{{ "Participation" | tr }}
{{ voteinfo.Yes }}{{ voteinfo.No }}{{ voteinfo.Abstain }}{{ voteinfo.total_votes() }} + {% if not has_voted %} + + {{ "No" | tr }} + + {% else %} + + {{ "Yes" | tr }} + + {% endif %} + + {% if voteinfo.ActiveTUs %} + {{ (participation * 100) | number_format(2) }}% + {% else %} + {{ "unknown" | tr }} + {% endif %} +
diff --git a/templates/partials/tu/proposal/form.html b/templates/partials/tu/proposal/form.html new file mode 100644 index 00000000..d783a622 --- /dev/null +++ b/templates/partials/tu/proposal/form.html @@ -0,0 +1,14 @@ +
+ +
+ + + +
+
diff --git a/templates/partials/tu/proposal/voters.html b/templates/partials/tu/proposal/voters.html new file mode 100644 index 00000000..2fd42bdf --- /dev/null +++ b/templates/partials/tu/proposal/voters.html @@ -0,0 +1,10 @@ +

{{ "Voters" | tr }}

+ diff --git a/templates/tu/show.html b/templates/tu/show.html new file mode 100644 index 00000000..ca5cbe63 --- /dev/null +++ b/templates/tu/show.html @@ -0,0 +1,20 @@ +{% extends "partials/layout.html" %} + +{% block pageContent %} +
+ {% include "partials/tu/proposal/details.html" %} +
+ +
+ {% include "partials/tu/proposal/voters.html" %} +
+ +
+ {% if error %} + {{ error | tr }} + {% else %} + {% include "partials/tu/proposal/form.html" %} + {% endif %} +
+ +{% endblock %} diff --git a/test/test_trusted_user_routes.py b/test/test_trusted_user_routes.py index a6527e6f..73cea9bf 100644 --- a/test/test_trusted_user_routes.py +++ b/test/test_trusted_user_routes.py @@ -18,6 +18,7 @@ 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}$' +PARTICIPATION_REGEX = r'^1?[0-9]{2}[%]$' # 0% - 100% def parse_root(html): @@ -103,6 +104,26 @@ def user(): 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): with client as request: 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 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." diff --git a/web/html/css/aurweb.css b/web/html/css/aurweb.css index bb4e3ad7..2748462f 100644 --- a/web/html/css/aurweb.css +++ b/web/html/css/aurweb.css @@ -204,3 +204,11 @@ label.confirmation, overflow: hidden; transition: height 1s; } + +.proposal.details { + margin: .33em 0 1em; +} + +button[type="submit"] { + padding: 0 0.6em; +}