From 0e21faff8279cf573c0483ff3be5092beef0e8e9 Mon Sep 17 00:00:00 2001 From: INOPIAE Date: Wed, 16 Jan 2019 07:27:38 +0100 Subject: [PATCH] add: close motion on request --- README.md | 12 +++++++++ motion.py | 31 +++++++++++++++++++----- templates/single_motion.html | 7 +++++- tests/test_motion.py | 47 +++++++++++++++++++++++++++++++++--- 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 20331c3..a3fd493 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,18 @@ python -m unittest tests/test_motion.py The database schema is automatically installed when the table "schema_version" does not exist and the application is started. +The following user rights can be granted: +- create: user is able to create a new motion +- vote: user is able to vote running motions +- cancel: user is able to cancel a running motion +- finish: user is able to close a running motion +- audit: user is able to see given votes of a finished motion + +To grant right use the following (here with vote right as example): +- on all groups add "vote:*" +- on one given group add "vote:group1" +- on two given groups add "vote:group1 vote:group2" + # Usage Within the motion content markdown can be used for formatting e.g. diff --git a/motion.py b/motion.py index 0314550..1c83fc9 100644 --- a/motion.py +++ b/motion.py @@ -2,10 +2,12 @@ from flask import g from flask import Flask from flask import render_template, redirect from flask import request +from functools import wraps import postgresql import filters from flaskext.markdown import Markdown from markdown.extensions import Extension +from datetime import date, time, datetime def get_db(): db = getattr(g, '_database', None) @@ -63,7 +65,6 @@ def lookup_user(): user = env.get("USER") roles = env.get("ROLES") - if user is None: return "Server misconfigured", 500 roles = roles.split(" ") @@ -203,20 +204,38 @@ def put_motion(): def motion_edited(motion): return rel_redirect("/?start=" + str(motion) + "#motion-" + str(motion)) + -@app.route("/motion//cancel", methods=['POST']) -def cancel_motion(motion): - rv = get_db().prepare("SELECT id, type FROM motion WHERE identifier=$1 AND host=$2")(motion, request.host); +def validate_access(data): + rv = get_db().prepare("SELECT id, type, deadline, canceled FROM motion WHERE identifier=$1 AND host=$2")(data[0], data[1]); if len(rv) == 0: return "Error, Not found", 404 id = rv[0].get("id") - if not may("cancel", rv[0].get("type")): + if not may(data[2], rv[0].get("type")): return "Forbidden", 403 + if rv[0].get("deadline") < datetime.now() or rv[0].get("canceled") is not None: + return "Error, out of time", 403 + return id + + +@app.route("/motion//cancel", methods=['POST']) +def cancel_motion(motion): + id = validate_access([motion, request.host, 'cancel']) + if not isinstance(id, int): + return id[0], id[1] if request.form.get("reason", "none") == "none": return "Error, form requires reason", 500 rv = get_db().prepare("UPDATE motion SET canceled=CURRENT_TIMESTAMP, cancelation_reason=$1, canceled_by=$2 WHERE identifier=$3 AND host=$4 AND canceled is NULL")(request.form.get("reason", ""), g.voter, motion, request.host) return motion_edited(id) +@app.route("/motion//finish", methods=['POST']) +def finish_motion(motion): + id = validate_access([motion, request.host, 'finish']) + if not isinstance(id, int): + return id[0], id[1] + rv = get_db().prepare("UPDATE motion SET deadline=CURRENT_TIMESTAMP WHERE identifier=$1 AND host=$2 AND canceled is NULL")(motion, request.host) + return motion_edited(id) + @app.route("/motion/") def show_motion(motion): p = get_db().prepare("SELECT motion.*, poser.email AS poser, canceler.email AS canceler, (motion.deadline > CURRENT_TIMESTAMP AND canceled is NULL) AS running, vote.result FROM motion "\ @@ -230,7 +249,7 @@ def show_motion(motion): votes = None if may("audit", rv[0].get("type")) and not rv[0].get("running") and not rv[0].get("canceled"): votes = get_db().prepare("SELECT vote.result, voter.email FROM vote INNER JOIN voter ON voter.id = vote.voter_id WHERE vote.motion_id=$1")(rv[0].get("id")); - return render_template('single_motion.html', motion=rv[0], may_vote=may("vote", rv[0].get("type")), may_cancel=may("cancel", rv[0].get("type")), votes=votes, singlemotion=True) + return render_template('single_motion.html', motion=rv[0], may_vote=may("vote", rv[0].get("type")), may_cancel=may("cancel", rv[0].get("type")), may_finish=may("finish", rv[0].get("type")), votes=votes, singlemotion=True) @app.route("/motion//vote", methods=['POST']) def vote(motion): diff --git a/templates/single_motion.html b/templates/single_motion.html index c265432..8935636 100644 --- a/templates/single_motion.html +++ b/templates/single_motion.html @@ -27,7 +27,12 @@ Motion: {{motion.name}} {%- if may_cancel %}
- +
+
+{%- endif %} +{%- if may_finish %} +
+
{%- endif %} {%- endif %} diff --git a/tests/test_motion.py b/tests/test_motion.py index c1f996c..1577f3e 100644 --- a/tests/test_motion.py +++ b/tests/test_motion.py @@ -48,6 +48,12 @@ class BasicTest(TestCase): data=dict(reason=reason) ) + def finishMotion(self, user, motion): + return self.app.post( + '/motion/' + motion +'/finish', + environ_base={'USER_ROLES': user} + ) + def buildResultText(self, motiontext, yes, no, abstain): return '

