blob: 0b9a977718a3899a4f495ae05ca492d36ec37c79 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
### NOTE: the voting handlers require login for ASF committers only.
### Obviously, this is not a general purpose solution. Something for
### the future, to figure out how we'd like to do configuration
### authorization for various install scenarios and authn systems.
import pathlib
import datetime
import functools
import logging
import random
from easydict import EasyDict as edict
import asfpy.stopwatch
import quart
import asfquart.session
from asfquart.auth import Requirements as R
import ezt
import steve.election
import steve.crypto
import steve.persondb
import steve.vtypes.stv
APP = asfquart.APP
_LOGGER = logging.getLogger(__name__)
THIS_DIR = pathlib.Path(__file__).resolve().parent
DB_FNAME = THIS_DIR / APP.cfg.db
TEMPLATES = THIS_DIR / 'templates'
STATICDIR = THIS_DIR / 'static'
DOCSDIR = THIS_DIR / 'docs'
# Formatted values to inject into templates.
FMT_DATE = '%b %d'
FMT_DATE_FULL = '%Y-%m-%d %H:%M'
SOON_1HOUR = 60 * 60
SOON_CUTOFF = 48 * SOON_1HOUR # 48 hours, in seconds
T_BAD_EID = APP.load_template(TEMPLATES / 'e_bad_eid.ezt')
T_BAD_IID = APP.load_template(TEMPLATES / 'e_bad_iid.ezt')
T_BAD_PID = APP.load_template(TEMPLATES / 'e_bad_pid.ezt')
def rewrite_description(issue):
"""Rewrite issue description: wrap in <pre> and convert doc:filename to links."""
import re
desc = issue.description
# Replace doc:filename with <a> link
def repl(match):
filename = match.group(1)
return f'<a href="/docs/{issue.iid}/{filename}">{filename}</a>'
desc = re.sub(r'doc:([^\s]+)', repl, desc)
# Wrap in <pre>
issue.description = f'<pre>{desc}</pre>'
async def basic_info():
"""Return base-level EZT template data."""
basic = edict()
# Flashes are stored in the Session. Fetch and turn each flash into
# EasyDict objects for use by templates.
# NOTE: .flash() is called with (message, category), but the
# .get_flashed_messages() returns tuples of (category, message)
basic.flashes = [
edict(message=f[1], category=f[0])
for f in quart.get_flashed_messages(with_categories=True)
]
s = await asfquart.session.read()
if s:
basic.update(
uid=s['uid'],
name=s['fullname'],
email=s['email'],
)
else:
# No session.
basic.update(
uid=None,
name=None,
email=None,
)
### generate a real token and store in the session
basic.csrf_token = 'placeholder'
return basic
async def _set_election_date(election, field):
"""Helper to set open_at or close_at on an election, with validation and logging."""
result = await basic_info()
### check authz
data = await quart.request.get_json()
date_str = data.get('date')
if not date_str:
quart.abort(400, 'Missing date')
# Validate date (basic check)
try:
dt = datetime.datetime.fromisoformat(date_str).date()
except ValueError:
quart.abort(400, 'Invalid date format')
# Set the date on the election (field is 'open_at' or 'close_at')
if field == 'open_at':
election.set_open_at(dt)
elif field == 'close_at':
election.set_close_at(dt)
else:
quart.abort(400, 'Invalid field')
_LOGGER.info(
f'User[U:{result.uid}] set {field} for election[E:{election.eid}] to {date_str}'
)
return '', 204
# Define a bunch of helpers for recording "flash" messages in the session.
# Each helper function is:
# async def flash_FOO(message)
# where FOO is one of the eight Bootstrap alert classes. See:
# https://getbootstrap.com/docs/5.0/components/alerts/#examples
flash_primary = functools.partial(quart.flash, category='primary')
flash_secondary = functools.partial(quart.flash, category='secondary')
flash_success = functools.partial(quart.flash, category='success')
flash_danger = functools.partial(quart.flash, category='danger')
flash_warning = functools.partial(quart.flash, category='warning')
flash_info = functools.partial(quart.flash, category='info')
flash_light = functools.partial(quart.flash, category='light')
flash_dark = functools.partial(quart.flash, category='dark')
@APP.get('/')
@APP.use_template(TEMPLATES / 'home.ezt')
async def home_page():
result = await basic_info()
result.title = 'Home'
return result
@APP.get('/voter')
@asfquart.auth.require({R.committer}) ### need general solution
@APP.use_template(TEMPLATES / 'voter.ezt')
async def voter_page():
result = await basic_info()
result.title = 'Voting'
with asfpy.stopwatch.Stopwatch():
# These are lists of EasyDict instances for each Election.
election = steve.election.Election.open_to_pid(DB_FNAME, result.uid)
owned = steve.election.Election.owned_elections(DB_FNAME, result.uid)
result.open_elections = [postprocess_election(e) for e in election]
result.upcoming_elections = [
postprocess_election(e)
for e in steve.election.Election.upcoming_to_pid(DB_FNAME, result.uid)
]
result.past_elections = [] ### TBD
result.len_open = len(result.open_elections)
result.len_upcoming = len(result.upcoming_elections)
result.len_past = len(result.past_elections)
### no longer needed? move to navbar?
result.len_owned = len(owned)
if result.len_open:
result.active_tab = 'open'
elif result.len_upcoming:
result.active_tab = 'upcoming'
else:
result.active_tab = 'past'
return result
def load_election(func):
"""Decorator to load/pass-argument an Election from EID."""
@functools.wraps(func)
async def loader(eid):
try:
e = steve.election.Election(DB_FNAME, eid)
except steve.election.ElectionNotFound:
result = await basic_info()
result.title = 'Unknown Election'
result.eid = eid
# Note: result.uid (and friends) are needed for the navbar.
raise_404(T_BAD_EID, result)
# NOTREACHED
_LOGGER.debug(f'Loaded: {e}')
### check authz
return await func(e)
return loader
def load_election_issue(func):
"""Decorator to load/pass-argument an Election from EID."""
@functools.wraps(func)
async def loader(eid, iid):
try:
e = steve.election.Election(DB_FNAME, eid)
except steve.election.ElectionNotFound:
result = await basic_info()
result.title = 'Unknown Election'
result.eid = eid
# Note: result.uid (and friends) are needed for the navbar.
raise_404(T_BAD_EID, result)
# NOTREACHED
_LOGGER.debug(f'Loaded: {e}')
### check authz
try:
i = e.get_issue(iid)
except steve.election.IssueNotFound:
result = await basic_info()
result.title = 'Unknown Issue'
result.eid = eid
result.iid = iid
# Note: result.uid (and friends) are needed for the navbar.
raise_404(T_BAD_IID, result)
# NOTREACHED
### get_issue() should return an edict. fix it here.
issue = edict(iid=iid, title=i[0], description=i[1], vtype=i[2], kv=i[3])
return await func(e, issue)
return loader
@APP.get('/vote-on/<eid>')
@asfquart.auth.require({R.committer}) ### need general solution
@load_election
@APP.use_template(TEMPLATES / 'vote-on.ezt')
async def vote_on_page(election):
result = await basic_info()
result.title = 'Vote On Election'
result.eid = election.eid
result.election = election.get_metadata()
result.e_title = result.election.title
# Fetch owner name from PersonDB
pdb = steve.persondb.PersonDB.open(DB_FNAME)
try:
owner_person = pdb.get_person(result.election.owner_pid)
result.election.owner_name = owner_person[0] # name
except steve.persondb.PersonNotFound:
result.election.owner_name = 'unknown'
# Check if user can vote in this election (has mayvote entries)
election.q_find_issues.perform(result.uid, election.eid)
if not election.q_find_issues.fetchall():
result = await basic_info()
result.title = 'Access Denied'
result.eid = election.eid
raise_404(T_BAD_EID, result) # Reuse template for consistency
# Add more stuff into the Election instance.
_ = postprocess_election(result.election)
all_issues = election.list_issues()
# Split into YNA and STV issues, sort each by title
issues_yna = [i for i in all_issues if i.vtype == 'yna']
issues_yna.sort(key=lambda i: i.title)
issues_stv = [i for i in all_issues if i.vtype == 'stv']
issues_stv.sort(key=lambda i: i.title)
# Add seats and normalized candidates to STV issues from KV
for issue in issues_stv:
issue.seats = issue.kv.get('seats', 0)
candidates = steve.vtypes.stv.get_candidates(issue.kv)
issue.candidates = [
{'label': label, **candidate} for label, candidate in candidates.items()
]
# Shuffle candidates to prevent bias towards the first listed candidate
random.shuffle(issue.candidates)
# Compute counts and plurals
yna_count = len(issues_yna)
stv_count = len(issues_stv)
result.has_yna_issues = ezt.boolean(yna_count > 0)
result.has_stv_issues = ezt.boolean(stv_count > 0)
result.are_yna_plural = ezt.boolean(yna_count > 1)
result.are_stv_plural = ezt.boolean(stv_count > 1)
# Combine: STV first, then YNA
result.issues = issues_stv + issues_yna
result.issue_count = len(result.issues)
# Rewrite descriptions in-place
for issue in result.issues:
rewrite_description(issue)
result.has_voted = None # Leave as None for now
return result
@APP.get('/admin')
@asfquart.auth.require({R.committer}) ### need general solution
@APP.use_template(TEMPLATES / 'admin.ezt')
async def admin_page():
result = await basic_info()
result.title = 'Administration'
with asfpy.stopwatch.Stopwatch():
# These are lists of EasyDict instances for each Election.
election = steve.election.Election.open_to_pid(DB_FNAME, result.uid)
owned = steve.election.Election.owned_elections(DB_FNAME, result.uid)
result.owned = [postprocess_election(e) for e in owned]
### owned.owner_name should be based on OWNER_PID. That might not be
### the "me" because of authz access to manage issues.
### should open/keep a PersonDB instance in the APP
pdb = steve.persondb.PersonDB.open(DB_FNAME)
try:
me = pdb.get_person(result.uid)
except steve.persondb.PersonNotFound:
### the authn'd committer is not in "person"
result = await basic_info()
result.title = 'Unknown Person'
result.pid = result.uid
# Note: result.uid (and friends) are needed for the navbar.
raise_404(T_BAD_PID, result)
# NOTREACHED
### the query for OWNED is just for "me", so this is the correct
### name at the moment. When authz kicks in ... Nope.
# me is (name, email)
for e in result.owned:
e.owner_name = me[0]
result.len_election = len(election)
result.len_owned = len(owned)
return result
@APP.get('/manage/<eid>')
@asfquart.auth.require({R.committer}) ### need general solution
@load_election
@APP.use_template(TEMPLATES / 'manage.ezt')
async def manage_page(election):
result = await basic_info()
result.title = 'Manage an Election'
result.eid = election.eid
result.election = election.get_metadata()
result.e_title = result.election.title
result.e_state = result.election.state
# Add more stuff into the Election instance.
_ = postprocess_election(result.election)
result.issues = election.list_issues()
result.issue_count = len(result.issues)
return result
@APP.get('/manage-stv/<eid>/<iid>')
@asfquart.auth.require({R.committer}) ### need general solution
@load_election_issue
@APP.use_template(TEMPLATES / 'manage-stv.ezt')
async def manage_stv_page(election, issue):
if issue.vtype != 'stv':
# This page is just for STV issues. Redirect to the Election
# management page.
return quart.redirect(f'/manage/{election.eid}', code=303)
result = await basic_info()
result.title = 'Manage an STV Issue'
result.eid = election.eid
result.issue = issue
result.election = election.get_metadata()
result.e_title = result.election.title
result.e_state = result.election.state
# Add more stuff into the Election instance.
_ = postprocess_election(result.election)
kv = edict(issue.kv)
result.seats = kv.seats
### list of candidates. see KV.LABELMAP
# result.count = len(result.candidates)
return result
@APP.post('/do-set-open_at/<eid>')
@asfquart.auth.require({R.committer})
@load_election
async def do_set_open_at_endpoint(election):
return await _set_election_date(election, 'open_at')
@APP.post('/do-set-close_at/<eid>')
@asfquart.auth.require({R.committer})
@load_election
async def do_set_close_at_endpoint(election):
return await _set_election_date(election, 'close_at')
@APP.post('/do-vote/<eid>')
@asfquart.auth.require({R.committer})
@load_election
async def do_vote_endpoint(election):
result = await basic_info()
### check authz
# Parse the form data
form = edict(await quart.request.form)
if not form:
await flash_danger('No vote data provided.')
return quart.redirect(f'/vote-on/{election.eid}', code=303)
# Extract votes from form (keys like 'vote-<iid>')
votes = {}
for key, value in form.items():
if key.startswith('vote-'):
iid = key.split('-', 1)[1]
votes[iid] = value
# Get the list of issues for this election
issues = election.list_issues()
issue_dict = {i.iid: i for i in issues}
# Process each vote
for iid, votestring in votes.items():
if iid not in issue_dict:
await flash_danger(f'Invalid issue ID: {iid}')
return quart.redirect(f'/vote-on/{election.eid}', code=303)
try:
election.add_vote(result.uid, iid, votestring)
_LOGGER.info(
f'User[U:{result.uid}] voted on issue[I:{iid}] in election[E:{election.eid}]'
)
except Exception as e:
_LOGGER.error(
f'Error adding vote for user[U:{result.uid}] on issue[I:{iid}]: {e}'
)
await flash_danger(f'Error submitting vote for issue {iid}.')
return quart.redirect(f'/vote-on/{election.eid}', code=303)
await flash_success('Votes submitted successfully!')
return quart.redirect('/voter', code=303)
@APP.post('/do-create-election')
@asfquart.auth.require({R.pmc_member}) ### need general solution
async def do_create_endpoint():
"Create a new Election."
result = await basic_info()
### check authz
form = edict(await quart.request.form)
# Create the Election.
election = steve.election.Election.create(DB_FNAME, form.title, result.uid)
_LOGGER.info(
f'User[U:{result.uid}] created election[E:{election.eid}];'
f' title: "{form.title}"'
)
await flash_success(f'Created election: {form.title}')
# Go to the management page for the new Election.
return quart.redirect(f'/manage/{election.eid}', code=303)
@APP.get('/do-open/<eid>')
@asfquart.auth.require({R.committer}) ### need general solution
@load_election
async def do_open_endpoint(election):
result = await basic_info()
### check authz
### should open/keep a PersonDB instance in the APP
pdb = steve.persondb.PersonDB.open(DB_FNAME)
# Open the Election.
election.open(pdb)
_LOGGER.info(f'User[U:{result.uid}] opened election[E:{election.eid}]')
title = election.get_metadata().title
await flash_success(f'Opened election: {title}')
# Return to the management page for this Election.
return quart.redirect(f'/manage/{election.eid}', code=303)
@APP.get('/do-close/<eid>')
@asfquart.auth.require({R.committer}) ### need general solution
@load_election
async def do_close_endpoint(election):
result = await basic_info()
### check authz
# Close the Election.
election.close()
_LOGGER.info(f'User[U:{result.uid}] closed election[E:{election.eid}]')
title = election.get_metadata().title
await flash_success(f'Closed election: {title}')
# Return to the management page for this Election.
return quart.redirect(f'/manage/{election.eid}', code=303)
@APP.post('/do-add-issue/<eid>')
@asfquart.auth.require({R.committer}) ### need general solution
@load_election
async def do_add_issue_endpoint(election):
result = await basic_info()
### check authz
form = edict(await quart.request.form)
print('FORM:', form)
### do better with these
vtype = 'yna'
kv = None
### for STV, there is a SEATS form parameter. Create empty LABELMAP.
iid = election.add_issue(form.title, form.description, vtype, kv)
_LOGGER.info(
f'User[U:{result.uid}] added issue[I:{iid}] to election[E:{election.eid}]'
)
await flash_success(f'Issue "{form.title}" has been added.')
# Return to the management page for this Election.
return quart.redirect(f'/manage/{election.eid}', code=303)
@APP.post('/do-edit-issue/<eid>/<iid>')
@asfquart.auth.require({R.committer}) ### need general solution
@load_election_issue
async def do_edit_issue_endpoint(election, issue):
result = await basic_info()
### check authz
form = edict(await quart.request.form)
print('FORM:', form)
# Update the title/description.
### for now, no way to update the vtype or KV pairs.
election.edit_issue(issue.iid, form.title, form.description, issue.vtype, issue.kv)
_LOGGER.info(
f'User[U:{result.uid}] edited issue[I:{issue.iid}]'
f' in election[E:{election.eid}]'
)
# Use the new TITLE for this.
await flash_success(f'Issue "{form.title}" has been updated.')
# Return to the management page for this Election.
return quart.redirect(f'/manage/{election.eid}', code=303)
@APP.post('/do-delete-issue/<eid>/<iid>')
@asfquart.auth.require({R.committer}) ### need general solution
@load_election_issue
async def do_delete_issue_endpoint(election, issue):
result = await basic_info()
### check authz
# Issue exists, and was loaded. No errors to handle?
election.delete_issue(issue.iid)
_LOGGER.info(
f'User[U:{result.uid}] deleted issue[I:{issue.iid}]'
f' from election[E:{election.eid}]'
)
await flash_success(f'Issue "{issue.title}" has been deleted.')
# Return to the management page for this Election.
return quart.redirect(f'/manage/{election.eid}', code=303)
@APP.get('/profile')
@asfquart.auth.require # Bare decorator means just require a valid session
@APP.use_template(TEMPLATES / 'profile.ezt')
async def profile_page():
result = await basic_info()
result.title = 'Profile'
return result
@APP.get('/settings')
@asfquart.auth.require # Bare decorator means just require a valid session
@APP.use_template(TEMPLATES / 'settings.ezt')
async def settings_page():
result = await basic_info()
result.title = 'Settings'
return result
@APP.get('/about')
@APP.use_template(TEMPLATES / 'about.ezt')
async def about_page():
result = await basic_info()
result.title = 'About'
return result
# Serve supporting documents for an issue
@APP.get('/docs/<iid>/<docname>')
@asfquart.auth.require # fine-grained per-doc authz within the handler
async def serve_doc(iid, docname):
result = await basic_info() # get PID ### grr: called uid
db = steve.election.Election.open_database(DB_FNAME)
row = db.q_get_mayvote.first_row(result.uid, iid)
if not row:
# Nothing fancy. Just pretend the file does not exist
quart.abort(404)
# NOTREACHED
### verify the propriety of DOCNAME.
# Return per-issue document.
return await quart.send_from_directory(DOCSDIR / iid, docname)
# Route to serve static files (CSS and JS)
@APP.get('/static/<path:filename>')
async def serve_static(filename):
return await quart.send_from_directory(STATICDIR, filename)
@APP.get('/favicon.ico')
async def serve_favicon():
return await quart.send_from_directory(STATICDIR, 'favicon.ico')
def format_datetime(dt):
"Format a datetime as absolute or relative."
# Carry through an absent datetime.
if not dt:
return None
delta = dt.timestamp() - datetime.datetime.now().timestamp()
if 0 < delta < SOON_CUTOFF:
if delta < SOON_1HOUR:
return f'in about {int(delta / 60)} minutes'
return f'in about {int(delta / 60 / 60)} hours'
return f'on {dt.strftime(FMT_DATE)}' # short format
def postprocess_election(e):
"Post-process attributes in an Election, as an EasyDict."
# NOTE: side effects. This function manipulates the E argument.
# Anything but 1 means the Election is not closed.
e.closed = ezt.boolean(e.closed == 1)
# We sometimes get IS_OPENED or STATE. Other times, neither.
# If no open/closed/state information, then we must be EDITABLE.
if 'is_opened' in e:
# Anything but 1 means the Election has not been opened.
e.is_opened = ezt.boolean(e.is_opened == 1)
elif 'state' not in e:
e.is_opened = None # ezt False
else:
e.is_opened = ezt.boolean(e.state != steve.election.Election.S_EDITABLE)
### note that IS_OPENED is a misnomer since it is True for closed
### elections. We should rename it to LOCKED.
# note: an election has a third Edit state: not open, not closed;
# this is called "editable" (S_EDITABLE)
# Format dates, if present.
dt_open = e.open_at and datetime.datetime.fromtimestamp(e.open_at)
e.fmt_open_at = format_datetime(dt_open)
e.fmt_open_at_full = dt_open and dt_open.strftime(FMT_DATE_FULL)
dt_close = e.close_at and datetime.datetime.fromtimestamp(e.close_at)
e.fmt_close_at = format_datetime(dt_close)
e.fmt_close_at_full = dt_close and dt_close.strftime(FMT_DATE_FULL)
# Add ISO date strings for input fields (YYYY-MM-DD)
e.fmt_open_at_iso = dt_open.date().isoformat() if dt_open else None
e.fmt_close_at_iso = dt_close.date().isoformat() if dt_close else None
### temporary. need to adjust input query.
if 'issue_count' not in e:
e.issue_count = 5 ### arbitrary. just provide a value
### need to figure this out later.
e.has_voted = None # EZT False
return e
def raise_404(template, data):
content = asfquart.utils.render(template, data)
quart.abort(quart.Response(content, status=404, mimetype='text/html'))