You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@steve.apache.org by ad...@apache.org on 2015/04/12 16:15:46 UTC

svn commit: r1673013 - in /steve/steve-web: ./ src/asf/steve/ src/asf/steve/backends/ src/asf/steve/commands/ src/asf/steve/methods/ tests/ tests/data/

Author: adc
Date: Sun Apr 12 14:15:45 2015
New Revision: 1673013

URL: http://svn.apache.org/r1673013
Log:
Sketched out voting methods plugins

Added:
    steve/steve-web/src/asf/steve/election.py
    steve/steve-web/src/asf/steve/methods/
    steve/steve-web/src/asf/steve/methods/__init__.py
    steve/steve-web/src/asf/steve/methods/ap.py
    steve/steve-web/tests/test_methods.py
Modified:
    steve/steve-web/setup.py
    steve/steve-web/src/asf/steve/backends/__init__.py
    steve/steve-web/src/asf/steve/backends/elastic.py
    steve/steve-web/src/asf/steve/backends/file.py
    steve/steve-web/src/asf/steve/commands/mkelection.py
    steve/steve-web/tests/data/steve.cfg
    steve/steve-web/tests/test_steve.py

Modified: steve/steve-web/setup.py
URL: http://svn.apache.org/viewvc/steve/steve-web/setup.py?rev=1673013&r1=1673012&r2=1673013&view=diff
==============================================================================
--- steve/steve-web/setup.py (original)
+++ steve/steve-web/setup.py Sun Apr 12 14:15:45 2015
@@ -8,7 +8,7 @@
 # "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
+# 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
@@ -20,10 +20,11 @@
 
 from io import open
 import subprocess
-import sys
 
 from setuptools import find_packages, setup, Command, os
 
+import sys
+
 
 VERSION = '1.0'
 
@@ -64,7 +65,6 @@ class Doc(Command):
 with open('requirements.txt') as f:
     required = f.read().splitlines()
 