'+motiontext+'

\n

\nYes '+str(yes)+'
'\ + '\nNo '+str(no)+'
'\ @@ -292,6 +298,12 @@ class VoterTests(BasicTest): self.assertEqual(response.status_code, 403) self.assertIn(str.encode('Forbidden'), response.data) + def test_finishMotion(self): + motion='g1.20200402.004' + response = self.finishMotion(user, motion) + self.assertEqual(response.status_code, 403) + self.assertIn(str.encode('Forbidden'), response.data) + def test_see_old_vote(self): motion='g1.20200402.002' result = self.app.get('/motion/' + motion, environ_base={'USER_ROLES': user}, follow_redirects=True) @@ -313,7 +325,7 @@ class CreateMotionTests(BasicTest): def setUp(self): self.init_test() global user - user='testuser/vote:* create:* cancel:*' + user='testuser/vote:* create:* cancel:* finish:*' self.db_clear() def tearDown(self): @@ -428,18 +440,47 @@ class CreateMotionTests(BasicTest): self.assertEqual(response.status_code, 500) self.assertIn(str.encode('Error, form requires reason'), response.data) - reason='cancel test' + reason='cancel-test' response = self.cancelMotion(user, motion, reason) self.assertEqual(response.status_code, 302) result = self.app.get('/', environ_base={'USER_ROLES': user}) self.assertIn(b'Cancelation reason: ' + str.encode(reason), result.data) - motion='g1.30190402.001' + motion='g1.20190402.001' reason="none" response = self.cancelMotion(user, motion, reason) self.assertEqual(response.status_code, 404) self.assertIn(str.encode('Error, Not found'), response.data) + motion='g1.30200402.001' + reason="cancel-test" + response = self.cancelMotion(user, motion, reason) + self.assertEqual(response.status_code, 404) + self.assertIn(str.encode('Error, Not found'), response.data) + + motion='g1.20200402.004' + response = self.cancelMotion(user, motion, reason) + self.assertEqual(response.status_code, 403) + self.assertIn(str.encode('Error, out of time'), response.data) + + def test_finishMotion(self): + self.db_sampledata() + + motion='g1.20200402.004' + response = self.finishMotion(user, motion) + self.assertEqual(response.status_code, 302) + result = self.app.get('/', environ_base={'USER_ROLES': user}) + self.assertIn(b'Motion D (Finished)', result.data) + + motion='g1.30190402.001' + response = self.finishMotion(user, motion) + self.assertEqual(response.status_code, 404) + self.assertIn(str.encode('Error, Not found'), response.data) + + motion='g1.20200402.001' + response = self.finishMotion(user, motion) + self.assertEqual(response.status_code, 403) + self.assertIn(str.encode('Error, out of time'), response.data) class AuditMotionTests(BasicTest): -- 2.39.2