forked from fedora-infra/nuancier
-
Notifications
You must be signed in to change notification settings - Fork 0
/
__init__.py
440 lines (356 loc) · 14.3 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# -*- coding: utf-8 -*-
#
# Copyright © 2013-2017 Red Hat, Inc.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions
# of the GNU General Public License v.2, or (at your option) any later
# version. This program is distributed in the hope that it will be
# useful, but WITHOUT ANY WARRANTY expressed or implied, including the
# implied warranties of MERCHANTABILITY or FITNESS FOR A PARTICULAR
# PURPOSE. See the GNU General Public License for more details. You
# should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# Any Red Hat trademarks that are incorporated in the source
# code or documentation are not subject to the GNU General Public
# License and may only be used or replicated with the express permission
# of Red Hat, Inc.
#
'''
Top level of the nuancier Flask application.
'''
import logging
import logging.handlers
import os
import sys
from functools import wraps
import flask
import dogpile.cache
import six
from flask_fas_openid import FAS
from six.moves.urllib.parse import urlparse, urljoin
from sqlalchemy.exc import SQLAlchemyError
from werkzeug import secure_filename
try:
from PIL import Image
except ImportError: # pragma: no cover
# This is for the old versions not using pillow
import Image
# There's currently a circular dependency between forms.py
# and this module and a refacter is required to fix it.
# until then, this needs to be set up before forms is imported.
APP = flask.Flask(__name__) # NOQA
import nuancier.forms
import nuancier.lib as nuancierlib
import nuancier.proxy
## Some of the object we use here have inherited methods which apparently
## pylint does not detect.
# pylint: disable=E1101, E1103
__version__ = '0.11.0'
APP.config.from_object('nuancier.default_config')
if 'NUANCIER_CONFIG' in os.environ: # pragma: no cover
APP.config.from_envvar('NUANCIER_CONFIG')
# Set up FAS extension
FAS = FAS(APP)
APP.wsgi_app = nuancier.proxy.ReverseProxied(APP.wsgi_app)
# Initialize the cache.
CACHE = dogpile.cache.make_region().configure(
APP.config.get('NUANCIER_CACHE_BACKEND', 'dogpile.cache.memory'),
**APP.config.get('NUANCIER_CACHE_KWARGS', {})
)
# Set up the logger
## Send emails for big exception
mail_handler = logging.handlers.SMTPHandler(
APP.config.get('NUANCIER_EMAIL_SMTP_SERVER', '127.0.0.1'),
APP.config.get('NUANCIER_EMAIL_FROM', 'nobody@fedoraproject.org'),
APP.config.get('NUANCIER_EMAIL_ERROR_TO', 'admin@fedoraproject.org'),
'[Nuancier] error')
mail_handler.setFormatter(logging.Formatter('''
Message type: %(levelname)s
Location: %(pathname)s:%(lineno)d
Module: %(module)s
Function: %(funcName)s
Time: %(asctime)s
Message:
%(message)s
'''))
mail_handler.setLevel(logging.ERROR)
if not APP.debug:
APP.logger.addHandler(mail_handler)
# Log to stderr as well
stderr_log = logging.StreamHandler(sys.stderr)
stderr_log.setLevel(logging.INFO)
APP.logger.addHandler(stderr_log)
LOG = APP.logger
SESSION = nuancierlib.create_session(APP.config['DB_URL'])
def is_safe_url(target):
""" Checks that the target url is safe and sending to the current
website not some other malicious one.
"""
ref_url = urlparse(flask.request.host_url)
test_url = urlparse(
urljoin(flask.request.host_url, target))
return test_url.scheme in ('http', 'https') and \
ref_url.netloc == test_url.netloc
def is_nuancier_admin(user):
''' Is the user a nuancier admin.
'''
if not user:
return False
if not user.cla_done or len(user.groups) < 1:
return False
admins = APP.config['ADMIN_GROUP']
if isinstance(admins, six.string_types): # pragma: no cover
admins = set([admins])
else:
admins = set(admins)
return len(set(user.groups).intersection(admins)) > 0
def is_nuancier_reviewer(user):
''' Is the user a nuancier reviewer.
'''
if not user:
return False
if not user.cla_done or len(user.groups) < 1:
return False
reviewers = APP.config['REVIEW_GROUP']
if isinstance(reviewers, six.string_types): # pragma: no cover
reviewers = set([reviewers])
else: # pragma: no cover
reviewers = set(reviewers)
return len(set(user.groups).intersection(reviewers)) > 0
def has_weigthed_vote(user):
''' Has the user a weigthed vote or not.
'''
if not user: # pragma: no cover
return False
if not user.cla_done or len(user.groups) < 1: # pragma: no cover
return False
voters = APP.config['WEIGHTED_GROUP']
if isinstance(voters, six.string_types): # pragma: no cover
voters = set([voters])
else: # pragma: no cover
voters = set(voters)
return len(set(user.groups).intersection(voters)) > 0
def fas_login_required(function):
''' Flask decorator to ensure that the user is logged in against FAS.
To use this decorator you need to have a function named 'auth_login'.
Without that function the redirect if the user is not logged in will not
work.
'''
@wraps(function)
def decorated_function(*args, **kwargs):
''' Wrapped function actually checking if the user is logged in.
'''
if not hasattr(flask.g, 'fas_user') or flask.g.fas_user is None:
return flask.redirect(flask.url_for('.login',
next=flask.request.url))
elif not flask.g.fas_user.cla_done:
flask.flash('You must sign the CLA (Contributor License '
'Agreement to use nuancier', 'error')
return flask.redirect(flask.url_for('index'))
return function(*args, **kwargs)
return decorated_function
def contributor_required(function):
''' Flask decorator to ensure that the user is logged in against FAS.
We'll always make sure the user is CLA+1 as it's what's needed to be
allowed to vote.
'''
@wraps(function)
def decorated_function(*args, **kwargs):
''' Wrapped function actually checking if the user is logged in.
'''
if not hasattr(flask.g, 'fas_user') or flask.g.fas_user is None:
return flask.redirect(flask.url_for('.login',
next=flask.request.url))
elif not flask.g.fas_user.cla_done:
flask.flash('You must sign the CLA (Contributor License '
'Agreement to use nuancier', 'error')
return flask.redirect(flask.url_for('index'))
elif len(flask.g.fas_user.groups) == 0:
flask.flash('You must be in one more group than the CLA',
'error')
return flask.redirect(flask.url_for('index'))
return function(*args, **kwargs)
return decorated_function
def nuancier_admin_required(function):
''' Decorator used to check if the loged in user is a nuancier admin
or not.
'''
@wraps(function)
def decorated_function(*args, **kwargs):
''' Wrapped function actually checking if the user is an admin for
nuancier.
'''
if not hasattr(flask.g, 'fas_user') or flask.g.fas_user is None:
return flask.redirect(flask.url_for('.login',
next=flask.request.url))
elif not flask.g.fas_user.cla_done:
flask.flash('You must sign the CLA (Contributor License '
'Agreement to use nuancier', 'error')
return flask.redirect(flask.url_for('index'))
elif len(flask.g.fas_user.groups) == 0:
flask.flash(
'You must be in one more group than the CLA', 'error')
return flask.redirect(flask.url_for('index'))
elif not is_nuancier_admin(flask.g.fas_user) \
and not is_nuancier_reviewer(flask.g.fas_user):
flask.flash(
'You are neither an administrator or a reviewer of nuancier',
'error')
return flask.redirect(flask.url_for('msg'))
else:
return function(*args, **kwargs)
return decorated_function
def validate_input_file(input_file):
''' Validate the submitted input file.
This validation has four layers:
- extension of the file provided
- MIMETYPE of the file provided
- portrait or landscape orientation
- size of the image (1600x1200 minimal)
- ratio of the image (16:9) - not checked by this function,
it is only recommended
:arg input_file: a File object of the candidate submitted/uploaded and
for which we want to check that it compliants with our expectations.
'''
extension = os.path.splitext(
secure_filename(input_file.filename))[1][1:].lower()
if extension not in APP.config.get('ALLOWED_EXTENSIONS', []):
raise nuancierlib.NuancierException(
'The submitted candidate has the file extension "%s" which is '
'not an allowed format' % extension)
mimetype = input_file.mimetype.lower()
if mimetype not in APP.config.get(
'ALLOWED_MIMETYPES', []): # pragma: no cover
raise nuancierlib.NuancierException(
'The submitted candidate has the MIME type "%s" which is '
'not an allowed MIME type' % mimetype)
try:
image = Image.open(input_file.stream)
except:
raise nuancierlib.NuancierException(
'The submitted candidate could not be opened as an Image')
width, height = image.size
min_width = APP.config.get('PICTURE_MIN_WIDTH', 1600)
min_height = APP.config.get('PICTURE_MIN_HEIGHT', 1200)
if width < height:
if APP.config.get('ALLOW_PORTRAIT', False):
# portrait allowed, turn condition by 90 deg.; width is heigh and
# vice versa
swap = min_width
min_width = min_height
min_height = swap
else:
raise nuancierlib.NuancierException(
'This instance does not support portrait oriented pictures.')
if width < min_width:
raise nuancierlib.NuancierException(
'The submitted candidate has a width of %s pixels which is lower'
' than the minimum %s pixels required' % (width, min_width))
if height < min_height:
raise nuancierlib.NuancierException(
'The submitted candidate has a height of %s pixels which is lower'
' than the minimum %s pixels required' % (height, min_height))
## Generic APP functions
@APP.template_filter('format_grp')
def format_grp(groups):
""" Template filter to present correctly the groups given.
In this case groups can be a string or a list
"""
if isinstance(groups, six.string_types): # pragma: no cover
groups = set([groups])
else: # pragma: no cover
groups = set(groups)
return ', '.join(groups)
@APP.context_processor
def inject_is_admin():
''' Inject whether the user is a nuancier admin or not in every page
(every template).
'''
user = None
if hasattr(flask.g, 'fas_user'):
user = flask.g.fas_user
return dict(is_admin=is_nuancier_admin(user),
is_reviewer=is_nuancier_reviewer(user),
version=__version__)
# pylint: disable=W0613
@APP.teardown_request
def shutdown_session(exception=None):
''' Remove the DB session at the end of each request. '''
SESSION.remove()
# pylint: disable=W0613
@APP.before_request
def set_session():
""" Set the flask session as permanent. """
flask.session.permanent = True
@CACHE.cache_on_arguments(expiration_time=3600)
@APP.route('/pictures/<path:filename>')
def base_picture(filename):
''' Returns a picture having the provided path relative to the
PICTURE_FOLDER set in the configuration.
'''
return flask.send_from_directory(APP.config['PICTURE_FOLDER'], filename)
@CACHE.cache_on_arguments(expiration_time=3600)
@APP.route('/cache/<path:filename>')
def base_cache(filename):
''' Returns a picture having the provided path relative to the
CACHE_FOLDER set in the configuration.
'''
return flask.send_from_directory(APP.config['CACHE_FOLDER'], filename)
@APP.route('/msg/')
def msg():
''' Page used to display error messages
'''
return flask.render_template('msg.html')
@APP.route('/login/', methods=['GET', 'POST'])
def login(): # pragma: no cover
''' Login mechanism for this application.
'''
next_url = None
if 'next' in flask.request.args:
if is_safe_url(flask.request.args['next']):
next_url = flask.request.args['next']
if not next_url or next_url == flask.url_for('.login'):
next_url = flask.url_for('.index')
if hasattr(flask.g, 'fas_user') and flask.g.fas_user is not None:
return flask.redirect(next_url)
else:
admins = APP.config['ADMIN_GROUP']
if isinstance(admins, six.string_types): # pragma: no cover
admins = set([admins])
else:
admins = set(admins)
groups = list(admins)[:]
reviewers = APP.config['REVIEW_GROUP']
if isinstance(reviewers, six.string_types): # pragma: no cover
reviewers = set([reviewers])
else:
reviewers = set(reviewers)
groups.extend(reviewers)
voters = APP.config['WEIGHTED_GROUP']
if isinstance(voters, six.string_types): # pragma: no cover
voters = set([voters])
else:
voters = set(voters)
groups.extend(voters)
return FAS.login(return_url=next_url, groups=groups)
@APP.route('/logout/')
def logout(): # pragma: no cover
''' Log out if the user is logged in other do nothing.
Return to the index page at the end.
'''
next_url = None
if 'next' in flask.request.args:
if is_safe_url(flask.request.args['next']):
next_url = flask.request.args['next']
if not next_url or next_url == flask.url_for('.login'):
next_url = flask.url_for('.index')
if hasattr(flask.g, 'fas_user') and flask.g.fas_user is not None:
FAS.logout()
flask.flash('You are no longer logged-in')
return flask.redirect(next_url)
# Finalize the import of other controllers
import nuancier.admin
import nuancier.ui