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 %}
+
+
+
+
+
+ {% 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() %}
+ {{ "Yes" | tr }} |
+ {{ "No" | tr }} |
+ {{ "Abstain" | tr }} |
+ {% endif %}
+
+ {{ "Total" | tr }} |
+ {{ "Voted" | tr }} |
+ {{ "Participation" | tr }} |
+
+
+
+ {% if not voteinfo.is_running() %}
+ {{ voteinfo.Yes }} |
+ {{ voteinfo.No }} |
+ {{ voteinfo.Abstain }} |
+ {% endif %}
+
+ {{ 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;
+}