]> WPIA git - motion.git/blob - motion.py
add: add footer with copyright and legal requirements
[motion.git] / motion.py
1 from flask import g
2 from flask import Flask
3 from flask import render_template, redirect
4 from flask import request
5 from functools import wraps
6 import postgresql
7 import filters
8 from flaskext.markdown import Markdown
9 from markdown.extensions import Extension
10 from datetime import date, time, datetime
11
12 def get_db():
13     db = getattr(g, '_database', None)
14     if db is None:
15         db = g._database = postgresql.open(app.config.get("DATABASE"), user=app.config.get("USER"), password=app.config.get("PASSWORD"))
16     #db.row_factory = sqlite3.Row
17     return db
18
19 app = Flask(__name__)
20 app.register_blueprint(filters.blueprint)
21
22 class EscapeHtml(Extension):
23     def extendMarkdown(self, md, md_globals):
24         del md.preprocessors['html_block']
25         del md.inlinePatterns['html']
26
27 md = Markdown(app, extensions=[EscapeHtml()])
28
29 class default_settings(object):
30     COPYRIGHTSTART="2017"
31     COPYRIGHTNAME="WPIA"
32     COPYRIGHTLINK="https://wpia.club"
33     IMPRINTLINK="https://documents.wpia.club/imprint.html"
34     DATAPROTECTIONLINK="https://documents.wpia.club/data_privacy_policy_html_pages_en.html"
35
36
37 # Load config
38 app.config.from_object('motion.default_settings')
39 app.config.from_pyfile('config.py')
40
41
42 class ConfigProxy:
43     def __init__(self, name):
44         self.name = name
45     @property
46     def per_host(self):
47         dict = app.config.get(self.name)
48         if dict is None:
49             return None
50         return dict.get(request.host)
51
52 prefix = ConfigProxy("GROUP_PREFIX")
53 times = ConfigProxy("DURATION")
54 debuguser = ConfigProxy("DEBUGUSER")
55
56 @app.before_request
57 def lookup_user():
58     global prefix
59
60     env = request.environ
61     user = None
62     my_debuguser = debuguser.per_host
63     if my_debuguser is not None:
64         parts = my_debuguser.split("/", 1)
65         user = parts[0]
66         roles = parts[1]
67
68     if "USER_ROLES" in env:
69         parts = env.get("USER_ROLES").split("/", 1)
70         user = parts[0]
71         roles = parts[1]
72
73     if "USER" in env and "ROLES" in env:
74         user = env.get("USER")
75         roles = env.get("ROLES")
76
77     if user is None:
78         return "Server misconfigured", 500
79     roles = roles.split(" ")
80
81     if user == "<invalid>":
82         return "Access denied", 403;
83
84     db = get_db()
85     with db.xact():
86         rv = db.prepare("SELECT id FROM voter WHERE email=$1")(user)
87         if len(rv) == 0:
88             db.prepare("INSERT INTO voter(\"email\") VALUES($1)")(user)
89             rv = db.prepare("SELECT id FROM voter WHERE email=$1")(user)
90         g.voter = rv[0].get("id");
91     g.user = user
92     g.roles = {}
93
94     for r in roles:
95         a = r.split(":", 1)
96         if len(r)!=0:
97             val = a[1]
98             if a[0] not in g.roles:
99                 g.roles[a[0]] = []
100             if val == "*":
101                 g.roles[a[0]] = [group for group in prefix.per_host]
102             else:
103                 g.roles[a[0]].append(val)
104     return None
105
106 @app.context_processor
107 def init_footer_variables():
108     if int(app.config.get("COPYRIGHTSTART"))<datetime.now().year:
109         version_year = "%s - %s" % (app.config.get("COPYRIGHTSTART"), datetime.now().year)
110     else:
111         version_year = datetime.now().year
112
113     return dict(
114         footer = dict( version_year=version_year, 
115             copyright_link=app.config.get("COPYRIGHTLINK"),
116             copyright_name=app.config.get("COPYRIGHTNAME"),
117             imprint_link=app.config.get("DATAPROTECTIONLINK"),
118             dataprotection_link=app.config.get("DATAPROTECTIONLINK")
119         )
120     )
121
122
123 def get_allowed_cats(action):
124     return g.roles.get(action, []);
125
126 def may(action, motion):
127     return motion in get_allowed_cats(action)
128
129 @app.teardown_appcontext
130 def close_connection(exception):
131     db = getattr(g, '_database', None)
132     if db is not None:
133         db.close()
134
135 def init_db():
136     with app.app_context():
137         db = get_db()
138         try:
139             ver = db.prepare("SELECT version FROM schema_version")()[0][0];
140             print("Database Schema version: ", ver)
141         except postgresql.exceptions.UndefinedTableError:
142             g._database = None
143             db = get_db()
144             ver = 0
145
146         if ver < 1:
147             with app.open_resource('sql/schema.sql', mode='r') as f:
148                 db.execute(f.read())
149             return
150
151         if ver < 2:
152             with app.open_resource('sql/from_1.sql', mode='r') as f:
153                 db.execute(f.read())
154                 ct={}
155                 for group in [group for group in prefix[app.config.get("DEFAULT_HOST")]]:
156                     ct[group] = {"dt": "", "c": 0}
157
158                 p = db.prepare("UPDATE \"motion\" SET \"identifier\"=$1 WHERE \"id\"=$2")
159                 for row in db.prepare("SELECT id, \"type\", \"posed\" FROM \"motion\" ORDER BY \"id\" ASC"):
160                     dt=row[2].strftime("%Y%m%d")
161                     if ct[row[1]]["dt"] != dt:
162                         ct[row[1]]["dt"] = dt
163                         ct[row[1]]["c"] = 0
164                     ct[row[1]]["c"] = ct[row[1]]["c"] + 1
165                     name=prefix[app.config.get("DEFAULT_HOST")][row[1]]+"."+dt+"."+("%03d" % ct[row[1]]["c"])
166                     p(name, row[0])
167                 db.prepare("ALTER TABLE \"motion\" ALTER COLUMN \"identifier\" SET NOT NULL")()
168                 db.prepare("UPDATE \"schema_version\" SET \"version\"=2")()
169                 db.prepare("CREATE UNIQUE INDEX motion_ident ON motion (identifier)")()
170
171         if ver < 3:
172             with app.open_resource('sql/from_2.sql', mode='r') as f:
173                 db.execute(f.read())
174                 db.prepare("UPDATE \"motion\" SET \"host\"=$1")(app.config.get("DEFAULT_HOST"))
175                 db.prepare("ALTER TABLE \"motion\" ALTER COLUMN \"host\" SET NOT NULL")()
176                 db.prepare("UPDATE \"schema_version\" SET \"version\"=3")()
177
178 init_db()
179
180 @app.route("/")
181 def main():
182     start=int(request.args.get("start", "-1"));
183     q = "SELECT motion.*, votes.*, poser.email AS poser, canceler.email AS canceler, (motion.deadline > CURRENT_TIMESTAMP AND canceled is NULL) AS running FROM motion LEFT JOIN (SELECT motion_id, "\
184                              + "COUNT(CASE WHEN result='yes' THEN 'yes' ELSE NULL END) as yes, "\
185                              + "COUNT(CASE WHEN result='no' THEN 'no' ELSE NULL END) as no, "\
186                              + "COUNT(CASE WHEN result='abstain' THEN 'abstain' ELSE NULL END) as abstain "\
187                              + "FROM vote GROUP BY motion_id) as votes ON votes.motion_id=motion.id "\
188                              + "LEFT JOIN voter poser ON poser.id = motion.posed_by "\
189                              + "LEFT JOIN voter canceler ON canceler.id = motion.canceled_by "
190     prev=None
191     if start == -1:
192         p = get_db().prepare(q + "WHERE motion.host = $1 ORDER BY motion.id DESC LIMIT 11")
193         rv = p(request.host)
194     else:
195         p = get_db().prepare(q + "WHERE motion.host = $1 AND motion.id <= $2 ORDER BY motion.id DESC LIMIT 11")
196         rv = p(request.host, start)
197         rs = get_db().prepare("SELECT id FROM motion WHERE motion.host = $1 AND motion.id > $2 ORDER BY id ASC LIMIT 10")(request.host, start)
198         if len(rs) == 10:
199             prev = rs[9][0]
200         else:
201             prev = -1
202     return render_template('index.html', motions=rv[:10], more=rv[10]["id"] if len(rv) == 11 else None, times=times.per_host, prev=prev,
203                            categories=get_allowed_cats("create"), singlemotion=False)
204
205 def rel_redirect(loc):
206     r = redirect(loc)
207     r.autocorrect_location_header = False
208     return r
209
210 @app.route("/motion", methods=['POST'])
211 def put_motion():
212     cat=request.form.get("category", "")
213     if cat not in get_allowed_cats("create"):
214         return "Forbidden", 403
215     time = int(request.form.get("days", "3"));
216     if time not in times.per_host:
217         return "Error, invalid length", 400
218     title=request.form.get("title", "")
219     title=title.strip()
220     if title =='':
221         return "Error, missing title", 400
222     content=request.form.get("content", "")
223     content=content.strip()
224     if content =='':
225         return "Error, missing content", 400
226
227     db = get_db()
228     with db.xact():
229         t = db.prepare("SELECT CURRENT_TIMESTAMP")()[0][0];
230         s = db.prepare("SELECT MAX(\"identifier\") FROM \"motion\" WHERE \"type\"=$1 AND \"host\"=$2 AND DATE(\"posed\")=DATE(CURRENT_TIMESTAMP)")
231         sr = s(cat, request.host)
232         ident=""
233         if len(sr) == 0 or sr[0][0] is None:
234             ident=prefix.per_host[cat]+"."+t.strftime("%Y%m%d")+".001"
235         else:
236             ident=prefix.per_host[cat]+"."+t.strftime("%Y%m%d")+"."+("%03d" % (int(sr[0][0].split(".")[2])+1))
237         p = db.prepare("INSERT INTO motion(\"name\", \"content\", \"deadline\", \"posed_by\", \"type\", \"identifier\", \"host\") VALUES($1, $2, CURRENT_TIMESTAMP + $3 * interval '1 days', $4, $5, $6, $7)")
238         p(title, content, time, g.voter, cat, ident, request.host)
239     return rel_redirect("/")
240
241 def motion_edited(motion):
242     return rel_redirect("/?start=" + str(motion) + "#motion-" + str(motion))
243
244 def validate_motion_access(privilege):
245     def decorator(f):
246         def decorated_function(motion):
247             db = get_db()
248             with db.xact():
249                 rv = db.prepare("SELECT id, type, deadline < CURRENT_TIMESTAMP AS expired, canceled FROM motion WHERE identifier=$1 AND host=$2")(motion, request.host);
250                 if len(rv) == 0:
251                     return "Error, Not found", 404
252                 id = rv[0].get("id")
253                 if not may(privilege, rv[0].get("type")):
254                     return "Forbidden", 403
255                 if rv[0].get("canceled") is not None:
256                     return "Error, motion was canceled", 403
257                 if rv[0].get("expired"):
258                     return "Error, out of time", 403
259             return f(motion, id)
260         decorated_function.__name__ = f.__name__
261         return decorated_function
262     return decorator
263     
264 @app.route("/motion/<string:motion>/cancel", methods=['POST'])
265 @validate_motion_access('cancel')
266 def cancel_motion(motion, id):
267     if request.form.get("reason", "none") == "none":
268         return "Error, form requires reason", 500
269     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)
270     return motion_edited(id)
271
272 @app.route("/motion/<string:motion>/finish", methods=['POST'])
273 @validate_motion_access('finish')
274 def finish_motion(motion, id):
275     rv = get_db().prepare("UPDATE motion SET deadline=CURRENT_TIMESTAMP WHERE identifier=$1 AND host=$2 AND canceled is NULL")(motion, request.host)
276     return motion_edited(id)
277
278 @app.route("/motion/<string:motion>")
279 def show_motion(motion):
280     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 "\
281                          + "LEFT JOIN vote on vote.motion_id=motion.id AND vote.voter_id=$2 "\
282                          + "LEFT JOIN voter poser ON poser.id = motion.posed_by "\
283                          + "LEFT JOIN voter canceler ON canceler.id = motion.canceled_by "
284                          + "WHERE motion.identifier=$1 AND motion.host=$3")
285     rv = p(motion, g.voter, request.host)
286     if len(rv) == 0:
287         return "Error, Not found", 404
288     votes = None
289     if may("audit", rv[0].get("type")) and not rv[0].get("running") and not rv[0].get("canceled"):
290         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"));
291     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)
292
293 @app.route("/motion/<string:motion>/vote", methods=['POST'])
294 @validate_motion_access('vote')
295 def vote(motion, id):
296     v = request.form.get("vote", "abstain")
297     db = get_db()
298     p = db.prepare("SELECT * FROM vote WHERE motion_id = $1 AND voter_id = $2")
299     rv = p(id, g.voter)
300     if len(rv) == 0:
301         db.prepare("INSERT INTO vote(motion_id, voter_id, result) VALUES($1,$2,$3)")(id, g.voter, v)
302     else:
303         db.prepare("UPDATE vote SET result=$3, entered=CURRENT_TIMESTAMP WHERE motion_id=$1 AND voter_id = $2")(id, g.voter, v)
304     return motion_edited(id)