-
 setup(
     name='steve-site',
     version=VERSION,
@@ -87,15 +87,21 @@ setup(
 
     tests_require=['tox'],
 
-    entry_points='''
-    [console_scripts]
-    setup = asf.steve.commands.setup:main
-    mkelection = asf.steve.commands.mkelection:main
-
-    [steve_backend.plugins]
-    file-be = asf.steve.backends.file:FileBackend
-    elastic-storage-be = asf.steve.backends.elastic:ElasticStorageBackend
-    ''',
+    entry_points={
+        'console_scripts': [
+            'setup = asf.steve.commands.setup:main',
+            'mkelection = asf.steve.commands.mkelection:main'
+        ],
+
+        'steve_backend.plugins': [
+            'file-be = asf.steve.backends.file:FileBackend',
+            'elastic-storage-be = asf.steve.backends.elastic:ElasticStorageBackend'
+        ],
+
+        'steve_voting.plugins': [
+            'ap = asf.steve.methods.ap:Ap'
+        ]
+    },
 
     classifiers=[
         'Intended Audience :: Developers',

Modified: steve/steve-web/src/asf/steve/backends/__init__.py
URL: http://svn.apache.org/viewvc/steve/steve-web/src/asf/steve/backends/__init__.py?rev=1673013&r1=1673012&r2=1673013&view=diff
==============================================================================
--- steve/steve-web/src/asf/steve/backends/__init__.py (original)
+++ steve/steve-web/src/asf/steve/backends/__init__.py Sun Apr 12 14:15:45 2015
@@ -51,4 +51,4 @@ def load_plugin(plugin_name, configurati
     plugin_properties = util.properties_from_section(configurations, plugin_name)
     plugin = plugin_class(**plugin_properties)
 
-    return plugin
\ No newline at end of file
+    return plugin

Modified: steve/steve-web/src/asf/steve/backends/elastic.py
URL: http://svn.apache.org/viewvc/steve/steve-web/src/asf/steve/backends/elastic.py?rev=1673013&r1=1673012&r2=1673013&view=diff
==============================================================================
--- steve/steve-web/src/asf/steve/backends/elastic.py (original)
+++ steve/steve-web/src/asf/steve/backends/elastic.py Sun Apr 12 14:15:45 2015
@@ -66,7 +66,7 @@ class ElasticStorageBackend(object):
 
         return self.client.exists(index=ELASTIC_INDEX_NAME, doc_type=doc_type, id=eid)
 
-    def get_basedata(self, election):
+    def get_base_data(self, election):
         """ Get base data from an election
         :param str election: the id of the election
         :return: the base data from an election

Modified: steve/steve-web/src/asf/steve/backends/file.py
URL: http://svn.apache.org/viewvc/steve/steve-web/src/asf/steve/backends/file.py?rev=1673013&r1=1673012&r2=1673013&view=diff
==============================================================================
--- steve/steve-web/src/asf/steve/backends/file.py (original)
+++ steve/steve-web/src/asf/steve/backends/file.py Sun Apr 12 14:15:45 2015
@@ -19,7 +19,7 @@
 import hashlib
 import json
 import os
-import random
+
 import time
 
 
@@ -27,36 +27,178 @@ class FileBackend(object):
     def __init__(self, home_dir):
         self.home_dir = home_dir
 
-    def create_election(self, eid, title, owner, monitors, starts, ends, is_open):
-        """ Create an election
-        :param str eid: Election ID
-        :param str title: the title (name) of the election
-        :param str owner: the owner of this election
-        :param list monitors: email addresses to use for monitoring
-        :param str starts: the start date of the election
-        :param str ends: the end date of the election
-        :param bool is_open: flag that indicates if election is open to the public or not
-        """
-        election_hash = hashlib.sha512('%f-stv-%s' % (time.time(), os.environ['REMOTE_ADDR'] if 'REMOTE_ADDR' in os.environ else random.randint(1, 99999999999))).hexdigest(),
-
-        base_data = {
-            'id': eid,
-            'title': title,
-            'owner': owner,
-            'monitors': monitors,
-            'starts': starts,
-            'ends': ends,
-            'hash': election_hash,
-            'open': is_open
+    def document_exists(self, election, *issue):
+        "Returns True if an election/issue exists, False otherwise"
+        elpath = os.path.join(self.home_dir, 'issues', election)
+        if issue:
+            elpath += '/' + issue[0] + '.json'
+            return os.path.isfile(elpath)
+        else:
+            return os.path.isdir(elpath)
+
+    def get_base_data(self, election):
+        "Get base data from an election"
+        elpath = os.path.join(self.home_dir, 'issues', election)
+        if os.path.isdir(elpath):
+            with open(elpath + '/basedata.json', 'r') as f:
+                data = f.read()
+                f.close()
+                basedata = json.loads(data)
+
+                basedata['id'] = election
+                return basedata
+        return None
+
+    def close(self, election, reopen=False):
+        "Mark an election as closed"
+
+        elpath = os.path.join(self.home_dir, 'issues', election)
+        basedata = self.get_base_data(election)
+        if reopen:
+            basedata['closed'] = False
+        else:
+            basedata['closed'] = True
+        with open(elpath + '/basedata.json', 'w') as f:
+            f.write(json.dumps(basedata))
+            f.close()
+
+    def issue_get(self, electionID, issueID):
+        "Get JSON data from an issue"
+        issuedata = None
+        ihash = ''
+        issuepath = os.path.join(self.home_dir, 'issues', electionID, issueID) + '.json'
+        if os.path.isfile(issuepath):
+            with open(issuepath, 'r') as f:
+                data = f.read()
+                ihash = hashlib.sha224(data).hexdigest()
+                f.close()
+                issuedata = json.loads(data)
+
+        return issuedata, ihash
+
+    def votes_get(self, electionID, issueID):
+        "Read votes from the vote file"
+        rvotes = self.votes_get_raw(electionID, issueID)
+        votes = {}
+        for key in rvotes:
+            votes[key] = rvotes[key]['vote']
+        return {}
+
+    def votes_get_raw(self, electionID, issueID):
+        issuepath = os.path.join(self.home_dir, 'issues', electionID, issueID) + '.json.votes'
+        if os.path.isfile(issuepath):
+            with open(issuepath, 'r') as f:
+                votes = json.loads(f.read())
+                f.close()
+                return votes
+        return {}
+
+    def election_create(self, eid, basedata):
+        elpath = os.path.join(self.home_dir, 'issues', eid)
+        os.mkdir(elpath)
+        with open(elpath + '/basedata.json', 'w') as f:
+            f.write(json.dumps(basedata))
+            f.close()
+        with open(elpath + '/voters.json', 'w') as f:
+            f.write('{}')
+            f.close()
+
+    def election_update(self, electionID, basedata):
+        elpath = os.path.join(self.home_dir, 'issues', electionID)
+        with open(elpath + '/basedata.json', 'w') as f:
+            f.write(json.dumps(basedata))
+            f.close()
+
+    def issue_update(self, electionID, issueID, issueData):
+        issuepath = os.path.join(self.home_dir, 'issues', electionID, issueID) + '.json'
+        with open(issuepath, 'w') as f:
+            f.write(json.dumps(issueData))
+            f.close()
+
+    def issue_list(self, election):
+        "List all issues in an election"
+        issues = []
+        elpath = os.path.join(self.home_dir, 'issues', election)
+        if os.path.isdir(elpath):
+            issues = [f.strip('.json') for f in os.listdir(elpath) if os.path.isfile(os.path.join(elpath, f)) and f != 'basedata.json' and f != 'voters.json' and f.endswith('.json')]
+        return issues
+
+    def election_list(self):
+        "List all elections"
+        elections = []
+        path = os.path.join(self.home_dir, 'issues')
+        if os.path.isdir(path):
+            elections = [f for f in os.listdir(path) if os.path.isdir(os.path.join(path, f))]
+        return elections
+
+    def vote(self, electionID, issueID, uid, vote):
+        "Casts a vote on an issue"
+        votes = {}
+        issuepath = os.path.join(self.home_dir, 'issues', electionID, issueID) + '.json'
+        if os.path.isfile(issuepath + '.votes'):
+            with open(issuepath + '.votes', 'r') as f:
+                votes = json.loads(f.read())
+                f.close()
+        votes[uid] = {
+            'vote': vote,
+            'timestamp': time.time()
         }
+        with open(issuepath + '.votes', 'w') as f:
+            f.write(json.dumps(votes))
+            f.close()
 
-        election_path = os.path.join(self.home_dir, 'issues', eid)
-        os.mkdir(election_path)
+    def issue_delete(self, electionID, issueID):
+        "Deletes an issue if it exists"
+
+        issuepath = os.path.join(self.home_dir, 'issues', electionID, issueID) + '.json'
+        if os.path.isfile(issuepath):
+            os.unlink(issuepath)
+        if os.path.isfile(issuepath + '.votes'):
+            os.unlink(issuepath + '.votes')
+
+    def issue_create(self, electionID, issueID, data):
+        issuepath = os.path.join(self.home_dir, 'issues', electionID, issueID) + '.json'
+        with open(issuepath, 'w') as f:
+            f.write(json.dumps(data))
+            f.close()
 
-        with open(os.path.join(election_path, 'basedata.json'), 'w') as f:
-            f.write(json.dumps(base_data, sort_keys=True, indent=1))
+    def voter_get_uid(self, electionID, votekey):
+        "Get vote UID/email with a given vote key hash"
+        elpath = os.path.join(self.home_dir, 'issues', electionID)
+        with open(elpath + '/voters.json', 'r') as f:
+            voters = json.loads(f.read())
+            f.close()
+            for voter in voters:
+                if voters[voter] == votekey:
+                    return voter
+        return None
+
+    def voter_add(self, election, PID, xhash):
+        elpath = os.path.join(self.home_dir, 'issues', election)
+        with open(elpath + '/voters.json', 'r') as f:
+            voters = json.loads(f.read())
+            f.close()
+        voters[PID] = xhash
+        with open(elpath + '/voters.json', 'w') as f:
+            f.write(json.dumps(voters))
             f.close()
 
-        with open(os.path.join(election_path, 'voters.json'), 'w') as f:
-            f.write("{}")
+    def voter_remove(self, election, UID):
+        elpath = os.path.join(self.home_dir, 'issues', election)
+        with open(elpath + '/voters.json', 'r') as f:
+            voters = json.loads(f.read())
             f.close()
+        if UID in voters:
+            del voters[UID]
+        with open(elpath + '/voters.json', 'w') as f:
+            f.write(json.dumps(voters))
+            f.close()
+
+    def voter_has_voted(self, election, issue, uid):
+        path = os.path.join(self.home_dir, 'issues', election, issue)
+        votes = {}
+        if os.path.isfile(path + '.json.votes'):
+            with open(path + '.json.votes', 'r') as f:
+                votes = json.loads(f.read())
+                f.close()
+        return True if uid in votes else False

Modified: steve/steve-web/src/asf/steve/commands/mkelection.py
URL: http://svn.apache.org/viewvc/steve/steve-web/src/asf/steve/commands/mkelection.py?rev=1673013&r1=1673012&r2=1673013&view=diff
==============================================================================
--- steve/steve-web/src/asf/steve/commands/mkelection.py (original)
+++ steve/steve-web/src/asf/steve/commands/mkelection.py Sun Apr 12 14:15:45 2015
@@ -15,11 +15,11 @@
 # limitations under the License.
 #
 import random
-import time
 
 import click
 
-from asf.steve import backends
+import time
+from asf.steve import backends, election
 from asf.steve.commands import Config
 
 
@@ -50,7 +50,15 @@ def main(cfg_file, eid, owner, title, mo
     click.echo('Using backend %s' % backend)
     be_plugin = backends.load_plugin(backend, cfg_file)
 
-    be_plugin.create_election(eid, title, owner, monitors, '', '', public)
+    if not cfg_file.has_option('general', 'root_url'):
+        raise click.ClickException('Cannot find root_url in [general] section')
+
+    root_url = cfg_file.get('general', 'root_url')
+
+    e = election.Election(be_plugin, root_url)
+
+    e.create_election(eid, title, owner, monitors, '', '', public)
+
 
 if __name__ == '__main__':
     main()

Added: steve/steve-web/src/asf/steve/election.py
URL: http://svn.apache.org/viewvc/steve/steve-web/src/asf/steve/election.py?rev=1673013&view=auto
==============================================================================
--- steve/steve-web/src/asf/steve/election.py (added)
+++ steve/steve-web/src/asf/steve/election.py Sun Apr 12 14:15:45 2015
@@ -0,0 +1,176 @@
+#
+# 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.
+#
+import hashlib
+import os
+import random
+
+import time
+from asf.steve import methods
+
+
+class Election(object):
+    def __init__(self, backend, root_url):
+        self.backend = backend
+        self.root_url = root_url
+
+    def exists(self, election, issue=None):
+        """ Does this election or issue exist?
+        :param str election: the id of the election
+        :param str issue: the name of the issue
+        :return bool: True if an election/issue exists, False otherwise
+        """
+        return self.backend.document_exists(election, issue)
+
+    def get_base_data(self, election):
+        """ Get base data from an election
+        :param str election: the id of the election
+        :return:
+        """
+        return self.backend.get_base_data(election)
+
+    def close(self, election, reopen=False):
+        """ Mark an election as closed
+        :param str election: the id of the election
+        :param bool reopen:
+        """
+        self.backend.close(election, reopen)
+
+    def get_issue(self, election, issue):
+        """ Get JSON data from an issue
+        :param str election: the id of the election
+        :param str issue: the name of the issue
+        :return dict: JSON data from an issue
+        """
+        issue_data, issue_hash = self.backend.issue_get(election, issue)
+
+        if issue_data:
+            issue_data['hash'] = issue_hash
+            issue_data['id'] = issue
+            issue_data['APIURL'] = "https://%s/steve/voter/view/%s/%s" % (self.root_url, election, issue)
+            issue_data['prettyURL'] = "https://%s/steve/ballot?%s/%s" % (self.root_url, election, issue)
+
+            # Add vote category for JS magic
+            for vtype in constants.VOTE_TYPES:
+                if vtype['key'] == issue_data['type']:
+                    issue_data['category'] = vtype['category']
+                    break
+
+        return issue_data
+
+    def get_votes(self, election, issue):
+        """ Read votes from the vote file
+        :param str election: the id of the election
+        :param str issue: the name of the issue
+        :return dict: votes from the vote file
+        """
+        return self.backend.votes_get(election, issue)
+
+    def get_votes_raw(self, election, issue):
+        return self.backend.votes_get_raw(election, issue)
+
+    def create_election(self, eid, title, owner, monitors, starts, ends, isopen):
+        base_data = {
+            'id': eid,
+            'title': title,
+            'owner': owner,
+            'monitors': monitors,
+            'starts': starts,
+            'ends': ends,
+            'hash': hashlib.sha512("%f-stv-%s" % (time.time(), os.environ['REMOTE_ADDR'] if 'REMOTE_ADDR' in os.environ else random.randint(1, 99999999999))).hexdigest(),
+            'open': isopen
+        }
+        self.backend.election_create(eid, base_data)
+
+    def update_election(self, election, base_data):
+        self.backend.election_update(election, base_data)
+
+    def update_issue(self, election, issue, issue_data):
+        self.backend.issue_update(election, issue, issue_data)
+
+    def list_issues(self, election):
+        """ List all issues in an election
+        :param str election: the id of the election
+        :return list: issues in an election
+        """
+        return self.backend.issue_list(election)
+
+    def list_elections(self):
+        """ List all elections
+        :return list: all elections
+        """
+        return self.backend.election_list()
+
+    def vote(self, election, issue, voter, vote):
+        """ Casts a vote on an issue
+        :param str election: the id of the election
+        :param str issue: the name of the issue
+        :param str voter: the id of the voter
+        :param str vote: the id of the vote
+        :return str: the hash of the vote
+        """
+        base_data = self.get_base_data(election)
+        issue_data = self.get_issue(election, issue)
+        if base_data and issue_data:
+            vote_hash = hashlib.sha224(base_data['hash'] + issue + voter + vote).hexdigest()
+
+            # Vote verification
+            voting_method = methods.plugins()[issue_data['type']]
+
+            # This will/should raise an exception if the vote is invalid
+            uid = voter.get(election, base_data, voter)
+            voting_method.vote(base_data, issue, voter, vote, uid)
+
+            self.backend.vote(election, issue, voter, vote)
+
+            # LURK on who voted :O :O :O
+            # if config.has_option("general", "lurk") and config.get("general", "lurk") == "yes":
+            # email = voter.get(election, base_data, voterID)
+            # self.backend['lurk'](election, email)
+
+            return vote_hash
+        else:
+            raise Exception("No such election")
+
+    def delete_issue(self, election, issue):
+        """ Deletes an issue if it exists
+        :param str election: the id of the election
+        :param str issue: the name of the issue
+        """
+
+        if self.exists(election, issue):
+            self.self.backend.issue_delete(election, issue)
+        else:
+            raise Exception("No such election")
+
+    def get_hash(self, election):
+        issues = self.list_issues(election)
+        ihash = ""
+        output = []
+        for issue in issues:
+            issuedata = self.get_issue(election, issue)
+            votes = self.get_votes(election, issue)
+            ihash += issuedata['hash']
+            output.append("Issue #%s: %s\n- Checksum: %s\n- Votes cast: %u\n" % (issue, issuedata['title'], issuedata['hash'], len(votes)))
+        tothash = hashlib.sha224(ihash).hexdigest()
+        output.insert(0, ("You are receiving this data because you are listed as a monitor for this election.\nThe following data shows the state of the election data on disk. If any of these checksums change, especially the main checksum, then the election has been edited (rigged?) after invites were sent out.\n\nMain Election Checksum : %s\n\n" % tothash))
+        output.append("\nYou can monitor votes and recasts online at: %s/monitor.html?%s" % (self.root_url, election))
+        return tothash, "\n".join(output)
+
+    def create_issue(self, election, issue, data):
+        self.backend.issue_create(election, issue, data)

Added: steve/steve-web/src/asf/steve/methods/__init__.py
URL: http://svn.apache.org/viewvc/steve/steve-web/src/asf/steve/methods/__init__.py?rev=1673013&view=auto
==============================================================================
--- steve/steve-web/src/asf/steve/methods/__init__.py (added)
+++ steve/steve-web/src/asf/steve/methods/__init__.py Sun Apr 12 14:15:45 2015
@@ -0,0 +1,37 @@
+#
+# 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.
+#
+""" Voting methods plugins
+"""
+import pkg_resources
+
+VOTING_ENTRY_POINT = 'steve_voting.plugins'
+
+PLUGINS = {}
+
+
+def plugins():
+    """ Load the set of voting method plugins whose registered entry point is steve_voting.plugins
+    :return dict: a dictionary of voting method plugins indexed by their name
+    """
+    if not PLUGINS:
+
+        for entry_point in pkg_resources.iter_entry_points(VOTING_ENTRY_POINT):
+            PLUGINS[entry_point.name] = entry_point.load(require=False)
+
+    return PLUGINS

Added: steve/steve-web/src/asf/steve/methods/ap.py
URL: http://svn.apache.org/viewvc/steve/steve-web/src/asf/steve/methods/ap.py?rev=1673013&view=auto
==============================================================================
--- steve/steve-web/src/asf/steve/methods/ap.py (added)
+++ steve/steve-web/src/asf/steve/methods/ap.py Sun Apr 12 14:15:45 2015
@@ -0,0 +1,86 @@
+#
+# 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.
+#
+
+
+class Ap(object):
+    key = 'ap'
+    description = 'ASF PMC Style vote (YNA with binding votes)'
+    category = 'ap'
+
+    def validate(self, vote, issue):
+        """ Tries to validate a vote
+        :param vote:
+        :param issue:
+        :return: returns why if not valid, None otherwise
+        """
+        letters = ['y', 'n', 'a', 'by', 'bn']
+        if len(vote) >= 3 or vote not in letters:
+            return "Invalid vote. Accepted votes are: %s" % ", ".join(letters)
+        return None
+
+    def vote(self, basedata, issueID, voterID, vote, uid):
+        """ Invalidate a binding vote if not allowed to cast such
+        :param basedata:
+        :param issueID:
+        :param voterID:
+        :param vote:
+        :param uid:
+        :return:
+        """
+        if vote.startswith('b'):
+            # Simple check example: if not apache committer, discard vote if binding
+            if not uid.endswith("@apache.org"):
+                raise Exception("You are not allowed to cast a binding vote!")
+
+    def tally(self, votes, issue):
+        """ Simple YNA tallying
+        :param votes: The JSON object from $issueid.json.votes
+        :return: dict with y,n,a,by,bn numbers as well as pretty-printed version
+        """
+        y = n = a = 0
+        by = bn = 0
+        # For each vote cast, tally it
+        for vote in votes.values():
+            if vote == 'y':
+                y += 1
+            elif vote == 'n':
+                n += 1
+            elif vote == 'a':
+                a += 1
+            elif vote == 'by':
+                by += 1
+            elif vote == 'bn':
+                bn += 1
+            else:
+                raise Exception("Invalid vote found in votes db!")
+
+        js = {
+            'votes': len(votes),
+            'yes': y,
+            'no': n,
+            'abstain': a,
+            'binding_yes': by,
+            'binding_no': bn
+        }
+
+        return js, """
+Yes:            %4u
+No:             %4u
+Abstain:        %4u
+Binding Yes:    %4u
+Binding No:     %4u
+""" % (y, n, a, by, bn)

Modified: steve/steve-web/tests/data/steve.cfg
URL: http://svn.apache.org/viewvc/steve/steve-web/tests/data/steve.cfg?rev=1673013&r1=1673012&r2=1673013&view=diff
==============================================================================
--- steve/steve-web/tests/data/steve.cfg (original)
+++ steve/steve-web/tests/data/steve.cfg Sun Apr 12 14:15:45 2015
@@ -16,6 +16,7 @@
 #
 [general]
 homedir=/var/tmp
+root_url=http://demo.stv.website
 
 [karma]
 adc=d

Added: steve/steve-web/tests/test_methods.py
URL: http://svn.apache.org/viewvc/steve/steve-web/tests/test_methods.py?rev=1673013&view=auto
==============================================================================
--- steve/steve-web/tests/test_methods.py (added)
+++ steve/steve-web/tests/test_methods.py Sun Apr 12 14:15:45 2015
@@ -0,0 +1,27 @@
+#
+# 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.
+#
+import pytest
+
+from asf.steve import methods
+
+
+def test_load_plugins():
+    plugins = methods.plugins()
+
+    assert 'ap' in plugins

Modified: steve/steve-web/tests/test_steve.py
URL: http://svn.apache.org/viewvc/steve/steve-web/tests/test_steve.py?rev=1673013&r1=1673012&r2=1673013&view=diff
==============================================================================
--- steve/steve-web/tests/test_steve.py (original)
+++ steve/steve-web/tests/test_steve.py Sun Apr 12 14:15:45 2015
@@ -20,8 +20,9 @@ from asf.steve import util
 
 
 class MockPlugin(object):
-    def __init__(self, homedir):
+    def __init__(self, homedir, root_url):
         self.homedir = homedir
+        self.root_url = root_url
 
 
 def test_properties_from_section(steve_cfg):
@@ -36,3 +37,4 @@ def test_steve_cfg(steve_cfg):
 
     assert plugin
     assert plugin.homedir == '/var/tmp'
+    assert plugin.root_url == 'http://demo.stv.website'