• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bloomberg / pybossa / 19114618549

05 Nov 2025 07:59PM UTC coverage: 94.093% (+0.03%) from 94.065%
19114618549

Pull #1069

github

peterkle
add more tests
Pull Request #1069: RDISCROWD-8392 upgrade to boto3

214 of 223 new or added lines in 7 files covered. (95.96%)

152 existing lines in 8 files now uncovered.

17920 of 19045 relevant lines covered (94.09%)

0.94 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.38
/pybossa/view/projects.py
1
# -*- coding: utf8 -*-
2
# This file is part of PYBOSSA.
3
#
4
# Copyright (C) 2017 Scifabric LTD.
5
#
6
# PYBOSSA is free software: you can redistribute it and/or modify
7
# it under the terms of the GNU Affero General Public License as published by
8
# the Free Software Foundation, either version 3 of the License, or
9
# (at your option) any later version.
10
#
11
# PYBOSSA is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU Affero General Public License for more details.
15
#
16
# You should have received a copy of the GNU Affero General Public License
17
# along with PYBOSSA.  If not, see <http://www.gnu.org/licenses/>.
18

19
import time
1✔
20
import re
1✔
21
import json
1✔
22
import os
1✔
23
import math
1✔
24
import requests
1✔
25
from io import StringIO
1✔
26
import six
1✔
27
import copy
1✔
28
from pybossa.cache.helpers import n_unexpired_gold_tasks, n_priority_x_tasks
1✔
29
from flask import Blueprint, request, url_for, flash, redirect, abort, Response, current_app
1✔
30
from flask import render_template, render_template_string, make_response, session
1✔
31
from flask import Markup, jsonify
1✔
32
from flask_login import login_required, current_user
1✔
33
from flask_babel import gettext
1✔
34
from flask_wtf.csrf import generate_csrf
1✔
35
import urllib.parse
1✔
36
from rq import Queue
1✔
37
from werkzeug.datastructures import MultiDict
1✔
38
from werkzeug.utils import secure_filename
1✔
39

40
import pybossa.sched as sched
1✔
41
from pybossa.core import (uploader, signer, sentinel, json_exporter,
1✔
42
                          csv_exporter, importer, db, task_json_exporter,
43
                          task_csv_exporter, anonymizer, csrf)
44
from pybossa.model import make_uuid
1✔
45
from pybossa.model.project import Project
1✔
46
from pybossa.model.category import Category
1✔
47
from pybossa.model.task import Task
1✔
48
from pybossa.model.task_run import TaskRun
1✔
49
from pybossa.model.auditlog import Auditlog
1✔
50
from pybossa.model.project_stats import ProjectStats
1✔
51
from pybossa.model.webhook import Webhook
1✔
52
from pybossa.model.blogpost import Blogpost
1✔
53
from pybossa.view.account import get_bookmarks
1✔
54
from pybossa.util import (Pagination, admin_required, get_user_id_or_ip, rank,
1✔
55
                          handle_content_type, redirect_content_type,
56
                          get_avatar_url, admin_or_subadmin_required,
57
                          s3_get_file_contents, fuzzyboolean,
58
                          is_own_url_or_else,
59
                          description_from_long_description,
60
                          check_annex_response,
61
                          process_annex_load, process_tp_components,
62
                          process_table_component, PARTIAL_ANSWER_POSITION_KEY,
63
                          SavedTaskPositionEnum, delete_redis_keys, get_last_name,
64
                          PARTIAL_ANSWER_PREFIX, PARTIAL_ANSWER_KEY)
65
from pybossa.auth import ensure_authorized_to
1✔
66
from pybossa.cache import projects as cached_projects, ONE_DAY
1✔
67
from pybossa.cache import users as cached_users
1✔
68
from pybossa.cache import categories as cached_cat
1✔
69
from pybossa.cache import project_stats as stats
1✔
70
from pybossa.cache.helpers import add_custom_contrib_button_to, has_no_presenter
1✔
71
from pybossa.cache.task_browse_helpers import (get_searchable_columns,
1✔
72
                                               parse_tasks_browse_args)
73
from pybossa.ckan import Ckan
1✔
74
from pybossa.cookies import CookieHandler
1✔
75
from pybossa.password_manager import ProjectPasswdManager
1✔
76
from pybossa.jobs import (webhook, send_mail,
1✔
77
                          import_tasks, IMPORT_TASKS_TIMEOUT,
78
                          delete_bulk_tasks, TASK_DELETE_TIMEOUT,
79
                          export_tasks, EXPORT_TASKS_TIMEOUT,
80
                          mail_project_report, check_and_send_task_notifications)
81
from pybossa.forms.dynamic_forms import dynamic_project_form, dynamic_clone_project_form
1✔
82
from pybossa.forms.projects_view_forms import *
1✔
83
from pybossa.forms.admin_view_forms import SearchForm
1✔
84
from pybossa.importers import BulkImportException
1✔
85
from pybossa.pro_features import ProFeatureHandler
1✔
86

87
from pybossa.core import (project_repo, user_repo, task_repo, blog_repo,
1✔
88
                          result_repo, webhook_repo, auditlog_repo,
89
                          performance_stats_repo)
90
from pybossa.auditlogger import AuditLogger
1✔
91
from pybossa.contributions_guard import ContributionsGuard
1✔
92
from pybossa.default_settings import TIMEOUT
1✔
93
from pybossa.forms.admin_view_forms import *
1✔
94
from pybossa.cache.helpers import n_gold_tasks, n_available_tasks, oldest_available_task, n_completed_tasks_by_user
1✔
95
from pybossa.cache.helpers import n_available_tasks_for_user, latest_submission_task_date
1✔
96
from pybossa.util import crossdomain
1✔
97
from pybossa.error import ErrorStatus
1✔
98
from pybossa.redis_lock import get_locked_tasks_project
1✔
99
from pybossa.sched import (Schedulers, select_task_for_gold_mode, lock_task_for_user, fetch_lock_for_user,
1✔
100
    get_task_id_and_duration_for_project_user, sched_variants)
101
from pybossa.syncer import NotEnabled, SyncUnauthorized
1✔
102
from pybossa.syncer.project_syncer import ProjectSyncer
1✔
103
from pybossa.exporter.csv_reports_export import ProjectReportCsvExporter
1✔
104
from datetime import datetime
1✔
105
from pybossa.data_access import (data_access_levels, subadmins_are_privileged,
1✔
106
    ensure_annotation_config_from_form, ensure_amp_config_applied_to_project)
107
import pybossa.app_settings as app_settings
1✔
108
from copy import deepcopy
1✔
109
from pybossa.cache import delete_memoized
1✔
110
from sqlalchemy.orm.attributes import flag_modified
1✔
111
from pybossa.util import admin_or_project_owner, validate_ownership_id
1✔
112
from pybossa.api.project import ProjectAPI
1✔
113

114
cors_headers = ['Content-Type', 'Authorization']
1✔
115

116
blueprint = Blueprint('project', __name__)
1✔
117
blueprint_projectid = Blueprint('projectid', __name__)
1✔
118

119
MAX_NUM_SYNCHRONOUS_TASKS_IMPORT = 200
1✔
120
MAX_NUM_SYNCHRONOUS_TASKS_DELETE = 100
1✔
121
DEFAULT_TASK_TIMEOUT = ContributionsGuard.STAMP_TTL
1✔
122

123
RESERVED_TASKLIST_COLUMNS = ['userPrefLang', 'userPrefLoc']
1✔
124

125
auditlogger = AuditLogger(auditlog_repo, caller='web')
1✔
126
mail_queue = Queue('email', connection=sentinel.master)
1✔
127
importer_queue = Queue('medium',
1✔
128
                       connection=sentinel.master,
129
                       default_timeout=IMPORT_TASKS_TIMEOUT)
130
webhook_queue = Queue('high', connection=sentinel.master)
1✔
131
task_queue = Queue('medium',
1✔
132
                   connection=sentinel.master,
133
                   default_timeout=TASK_DELETE_TIMEOUT)
134
export_queue = Queue('low',
1✔
135
                     connection=sentinel.master,
136
                     default_timeout=EXPORT_TASKS_TIMEOUT)
137
USER_PREF_COLUMNS = [("userPrefLang", "user_pref_languages"), ("userPrefLoc", "user_pref_locations")]
1✔
138

139
@blueprint_projectid.route('/<int:projectid>/', defaults={'path': ''})
1✔
140
@blueprint_projectid.route('/<int:projectid>/<path:path>/')
1✔
141
def project_id_route_converter(projectid, path):
1✔
142
    project = project_repo.get(projectid)
1✔
143
    if not project:
1✔
144
        return abort(404)
1✔
145
    new_path = '/project/{}/{}'.format(project.short_name, path)
1✔
146
    return redirect_content_type(new_path)
1✔
147

148

149
def sanitize_project_owner(project, owner, current_user, ps=None):
1✔
150
    """Sanitize project and owner data."""
151
    is_current_user_owner = current_user.is_authenticated and owner.id == current_user.id
1✔
152
    if is_current_user_owner:
1✔
153
        if isinstance(project, Project):
1✔
154
            project_sanitized = project.dictize()   # Project object
1✔
155
        else:
156
            project_sanitized = project             # dict object
1✔
157
        owner_sanitized = cached_users.get_user_summary(owner.name)
1✔
158
    else:   # anonymous or different owner
159
        if request.headers.get('Content-Type') == 'application/json':
1✔
160
            if isinstance(project, Project):
1✔
161
                project_sanitized = project.to_public_json()            # Project object
1✔
162
            else:
163
                project_sanitized = Project().to_public_json(project)   # dict object
1✔
164
        else:    # HTML
165
            # Also dictize for HTML to have same output as authenticated user (see above)
166
            if isinstance(project, Project):
1✔
167
                project_sanitized = project.dictize()   # Project object
1✔
168
            else:
169
                project_sanitized = project             # dict object
1✔
170
        owner_sanitized = cached_users.public_get_user_summary(owner.name)
1✔
171
    project_sanitized = deepcopy(project_sanitized)
1✔
172

173
    if not owner_sanitized:
1✔
174
        current_app.logger.info("""owner_sanitized is None after %sget_user_summary().
1✔
175
                            project id %s, project name %s, owner id %s, owner name %s, current user id %s,
176
                            current user name %s, current user authenticated %s, is_current_user == owner %s""",
177
                            "public_" if not is_current_user_owner else "",
178
                            project.id, project.name, owner.id, owner.name, current_user.id, current_user.name,
179
                            current_user.is_authenticated, is_current_user_owner)
180

181
    # remove project, owner creds so that they're unavailable under json response
182
    project_sanitized['info'].pop('passwd_hash', None)
1✔
183
    project_sanitized.pop('secret_key', None)
1✔
184
    owner_sanitized.pop('api_key', None)
1✔
185

186
    if ps:
1✔
187
        project_sanitized['n_tasks'] = ps.n_tasks
1✔
188
        project_sanitized['n_task_runs'] = ps.n_tasks
1✔
189
        project_sanitized['n_results'] = ps.n_results
1✔
190
        project_sanitized['n_completed_tasks'] = ps.n_completed_tasks
1✔
191
        project_sanitized['n_volunteers'] = ps.n_volunteers
1✔
192
        project_sanitized['overall_progress'] = ps.overall_progress
1✔
193
        project_sanitized['n_blogposts'] = ps.n_blogposts
1✔
194
        project_sanitized['last_activity'] = ps.last_activity
1✔
195
        project_sanitized['overall_progress'] = ps.overall_progress
1✔
196
    return project_sanitized, owner_sanitized
1✔
197

198
def zip_enabled(project, user):
1✔
199
    """Return if the user can download a ZIP file."""
200
    if project.zip_download is False:
1✔
201
        if user.is_anonymous:
1✔
UNCOV
202
            return abort(401)
×
203
        if (user.is_authenticated and
1✔
204
            (user.id not in project.owners_ids and
205
                user.admin is False)):
UNCOV
206
            return abort(403)
×
207

208

209
def project_title(project, page_name):
1✔
210
    if not project:  # pragma: no cover
211
        return "Project not found"
212
    if page_name is None:
1✔
213
        return "Project: %s" % (project.name)
1✔
214
    return "Project: %s &middot; %s" % (project.name, page_name)
1✔
215

216

217
def project_by_shortname(short_name):
1✔
218
    project = project_repo.get_by(short_name=short_name)
1✔
219
    if project:
1✔
220
        # Get owner
221
        ps = stats.get_stats(project.id, full=True)
1✔
222
        owner = user_repo.get(project.owner_id)
1✔
223
        return (project, owner, ps)
1✔
224
    else:
225
        return abort(404)
1✔
226

227

228
def allow_deny_project_info(project_short_name):
1✔
229
    """Return project info for user as admin, subadmin or project coowner."""
230
    result = project_by_shortname(project_short_name)
1✔
231
    project = result[0]
1✔
232
    if (current_user.admin
1✔
233
        or subadmins_are_privileged(current_user)
234
        or current_user.id in project.owners_ids):
235
        return result
1✔
236
    return abort(403)
1✔
237

238

239
def pro_features(owner=None):
1✔
240
    feature_handler = ProFeatureHandler(current_app.config.get('PRO_FEATURES'))
1✔
241
    pro = {
1✔
242
        'auditlog_enabled': feature_handler.auditlog_enabled_for(current_user),
243
        'autoimporter_enabled': feature_handler.autoimporter_enabled_for(current_user),
244
        'webhooks_enabled': feature_handler.webhooks_enabled_for(current_user)
245
    }
246
    if owner:
1✔
247
        pro['better_stats_enabled'] = feature_handler.better_stats_enabled_for(
1✔
248
                                          current_user,
249
                                          owner)
250
    return pro
1✔
251

252

253
@blueprint.route('/search/', defaults={'page': 1})
1✔
254
@blueprint.route('/search/page/<int:page>/')
1✔
255
@login_required
1✔
256
def search(page):
1✔
UNCOV
257
    def lookup(*args, **kwargs):
×
258
        return cached_projects.text_search(search_text)
×
259
    def no_results(*args, **kwargs):
×
260
        return []
×
261
    search_text = request.args.get('search_text', None)
×
262
    lookup_fn = lookup
×
263
    if not search_text:
×
264
        flash(gettext('Please provide a search phrase'), 'error')
×
265
        lookup_fn = no_results
×
266
    extra_tmplt_args = {'search_text': search_text}
×
267
    return project_index(page, lookup_fn, 'search_results', False, False, None,
×
268
                         False, True, extra_tmplt_args)
269

270

271
@blueprint.route('/category/featured/', defaults={'page': 1})
1✔
272
@blueprint.route('/category/featured/page/<int:page>/')
1✔
273
@login_required
1✔
274
def featured(page):
1✔
275
    """List projects in the system"""
276
    order_by = request.args.get('orderby', None)
1✔
277
    desc = bool(request.args.get('desc', False))
1✔
278
    return project_index(page, cached_projects.get_all_featured,
1✔
279
                         'featured', True, False, order_by, desc)
280

281

282
def project_index(page, lookup, category, fallback, use_count, order_by=None,
1✔
283
                  desc=False, pre_ranked=False, extra_tmplt_args=None):
284
    """Show projects of a category"""
285
    per_page = current_app.config['APPS_PER_PAGE']
1✔
286
    ranked_projects = lookup(category)
1✔
287

288
    if not pre_ranked:
1✔
289
        ranked_projects = rank(ranked_projects, order_by, desc)
1✔
290

291
    offset = (page - 1) * per_page
1✔
292
    projects = ranked_projects[offset:offset+per_page]
1✔
293
    count = len(ranked_projects)
1✔
294

295
    if fallback and not projects:  # pragma: no cover
296
        return redirect(url_for('.index'))
297

298
    pagination = Pagination(page, per_page, count)
1✔
299
    categories = cached_cat.get_visible()
1✔
300
    categories = sorted(categories,
1✔
301
                        key=lambda category: category.name)
302
    # Check for pre-defined categories featured and draft
303
    featured_cat = Category(name='Featured',
1✔
304
                            short_name='featured',
305
                            description='Featured projects')
306
    historical_contributions_cat = Category(name='Historical Contributions',
1✔
307
                                            short_name='historical_contributions',
308
                                            description='Projects previously contributed to')
309
    if category == 'featured':
1✔
310
        active_cat = featured_cat
1✔
311
    elif category == 'draft':
1✔
312
        active_cat = Category(name='Draft',
1✔
313
                              short_name='draft',
314
                              description='Draft projects')
315
    elif category == 'historical_contributions':
1✔
316
        active_cat = historical_contributions_cat
1✔
317
    elif category == 'search_results':
1✔
UNCOV
318
        active_cat = Category(name='Search Results',
×
319
                              short_name='search_results',
320
                              description='Projects matching text \'{search_text}\''.format(**extra_tmplt_args))
321
    else:
322
        active_cat = project_repo.get_category_by(short_name=category)
1✔
323

324
    if current_app.config.get('HISTORICAL_CONTRIBUTIONS_AS_CATEGORY'):
1✔
325
        categories.insert(0, historical_contributions_cat)
1✔
326
    # Check if we have to add the section Featured to local nav
327
    if cached_projects.n_count('featured') > 0:
1✔
328
        categories.insert(0, featured_cat)
1✔
329
    template_args = extra_tmplt_args or {}
1✔
330
    template_args.update({
1✔
331
        "projects": projects,
332
        "title": gettext("Projects"),
333
        "pagination": pagination,
334
        "active_cat": active_cat,
335
        "categories": categories,
336
        "template": '/projects/index.html'})
337

338
    if use_count:
1✔
339
        template_args.update({"count": count})
1✔
340
    return handle_content_type(template_args)
1✔
341

342

343
@blueprint.route('/category/draft/', defaults={'page': 1})
1✔
344
@blueprint.route('/category/draft/page/<int:page>/')
1✔
345
@login_required
1✔
346
@admin_required
1✔
347
def draft(page):
1✔
348
    """Show the Draft projects"""
349
    order_by = request.args.get('orderby', None)
1✔
350
    desc = bool(request.args.get('desc', False))
1✔
351
    return project_index(page, cached_projects.get_all_draft, 'draft',
1✔
352
                         False, True, order_by, desc)
353

354

355
@blueprint.route('/category/historical_contributions/', defaults={'page': 1})
1✔
356
@blueprint.route('/category/historical_contributions/page/<int:page>/')
1✔
357
@login_required
1✔
358
def index(page):
1✔
359
    """Show the projects a user has previously worked on"""
360
    order_by = request.args.get('orderby', None)
1✔
361
    desc = bool(request.args.get('desc', False))
1✔
362
    pre_ranked = True
1✔
363
    user_id = current_user.id
1✔
364
    def lookup(*args, **kwargs):
1✔
365
        return cached_users.projects_contributed(user_id, order_by='last_contribution')
1✔
366
    return project_index(page, lookup, 'historical_contributions', False, True, order_by,
1✔
367
                         desc, pre_ranked)
368

369

370
@blueprint.route('/category/<string:category>/', defaults={'page': 1})
1✔
371
@blueprint.route('/category/<string:category>/page/<int:page>/')
1✔
372
@login_required
1✔
373
def project_cat_index(category, page):
1✔
374
    """Show Projects that belong to a given category"""
375
    order_by = request.args.get('orderby', None)
1✔
376
    desc = bool(request.args.get('desc', False))
1✔
377
    return project_index(page, cached_projects.get_all, category, False, True,
1✔
378
                         order_by, desc)
379

380
@blueprint.route('/new', methods=['GET', 'POST'])
1✔
381
@login_required
1✔
382
@admin_or_subadmin_required
1✔
383
def new():
1✔
384
    ensure_authorized_to('create', Project)
1✔
385

386
    # Sort list of subproducts (value) for each product (key).
387
    prodsubprods = {key:sorted(value) for key, value in current_app.config.get('PRODUCTS_SUBPRODUCTS', {}).items()}
1✔
388
    data_classes = [(data_class, data_class, {} if enabled else dict(disabled='disabled'))
1✔
389
        for data_class, enabled in current_app.config.get('DATA_CLASSIFICATION', [('', False)])
390
    ]
391
    form = dynamic_project_form(ProjectForm, request.body, data_access_levels, prodsubprods, data_classes)
1✔
392

393
    def respond(errors):
1✔
394
        response = dict(template='projects/new.html',
1✔
395
                        project=None,
396
                        title=gettext("Create a Project"),
397
                        form=form, errors=errors,
398
                        prodsubprods=prodsubprods)
399
        return handle_content_type(response)
1✔
400

401

402
    if request.method != 'POST':
1✔
403
        return respond(False)
1✔
404

405
    if not form.validate():
1✔
406
        flash(gettext('Please correct the errors'), 'error')
1✔
407
        return respond(True)
1✔
408

409
    info = {
1✔
410
        'sync': {'enabled': False},
411
        'product': form.product.data,
412
        'subproduct': form.subproduct.data,
413
        'kpi': float(form.kpi.data),
414
        'data_classification': {
415
            'input_data': form.input_data_class.data,
416
            'output_data': form.output_data_class.data
417
        },
418
        "allow_taskrun_edit": False,
419
        "allow_anonymous_contributors": False
420
    }
421
    category_by_default = cached_cat.get_all()[0]
1✔
422

423
    project = Project(name=form.name.data,
1✔
424
                      short_name=form.short_name.data,
425
                      description=description_from_long_description(
426
                          form.description.data,
427
                          form.long_description.data
428
                          ),
429
                      long_description=form.long_description.data,
430
                      owner_id=current_user.id,
431
                      info=info,
432
                      category_id=category_by_default.id,
433
                      owners_ids=[current_user.id])
434

435
    project.set_password(form.password.data)
1✔
436
    ensure_annotation_config_from_form(project.info, form)
1✔
437

438
    project_repo.save(project)
1✔
439

440
    msg_1 = gettext('Project created!')
1✔
441
    flash(Markup('<i class="icon-ok"></i> {}').format(msg_1), 'success')
1✔
442
    markup = Markup('<i class="icon-bullhorn"></i> {} ' +
1✔
443
                    '<strong><a href="https://docs.pybossa.com"> {}' +
444
                    '</a></strong> {}')
445
    flash(markup.format(
1✔
446
              gettext('You can check the '),
447
              gettext('Guide and Documentation'),
448
              gettext('for adding tasks, a thumbnail, using PYBOSSA.JS, etc.')),
449
          'success')
450
    auditlogger.add_log_entry(None, project, current_user)
1✔
451

452
    return redirect_content_type(url_for('.update',
1✔
453
                                         short_name=project.short_name))
454

455

456
def clone_project(project, form):
1✔
457

458
    proj_dict = project.dictize()
1✔
459
    proj_dict['info'] = deepcopy(proj_dict['info'])
1✔
460
    proj_dict.pop('secret_key', None)
1✔
461
    proj_dict.pop('id', None)
1✔
462
    proj_dict.pop('created', None)
1✔
463
    proj_dict.pop('updated', None)
1✔
464
    proj_dict['info'].pop('passwd_hash', None)
1✔
465
    proj_dict['info'].pop('quiz', None)
1✔
466
    proj_dict['info'].pop('enrichments', None)
1✔
467
    proj_dict['info'].pop('data_classification', None)
1✔
468
    proj_dict['info'].pop('data_access', None)
1✔
469

470
    if  bool(data_access_levels) and not form.get('copy_users', False):
1✔
471
        proj_dict['info'].pop('project_users', None)
1✔
472

473
    if current_user.id not in project.owners_ids:
1✔
474
        proj_dict['info'].pop('ext_config', None)
1✔
475

476
    proj_dict['owner_id'] = current_user.id
1✔
477
    if form.get('copy_coowners', False):
1✔
478
        proj_dict['owners_ids'] = project.owners_ids
1✔
479
    else:
480
        proj_dict['owners_ids'] = deepcopy([current_user.id])
1✔
481
    proj_dict['name'] = form['name']
1✔
482

483
    task_presenter = proj_dict['info'].get('task_presenter', '')
1✔
484
    regex = r"([\"\'\/])({})([\"\'\/])".format(proj_dict['short_name'])
1✔
485
    proj_dict['info']['task_presenter'] = re.sub(regex, r"\1{}\3".format(form['short_name']), task_presenter)
1✔
486

487
    proj_dict['short_name'] = form['short_name']
1✔
488

489
    # Remove "encryption" from ext_config if it exists
490
    if 'ext_config' in proj_dict['info'] and isinstance(proj_dict['info']['ext_config'], dict):
1✔
491
        encryption = proj_dict['info']['ext_config'].pop('encryption', None)
1✔
492
        if encryption:
1✔
493
            current_app.logger.info("Removed encryption %s from ext_config when cloning project %d", encryption, project.id)
1✔
494

495

496
    proj_dict['info']['data_classification'] = {
1✔
497
        'input_data': form['input_data_class'],
498
        'output_data': form['output_data_class']
499
    }
500

501
    new_project = Project(**proj_dict)
1✔
502
    new_project.set_password(form['password'])
1✔
503
    project_repo.save(new_project)
1✔
504

505
    return new_project
1✔
506

507

508
@blueprint.route('/<short_name>/clone',  methods=['GET', 'POST'])
1✔
509
@login_required
1✔
510
@admin_or_subadmin_required
1✔
511
def clone(short_name):
1✔
512

513
    project, owner, ps = project_by_shortname(short_name)
1✔
514
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
515
                                                                owner,
516
                                                                current_user,
517
                                                                ps)
518
    ensure_authorized_to('read', project)
1✔
519
    data_classes = [(data_class, data_class, {} if enabled else dict(disabled='disabled'))
1✔
520
        for data_class, enabled in current_app.config.get('DATA_CLASSIFICATION', ('', False))
521
    ]
522
    form = dynamic_clone_project_form(ProjectCommonForm, request.form or None, data_access_levels, data_classes=data_classes, obj=project)
1✔
523
    project.input_data_class = project.info.get('data_classification', {}).get('input_data')
1✔
524
    project.output_data_class = project.info.get('data_classification', {}).get('output_data')
1✔
525

526
    if request.method == 'POST':
1✔
527
        ensure_authorized_to('create', Project)
1✔
528
        if not form.validate():
1✔
529
            flash(gettext('Please correct the errors'), 'error')
1✔
530
        else:
531
            new_project = clone_project(project, form.data)
1✔
532
            new_project, owner_sanitized = sanitize_project_owner(new_project,
1✔
533
                                                                owner,
534
                                                                current_user,
535
                                                                ps)
536
            flash(gettext('Project cloned!'), 'success')
1✔
537
            auditlogger.log_event(  project,
1✔
538
                                    current_user,
539
                                    'clone',
540
                                    'project.clone',
541
                                    json.dumps(project_sanitized),
542
                                    json.dumps(new_project))
543
            return redirect_content_type(url_for('.details', short_name=new_project['short_name']))
1✔
544
    return handle_content_type(dict(
1✔
545
        template='/projects/clone_project.html',
546
        action_url=url_for('project.clone', short_name=project.short_name),
547
        form=form,
548
        project=project_sanitized
549
    ))
550

551
def is_editor_disabled():
1✔
552
    return (not current_user.admin and
1✔
553
                    current_app.config.get(
554
                        'DISABLE_TASK_PRESENTER_EDITOR'))
555

556
def is_admin_or_owner(project):
1✔
557
    return (current_user.admin or
1✔
558
           (project.owner_id == current_user.id or
559
            current_user.id in project.owners_ids))
560

561
@blueprint.route('/<short_name>/tasks/taskpresenterimageupload', methods=['GET', 'POST'])
1✔
562
@login_required
1✔
563
@admin_or_subadmin_required
1✔
564
@csrf.exempt
1✔
565
def upload_task_guidelines_image(short_name):
1✔
566
    error = False
1✔
567
    return_code = 200
1✔
568
    project = project_by_shortname(short_name)
1✔
569

570
    imgurls = []
1✔
571
    if is_editor_disabled():
1✔
572
        flash(gettext('Task presenter editor disabled!'), 'error')
1✔
573
        error = True
1✔
574
        return_code = 400
1✔
575
    elif not is_admin_or_owner(project):
1✔
576
        flash(gettext('Ooops! Only project owners can upload files.'), 'error')
1✔
577
        error = True
1✔
578
        return_code = 400
1✔
579
    else:
580
        for file in request.files.getlist("image"):
1✔
581
            file_size_mb = file.seek(0, os.SEEK_END) / 1024 / 1024
1✔
582
            file.seek(0, os.SEEK_SET)
1✔
583
            file.filename = secure_filename(file.filename)
1✔
584
            if file_size_mb < current_app.config.get('MAX_IMAGE_UPLOAD_SIZE_MB', 5):
1✔
585
                container = "user_%s" % current_user.id
1✔
586
                uploader.upload_file(file, container=container)
1✔
587
                imgurls.append(get_avatar_url(
1✔
588
                    current_app.config.get('UPLOAD_METHOD'),
589
                    file.filename,
590
                    container,
591
                    current_app.config.get('AVATAR_ABSOLUTE')
592
                ))
593
            else:
594
                flash(gettext('File must be smaller than ' + str(current_app.config.get('MAX_IMAGE_UPLOAD_SIZE_MB', 5)) + ' MB.'))
1✔
595
                error = True
1✔
596
                return_code = 413
1✔
597

598
    response = {
1✔
599
        "imgurls" : imgurls,
600
        "error": error
601
    }
602

603
    return jsonify(response), return_code
1✔
604

605
@blueprint.route('/<short_name>/tasks/taskpresentereditor', methods=['GET', 'POST'])
1✔
606
@login_required
1✔
607
@admin_or_subadmin_required
1✔
608
def task_presenter_editor(short_name):
1✔
609
    errors = False
1✔
610
    project, owner, ps = project_by_shortname(short_name)
1✔
611

612
    title = project_title(project, "Task Presenter Editor")
1✔
613

614
    pro = pro_features()
1✔
615

616
    form = TaskPresenterForm(request.body)
1✔
617
    form.id.data = project.id
1✔
618

619
    is_task_presenter_update = True if 'task-presenter' in request.body else False
1✔
620
    is_task_guidelines_update = True if 'task-guidelines' in request.body else False
1✔
621

622
    disable_editor = (not current_user.admin and
1✔
623
                      current_app.config.get(
624
                          'DISABLE_TASK_PRESENTER_EDITOR'))
625
    disabled_msg = ('Task presenter code must be synced from '
1✔
626
                    'your corresponding job on the development '
627
                    'platform!')
628
    is_admin_or_owner = (
1✔
629
        current_user.admin or
630
        (project.owner_id == current_user.id or
631
            current_user.id in project.owners_ids))
632

633
    if disable_editor:
1✔
UNCOV
634
        flash(gettext(disabled_msg), 'error')
×
635

636
    if request.method == 'POST':
1✔
637
        # Limit maximum post data size.
638
        content_length = request.content_length
1✔
639
        max_length_mb = current_app.config.get('TASK_PRESENTER_MAX_SIZE_MB', 2)
1✔
640
        max_length_bytes = max_length_mb * 1024 * 1024 # 2 MB maximum POST data size
1✔
641

642
        if disable_editor:
1✔
UNCOV
643
            flash(gettext(disabled_msg), 'error')
×
644
            errors = True
×
645
        elif not is_admin_or_owner:
1✔
UNCOV
646
            flash(gettext('Ooops! Only project owners can update.'),
×
647
                  'error')
UNCOV
648
            errors = True
×
649
        elif content_length and content_length > max_length_bytes:
1✔
650
            flash(gettext('The task presenter/guidelines content exceeds ' + str(max_length_mb) +
1✔
651
                  ' MB. Please move large content to an external file.'),
652
                  'error')
653
            errors = True
1✔
654
        elif form.validate():
1✔
655
            db_project = project_repo.get(project.id)
1✔
656
            old_project = Project(**db_project.dictize())
1✔
657
            old_info = dict(db_project.info)
1✔
658
            if is_task_presenter_update:
1✔
659
                old_info['task_presenter'] = form.editor.data
1✔
660
            if is_task_guidelines_update:
1✔
661
                default_value_editor = '<p><br></p>'
1✔
662
                old_info['task_guidelines'] = '' if form.guidelines.data == default_value_editor else form.guidelines.data
1✔
663

664
            # Remove GitHub info on save
665
            for field in ['pusher', 'ref', 'ref_url', 'timestamp']:
1✔
666
                old_info.pop(field, None)
1✔
667

668
            # Remove sync info on save
669
            old_sync = old_info.pop('sync', None)
1✔
670
            if old_sync:
1✔
671
                for field in ['syncer', 'latest_sync', 'source_url']:
1✔
672
                    old_sync.pop(field, None)
1✔
673
                old_info['sync'] = old_sync
1✔
674

675
            db_project.info = old_info
1✔
676
            auditlogger.add_log_entry(old_project, db_project, current_user)
1✔
677
            project_repo.update(db_project)
1✔
678
            msg_1 = gettext('Task presenter updated!') if is_task_presenter_update else gettext('Task instructions updated!')
1✔
679
            markup = Markup('<i class="icon-ok"></i> {}')
1✔
680
            flash(markup.format(msg_1), 'success')
1✔
681

682
            project_sanitized, owner_sanitized = sanitize_project_owner(db_project,
1✔
683
                                                                        owner,
684
                                                                        current_user,
685
                                                                        ps)
686
            if not project_sanitized['info'].get('task_presenter') and not request.args.get('clear_template'):
1✔
687
                 wrap = lambda i: "projects/presenters/%s.html" % i
1✔
688
                 pres_tmpls = list(map(wrap, current_app.config.get('PRESENTERS')))
1✔
689
                 response = dict(template='projects/task_presenter_options.html',
1✔
690
                            title=title,
691
                            project=project_sanitized,
692
                            owner=owner_sanitized,
693
                            overall_progress=ps.overall_progress,
694
                            n_tasks=ps.n_tasks,
695
                            n_task_runs=ps.n_task_runs,
696
                            last_activity=ps.last_activity,
697
                            n_completed_tasks=ps.n_completed_tasks,
698
                            n_volunteers=ps.n_volunteers,
699
                            presenters=pres_tmpls,
700
                            pro_features=pro)
701
            else:
702
                form.editor.data = project_sanitized['info']['task_presenter']
1✔
703
                form.guidelines.data = project_sanitized['info'].get('task_guidelines')
1✔
704
                dict_project = add_custom_contrib_button_to(project_sanitized,
1✔
705
                                                get_user_id_or_ip())
706

707
        elif not form.validate():  # pragma: no cover
708
            flash(gettext('Please correct the errors'), 'error')
709
            errors = True
710

711
    if project.info.get('task_presenter') and not request.args.get('clear_template'):
1✔
712
        form.editor.data = project.info['task_presenter']
1✔
713
        form.guidelines.data = project.info.get('task_guidelines')
1✔
714
    else:
715
        if not request.args.get('template'):
1✔
716
            msg_1 = gettext('<strong>Note</strong> You will need to upload the'
1✔
717
                            ' tasks using the')
718
            msg_2 = gettext('CSV importer')
1✔
719
            url = '<a href="%s"> %s</a>' % (url_for('project.import_task',
1✔
720
                                                    short_name=project.short_name), msg_2)
721
            msg = msg_1 + url
1✔
722
            flash(Markup(msg), 'info')
1✔
723

724
            wrap = lambda i: "projects/presenters/%s.html" % i
1✔
725
            pres_tmpls = list(map(wrap, current_app.config.get('PRESENTERS')))
1✔
726

727
            project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
728
                                                                        owner,
729
                                                                        current_user,
730
                                                                        ps)
731

732
            response = dict(template='projects/task_presenter_options.html',
1✔
733
                            title=title,
734
                            project=project_sanitized,
735
                            owner=owner_sanitized,
736
                            overall_progress=ps.overall_progress,
737
                            n_tasks=ps.n_tasks,
738
                            n_task_runs=ps.n_task_runs,
739
                            last_activity=ps.last_activity,
740
                            n_completed_tasks=ps.n_completed_tasks,
741
                            n_volunteers=ps.n_volunteers,
742
                            presenters=pres_tmpls,
743
                            pro_features=pro)
744
            return handle_content_type(response)
1✔
745

746
        tmpl_name = request.args.get('template')
1✔
747
        s3_presenters = current_app.config.get('S3_PRESENTERS')
1✔
748
        if s3_presenters and tmpl_name in s3_presenters.keys():
1✔
UNCOV
749
            s3_bucket = current_app.config.get('S3_PRESENTER_BUCKET')
×
750
            s3_presenter = s3_presenters[tmpl_name]
×
751
            tmpl_string = s3_get_file_contents(s3_bucket, s3_presenter,
×
752
                                               conn='S3_PRES_CONN')
UNCOV
753
            tmpl = render_template_string(tmpl_string, project=project)
×
754
        else:
755
            tmpl_uri = 'projects/snippets/{}.html'.format(tmpl_name)
1✔
756
            tmpl = render_template(tmpl_uri, project=project)
1✔
757

758
        form.editor.data = tmpl
1✔
759
        form.guidelines.data = project.info.get('task_guidelines')
1✔
760

761
        msg = 'Your code will be <em>automagically</em> rendered in \
1✔
762
                      the <strong>preview section</strong>. Click in the \
763
                      preview button!'
764
        flash(Markup(gettext(msg)), 'info')
1✔
765

766
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
767
                                                                owner,
768
                                                                current_user,
769
                                                                ps)
770

771
    dict_project = add_custom_contrib_button_to(project_sanitized,
1✔
772
                                                get_user_id_or_ip())
773
    response = dict(template='projects/task_presenter_editor.html',
1✔
774
                    title=title,
775
                    form=form,
776
                    project=dict_project,
777
                    owner=owner_sanitized,
778
                    overall_progress=ps.overall_progress,
779
                    n_tasks=ps.n_tasks,
780
                    n_task_runs=ps.n_task_runs,
781
                    last_activity=ps.last_activity,
782
                    n_completed_tasks=ps.n_completed_tasks,
783
                    n_volunteers=ps.n_volunteers,
784
                    errors=errors,
785
                    presenter_tab_on=is_task_presenter_update,
786
                    guidelines_tab_on=is_task_guidelines_update,
787
                    pro_features=pro,
788
                    disable_editor=disable_editor or not is_admin_or_owner)
789
    return handle_content_type(response)
1✔
790

791

792
@blueprint.route('/<short_name>/delete', methods=['GET', 'POST'])
1✔
793
@login_required
1✔
794
def delete(short_name):
1✔
795
    project, owner, ps = project_by_shortname(short_name)
1✔
796

797
    title = project_title(project, "Delete")
1✔
798
    ensure_authorized_to('read', project)
1✔
799
    ensure_authorized_to('delete', project)
1✔
800
    pro = pro_features()
1✔
801
    project_sanitized, owner_sanitized = sanitize_project_owner(project, owner,
1✔
802
                                                                current_user,
803
                                                                ps)
804
    if request.method == 'GET':
1✔
805
        response = dict(template='/projects/delete.html',
1✔
806
                        title=title,
807
                        project=project_sanitized,
808
                        owner=owner_sanitized,
809
                        n_tasks=ps.n_tasks,
810
                        overall_progress=ps.overall_progress,
811
                        last_activity=get_last_activity(ps),
812
                        pro_features=pro,
813
                        csrf=generate_csrf())
814
        return handle_content_type(response)
1✔
815
    project_repo.delete(project)
1✔
816
    auditlogger.add_log_entry(project, None, current_user)
1✔
817
    flash(gettext('Project deleted!'), 'success')
1✔
818
    return redirect_content_type(url_for('account.profile', name=current_user.name))
1✔
819

820

821
@blueprint.route('/<short_name>/update', methods=['GET', 'POST'])
1✔
822
@login_required
1✔
823
def update(short_name):
1✔
824
    sync_enabled = current_app.config.get('SYNC_ENABLED')
1✔
825
    project, owner, ps = project_by_shortname(short_name)
1✔
826

827
    def handle_valid_form(form):
1✔
828
        project, owner, ps = project_by_shortname(short_name)
1✔
829

830
        new_project = project_repo.get_by_shortname(short_name)
1✔
831
        old_project = Project(**new_project.dictize())
1✔
832
        old_info = dict(new_project.info)
1✔
833
        old_project.info = old_info
1✔
834
        if form.id.data == new_project.id:
1✔
835
            new_project.name = form.name.data
1✔
836
            new_project.description = form.description.data
1✔
837
            new_project.long_description = form.long_description.data
1✔
838
            new_project.hidden = form.hidden.data
1✔
839
            new_project.webhook = form.webhook.data
1✔
840
            new_project.info = project.info
1✔
841
            new_project.owner_id = project.owner_id
1✔
842
            new_project.allow_anonymous_contributors = fuzzyboolean(form.allow_anonymous_contributors.data)
1✔
843
            new_project.category_id = form.category_id.data
1✔
844
            new_project.email_notif = form.email_notif.data
1✔
845
            ensure_annotation_config_from_form(new_project.info, form)
1✔
846

847
        if form.password.data:
1✔
848
            new_project.set_password(form.password.data)
1✔
849

850
        sync = new_project.info.get('sync', dict(enabled=False))
1✔
851
        sync['enabled'] = sync_enabled and form.sync_enabled.data
1✔
852
        new_project.info['sync'] = sync
1✔
853
        new_project.info['product'] = form.product.data
1✔
854
        new_project.info['subproduct'] = form.subproduct.data
1✔
855
        new_project.info['kpi'] = float(form.kpi.data)
1✔
856
        new_project.info['data_classification'] = {
1✔
857
            'input_data': form.input_data_class.data,
858
            'output_data': form.output_data_class.data
859
        }
860
        if form.enable_duplicate_task_check.data:
1✔
UNCOV
861
            new_project.info['duplicate_task_check'] = {
×
862
                'duplicate_fields': form.duplicate_task_check_duplicate_fields.data,
863
                'completed_tasks': form.duplicate_task_check_completed_tasks.data
864
            }
865
        else:
866
            new_project.info.pop('duplicate_task_check', None)
1✔
867
        if "allow_taskrun_edit" in form:
1✔
868
            new_project.info["allow_taskrun_edit"] = form.allow_taskrun_edit.data
1✔
869

870
        project_repo.update(new_project)
1✔
871
        auditlogger.add_log_entry(old_project, new_project, current_user)
1✔
872
        cached_cat.reset()
1✔
873
        cached_projects.clean_project(new_project.id)
1✔
874
        flash(gettext('Project updated!'), 'success')
1✔
875
        return redirect_content_type(url_for('.details',
1✔
876
                                     short_name=new_project.short_name))
877

878
    ensure_authorized_to('read', project)
1✔
879
    ensure_authorized_to('update', project)
1✔
880

881
    pro = pro_features()
1✔
882

883
    # Sort list of subproducts (value) for each product (key).
884
    prodsubprods = {key:sorted(value) for key, value in current_app.config.get('PRODUCTS_SUBPRODUCTS', {}).items()}
1✔
885
    data_classes = [(data_class, data_class, {} if enabled else dict(disabled='disabled'))
1✔
886
        for data_class, enabled in current_app.config.get('DATA_CLASSIFICATION', ('', False))
887
    ]
888

889
    title = project_title(project, "Update")
1✔
890
    if request.method == 'GET':
1✔
891
        sync = project.info.get('sync')
1✔
892
        if sync:
1✔
893
            project.sync_enabled = sync.get('enabled')
1✔
894
        project.product = project.info.get('product')
1✔
895
        project.subproduct = project.info.get('subproduct')
1✔
896
        project.kpi = project.info.get('kpi')
1✔
897
        project.input_data_class = project.info.get('data_classification', {}).get('input_data')
1✔
898
        project.output_data_class = project.info.get('data_classification', {}).get('output_data')
1✔
899
        project.allow_taskrun_edit = project.info.get("allow_taskrun_edit", False)
1✔
900
        duplicate_task_check_duplicate_fields = project.info.get("duplicate_task_check", {}).get("duplicate_fields", [])
1✔
901
        project.duplicate_task_check_duplicate_fields = duplicate_task_check_duplicate_fields
1✔
902
        project.duplicate_task_check_completed_tasks = project.info.get("duplicate_task_check", {}).get("completed_tasks", False)
1✔
903
        project.enable_duplicate_task_check = True if project.info.get("duplicate_task_check") else False
1✔
904
        ensure_amp_config_applied_to_project(project, project.info.get('annotation_config', {}))
1✔
905
        form = dynamic_project_form(ProjectUpdateForm, None, data_access_levels, obj=project,
1✔
906
                                    products=prodsubprods, data_classes=data_classes)
907
        upload_form = AvatarUploadForm()
1✔
908
        categories = project_repo.get_all_categories()
1✔
909
        categories = sorted(categories,
1✔
910
                            key=lambda category: category.name)
911
        form.category_id.choices = [(c.id, c.name) for c in categories]
1✔
912
        if project.category_id is None:
1✔
UNCOV
913
            project.category_id = categories[0].id
×
914
        form.populate_obj(project)
1✔
915
        form.set_duplicate_task_check_duplicate_fields_options([(field, field) for field in duplicate_task_check_duplicate_fields])
1✔
916

917

918
    if request.method == 'POST':
1✔
919
        upload_form = AvatarUploadForm()
1✔
920
        form = dynamic_project_form(ProjectUpdateForm, request.body, data_access_levels,
1✔
921
                                    products=prodsubprods, data_classes=data_classes)
922

923
        categories = cached_cat.get_all()
1✔
924
        categories = sorted(categories,
1✔
925
                            key=lambda category: category.name)
926
        form.category_id.choices = [(c.id, c.name) for c in categories]
1✔
927
        if request.form.get('btn') != 'Upload':
1✔
928
            if form.validate():
1✔
929
                return handle_valid_form(form)
1✔
930
            flash(gettext('Please correct the errors'), 'error')
1✔
931
        else:
932
            if upload_form.validate_on_submit():
1✔
933
                project = project_repo.get(project.id)
1✔
934
                _file = request.files['avatar']
1✔
935
                coordinates = (upload_form.x1.data, upload_form.y1.data,
1✔
936
                               upload_form.x2.data, upload_form.y2.data)
937
                prefix = time.time()
1✔
938
                _file.filename = "project_%s_thumbnail_%i.png" % (project.id, prefix)
1✔
939
                container = "user_%s" % current_user.id
1✔
940
                uploader.upload_file(_file,
1✔
941
                                     container=container,
942
                                     coordinates=coordinates)
943
                # Delete previous avatar from storage
944
                if project.info.get('thumbnail'):
1✔
945
                    uploader.delete_file(project.info['thumbnail'], container)
1✔
946
                project.info['thumbnail'] = _file.filename
1✔
947
                project.info['container'] = container
1✔
948
                upload_method = current_app.config.get('UPLOAD_METHOD')
1✔
949
                thumbnail_url = get_avatar_url(upload_method,
1✔
950
                                               _file.filename,
951
                                               container,
952
                                               current_app.config.get('AVATAR_ABSOLUTE')
953
                                               )
954
                project.info['thumbnail_url'] = thumbnail_url
1✔
955
                project_repo.save(project)
1✔
956
                flash(gettext('Your project thumbnail has been updated! It may \
1✔
957
                                  take some minutes to refresh...'), 'success')
958
            else:
UNCOV
959
                flash(gettext('You must provide a file to change the avatar'),
×
960
                      'error')
961
            return redirect_content_type(url_for('.update', short_name=short_name))
1✔
962

963
    project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
964
    project_sanitized, owner_sanitized = sanitize_project_owner(project, owner,
1✔
965
                                                                current_user,
966
                                                                ps)
967
    response = dict(template='/projects/update.html',
1✔
968
                    form=form,
969
                    upload_form=upload_form,
970
                    project=project_sanitized,
971
                    owner=owner_sanitized,
972
                    n_tasks=ps.n_tasks,
973
                    overall_progress=ps.overall_progress,
974
                    n_task_runs=ps.n_task_runs,
975
                    last_activity=ps.last_activity,
976
                    n_completed_tasks=ps.n_completed_tasks,
977
                    n_volunteers=ps.n_volunteers,
978
                    title=title,
979
                    pro_features=pro,
980
                    sync_enabled=sync_enabled,
981
                    private_instance=bool(data_access_levels),
982
                    prodsubprods=prodsubprods)
983
    return handle_content_type(response)
1✔
984

985

986
@blueprint.route('/<short_name>/remove-password', methods=['POST'])
1✔
987
@login_required
1✔
988
@csrf.exempt
1✔
989
def remove_password(short_name):
1✔
990
    if current_app.config.get('PROJECT_PASSWORD_REQUIRED'):
1✔
991
        flash(gettext('Project password required'), 'error')
1✔
992
        return redirect_content_type(url_for('.update', short_name=short_name))
1✔
993
    project, owner, ps = project_by_shortname(short_name)
1✔
994
    project.set_password("")
1✔
995
    project_repo.update(project)
1✔
996
    flash(gettext('Project password has been removed!'), 'success')
1✔
997
    return redirect_content_type(url_for('.update', short_name=short_name))
1✔
998

999

1000
@blueprint.route('/<short_name>/')
1✔
1001
@login_required
1✔
1002
def details(short_name):
1✔
1003

1004
    project, owner, ps = project_by_shortname(short_name)
1✔
1005

1006
    # all projects require password check
1007
    redirect_to_password = _check_if_redirect_to_password(project)
1✔
1008
    if redirect_to_password:
1✔
1009
        return redirect_to_password
1✔
1010
    num_available_tasks = n_available_tasks(project.id)
1✔
1011
    num_completed_tasks_by_user = n_completed_tasks_by_user(project.id, current_user.id)
1✔
1012
    oldest_task = oldest_available_task(project.id, current_user.id)
1✔
1013
    num_available_tasks_for_user = n_available_tasks_for_user(project, current_user.id)
1✔
1014
    latest_submission_date = latest_submission_task_date(project.id)
1✔
1015
    num_remaining_task_runs = cached_projects.n_remaining_task_runs(project.id)
1✔
1016
    num_expected_task_runs = cached_projects.n_expected_task_runs(project.id)
1✔
1017
    num_gold_tasks = n_gold_tasks(project.id)
1✔
1018
    num_locked_tasks = len({task["task_id"] for task in get_locked_tasks(project)})
1✔
1019
    num_priority_one_tasks = n_priority_x_tasks(project.id)
1✔
1020
    n_tasks = ps.n_tasks - num_gold_tasks
1✔
1021
    notifications = {}
1✔
1022

1023
    if n_tasks and ps.overall_progress < 100 and num_available_tasks_for_user == 0:
1✔
1024
        task_scheduler_id = project.info.get('sched')
1✔
1025
        task_scheduler = dict(sched_variants()).get(task_scheduler_id)
1✔
1026
        if task_scheduler_id in [Schedulers.task_queue, Schedulers.user_pref] and task_scheduler:
1✔
1027
            notifications['project_incomplete_info'] = {
1✔
1028
                'user_preferences': {
1029
                    'task_scheduler': task_scheduler,
1030
                    'account_profile_link': url_for('account.profile', name=current_user.name)
1031
                }
1032
            }
1033
    ensure_authorized_to('read', project, forbidden_code_override=423)
1✔
1034
    template = '/projects/project.html'
1✔
1035
    pro = pro_features()
1✔
1036

1037
    title = project_title(project, None)
1✔
1038
    project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
1039
    project_sanitized, owner_sanitized = sanitize_project_owner(project, owner,
1✔
1040
                                                                current_user,
1041
                                                                ps)
1042
    template_args = {"project": project_sanitized,
1✔
1043
                     "title": title,
1044
                     "owner":  owner_sanitized,
1045
                     "n_tasks": n_tasks,
1046
                     "n_task_runs": ps.n_task_runs,
1047
                     "overall_progress": ps.overall_progress,
1048
                     "last_activity": ps.last_activity,
1049
                     "n_completed_tasks": ps.n_completed_tasks,
1050
                     "num_expected_task_runs": num_expected_task_runs,
1051
                     "num_remaining_task_runs": num_remaining_task_runs,
1052
                     "num_gold_tasks": num_gold_tasks,
1053
                     "num_locked_tasks": num_locked_tasks,
1054
                     "n_volunteers": ps.n_volunteers,
1055
                     "pro_features": pro,
1056
                     "n_available_tasks": num_available_tasks,
1057
                     "n_completed_tasks_by_user": num_completed_tasks_by_user,
1058
                     "oldest_available_task": oldest_task,
1059
                     "n_available_tasks_for_user": num_available_tasks_for_user,
1060
                     "latest_submitted_task": latest_submission_date,
1061
                     "n_priority_1_tasks": num_priority_one_tasks
1062
                     }
1063
    if current_app.config.get('CKAN_URL'):
1✔
UNCOV
1064
        template_args['ckan_name'] = current_app.config.get('CKAN_NAME')
×
1065
        template_args['ckan_url'] = current_app.config.get('CKAN_URL')
×
1066
        template_args['ckan_pkg_name'] = short_name
×
1067
    if notifications:
1✔
1068
        template_args['notifications'] = notifications
1✔
1069
    response = dict(template=template, **template_args)
1✔
1070

1071
    return handle_content_type(response)
1✔
1072

1073
@blueprint.route('/<short_name>/summary', methods=['GET'])
1✔
1074
@login_required
1✔
1075
def summary(short_name):
1✔
1076
    project, owner, ps = project_by_shortname(short_name)
1✔
1077
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
1078
                                                                owner,
1079
                                                                current_user,
1080
                                                                ps)
1081
    ensure_authorized_to('read', project)
1✔
1082
    ensure_authorized_to('update', project)
1✔
1083
    pro = pro_features()
1✔
1084

1085
    response = {"template": '/projects/summary.html',
1✔
1086
                "project": project_sanitized,
1087
                "pro_features": pro,
1088
                "overall_progress": ps.overall_progress,
1089
                }
1090

1091
    return handle_content_type(response)
1✔
1092

1093
@blueprint.route('/<short_name>/settings')
1✔
1094
@login_required
1✔
1095
def settings(short_name):
1✔
1096

1097
    project, owner, ps = project_by_shortname(short_name)
1✔
1098
    title = project_title(project, "Settings")
1✔
1099
    ensure_authorized_to('read', project)
1✔
1100
    ensure_authorized_to('update', project)
1✔
1101
    pro = pro_features()
1✔
1102
    project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
1103
    owner_serialized = cached_users.get_user_summary(owner.name)
1✔
1104
    response = dict(template='/projects/settings.html',
1✔
1105
                    project=project,
1106
                    owner=owner_serialized,
1107
                    n_tasks=ps.n_tasks,
1108
                    overall_progress=ps.overall_progress,
1109
                    n_task_runs=ps.n_task_runs,
1110
                    last_activity=ps.last_activity,
1111
                    n_completed_tasks=ps.n_completed_tasks,
1112
                    n_volunteers=ps.n_volunteers,
1113
                    title=title,
1114
                    pro_features=pro,
1115
                    private_instance=bool(data_access_levels))
1116
    return handle_content_type(response)
1✔
1117

1118

1119
@blueprint.route('/<short_name>/tasks/import', methods=['GET', 'POST'])
1✔
1120
@login_required
1✔
1121
def import_task(short_name):
1✔
1122
    project, owner, ps = project_by_shortname(short_name)
1✔
1123

1124
    ensure_authorized_to('read', project)
1✔
1125
    ensure_authorized_to('update', project)
1✔
1126

1127
    title = project_title(project, "Import Tasks")
1✔
1128
    loading_text = gettext("Importing tasks, this may take a while, wait...")
1✔
1129
    pro = pro_features()
1✔
1130
    dict_project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
1131
    project_sanitized, owner_sanitized = sanitize_project_owner(dict_project,
1✔
1132
                                                                owner,
1133
                                                                current_user,
1134
                                                                ps)
1135
    template_args = dict(title=title, loading_text=loading_text,
1✔
1136
                         project=project_sanitized,
1137
                         owner=owner_sanitized,
1138
                         n_tasks=ps.n_tasks,
1139
                         overall_progress=ps.overall_progress,
1140
                         n_volunteers=ps.n_volunteers,
1141
                         n_completed_tasks=ps.n_completed_tasks,
1142
                         target='project.import_task',
1143
                         pro_features=pro)
1144

1145
    importer_type = request.form.get('form_name') or request.args.get('type')
1✔
1146
    all_importers = importer.get_all_importer_names()
1✔
1147
    if importer_type is not None and importer_type not in all_importers:
1✔
1148
        return abort(404)
1✔
1149
    form = GenericBulkTaskImportForm()(importer_type, request.body)
1✔
1150
    template_args['form'] = form
1✔
1151

1152
    if request.method == 'POST':
1✔
1153
        if form.validate():  # pragma: no cover
1154
            try:
1155
                return _import_tasks(project, **form.get_import_data())
1156
            except BulkImportException as e:
1157
                flash(gettext(str(e)), 'error')
1158
                current_app.logger.exception('project: {} {}'.format(project.short_name, e))
1159
            except Exception as e:
1160
                msg = 'Oops! Looks like there was an error! {}'.format(e)
1161
                flash(gettext(msg), 'error')
1162
                current_app.logger.exception('project: {} {}'.format(project.short_name, e))
UNCOV
1163
        template_args['template'] = '/projects/importers/%s.html' % importer_type
×
1164
        return handle_content_type(template_args)
×
1165

1166
    if request.method == 'GET':
1✔
1167
        template_tasks = current_app.config.get('TEMPLATE_TASKS')
1✔
1168
        if importer_type is None:
1✔
1169
            if len(all_importers) == 1:
1✔
UNCOV
1170
                return redirect_content_type(url_for('.import_task',
×
1171
                                                     short_name=short_name,
1172
                                                     type=all_importers[0]))
1173
            template_wrap = lambda i: "projects/tasks/gdocs-%s.html" % i
1✔
1174
            task_tmpls = list(map(template_wrap, template_tasks))
1✔
1175
            template_args['task_tmpls'] = task_tmpls
1✔
1176
            importer_wrap = lambda i: "projects/tasks/%s.html" % i
1✔
1177
            template_args['available_importers'] = list(map(importer_wrap, all_importers))
1✔
1178
            template_args['template'] = '/projects/task_import_options.html'
1✔
1179
            return handle_content_type(template_args)
1✔
1180
        if importer_type == 'gdocs' and request.args.get('template'):  # pragma: no cover
1181
            template = request.args.get('template')
1182
            form.googledocs_url.data = template_tasks.get(template)
1183
        template_args['template'] = '/projects/importers/%s.html' % importer_type
1✔
1184
        return handle_content_type(template_args)
1✔
1185

1186

1187
def _import_tasks(project, **form_data):
1✔
1188

1189
    report = None
1✔
1190
    number_of_tasks = importer.count_tasks_to_import(**form_data)
1✔
1191
    if number_of_tasks <= MAX_NUM_SYNCHRONOUS_TASKS_IMPORT:
1✔
1192
        report = importer.create_tasks(task_repo, project, **form_data)
1✔
1193
        flash(report.message)
1✔
1194
        if report.total > 0:
1✔
1195
            # reset cache / memoized
1196
            delete_memoized(get_searchable_columns)
1✔
1197
            delete_memoized(n_available_tasks_for_user)
1✔
1198
            cached_projects.delete_browse_tasks(project.id)
1✔
1199
            check_and_send_task_notifications(project.id)
1✔
1200
    else:
1201
        importer_queue.enqueue(import_tasks, project.id, current_user.fullname, **form_data)
1✔
1202
        flash(gettext("You're trying to import a large amount of tasks, so please be patient.\
1✔
1203
            You will receive an email when the tasks are ready."))
1204

1205
    if not report or report.total > 0: #success
1✔
1206
        return redirect_content_type(url_for('.tasks', short_name=project.short_name))
1✔
1207
    else:
1208
        return redirect_content_type(url_for('.import_task',
1✔
1209
                                         short_name=project.short_name,
1210
                                         type=form_data['type']))
1211

1212

1213
@blueprint.route('/<short_name>/tasks/autoimporter', methods=['GET', 'POST'])
1✔
1214
@login_required
1✔
1215
@admin_required
1✔
1216
def setup_autoimporter(short_name):
1✔
1217
    pro = pro_features()
1✔
1218
    if not pro['autoimporter_enabled']:
1✔
UNCOV
1219
        raise abort(403)
×
1220

1221
    project, owner, ps = project_by_shortname(short_name)
1✔
1222

1223
    dict_project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
1224
    template_args = dict(project=dict_project,
1✔
1225
                         owner=owner,
1226
                         n_tasks=ps.n_tasks,
1227
                         overall_progress=ps.overall_progress,
1228
                         n_volunteers=ps.n_volunteers,
1229
                         n_completed_tasks=ps.n_completed_tasks,
1230
                         pro_features=pro,
1231
                         target='project.setup_autoimporter')
1232
    ensure_authorized_to('read', project)
1✔
1233
    ensure_authorized_to('update', project)
1✔
1234
    importer_type = request.form.get('form_name') or request.args.get('type')
1✔
1235
    all_importers = importer.get_autoimporter_names()
1✔
1236
    if importer_type is not None and importer_type not in all_importers:
1✔
1237
        raise abort(404)
1✔
1238
    form = GenericBulkTaskImportForm()(importer_type, request.form)
1✔
1239
    template_args['form'] = form
1✔
1240

1241
    if project.has_autoimporter():
1✔
1242
        current_autoimporter = project.get_autoimporter()
1✔
1243
        importer_info = dict(**current_autoimporter)
1✔
1244
        return render_template('/projects/task_autoimporter.html',
1✔
1245
                                importer=importer_info, **template_args)
1246

1247
    if request.method == 'POST':
1✔
1248
        if form.validate():  # pragma: no cover
1249
            project.set_autoimporter(form.get_import_data())
1250
            project_repo.save(project)
1251
            auditlogger.log_event(project, current_user, 'create', 'autoimporter',
1252
                                  'Nothing', json.dumps(project.get_autoimporter()))
1253
            flash(gettext("Success! Tasks will be imported daily."))
1254
            return redirect(url_for('.setup_autoimporter', short_name=project.short_name))
1255

1256
    if request.method == 'GET':
1✔
1257
        if importer_type is None:
1✔
1258
            wrap = lambda i: "projects/tasks/%s.html" % i
1✔
1259
            template_args['available_importers'] = list(map(wrap, all_importers))
1✔
1260
            return render_template('projects/task_autoimport_options.html',
1✔
1261
                                   **template_args)
1262
    return render_template('/projects/importers/%s.html' % importer_type,
1✔
1263
                                **template_args)
1264

1265

1266
@blueprint.route('/<short_name>/tasks/autoimporter/delete', methods=['POST'])
1✔
1267
@login_required
1✔
1268
@admin_required
1✔
1269
def delete_autoimporter(short_name):
1✔
1270
    pro = pro_features()
1✔
1271
    if not pro['autoimporter_enabled']:
1✔
UNCOV
1272
        raise abort(403)
×
1273

1274
    project = project_by_shortname(short_name)[0]
1✔
1275

1276
    ensure_authorized_to('read', project)
1✔
1277
    ensure_authorized_to('update', project)
1✔
1278
    if project.has_autoimporter():
1✔
1279
        autoimporter = project.get_autoimporter()
1✔
1280
        project.delete_autoimporter()
1✔
1281
        project_repo.save(project)
1✔
1282
        auditlogger.log_event(project, current_user, 'delete', 'autoimporter',
1✔
1283
                              json.dumps(autoimporter), 'Nothing')
1284
    return redirect(url_for('.tasks', short_name=project.short_name))
1✔
1285

1286

1287
@blueprint.route('/<short_name>/password', methods=['GET', 'POST'])
1✔
1288
@login_required
1✔
1289
def password_required(short_name):
1✔
1290
    project, owner, ps = project_by_shortname(short_name)
1✔
1291
    ensure_authorized_to('read', project, forbidden_code_override=423)
1✔
1292
    form = PasswordForm(request.form)
1✔
1293

1294
    # if is_own_url_or_else returns None, use the default url.
1295
    # This avoids exception of redirect(next_url)
1296
    next_url = is_own_url_or_else(request.args.get('next'), url_for('home.home')) or url_for('home.home')
1✔
1297

1298
    if request.method == 'POST' and form.validate():
1✔
1299
        password = request.form.get('password')
1✔
1300
        cookie_exp = current_app.config.get('PASSWD_COOKIE_TIMEOUT')
1✔
1301
        passwd_mngr = ProjectPasswdManager(CookieHandler(request, signer, cookie_exp))
1✔
1302
        if passwd_mngr.validates(password, project):
1✔
1303
            response = make_response(redirect(next_url))
1✔
1304
            return passwd_mngr.update_response(response, project, get_user_id_or_ip())
1✔
1305
        flash(gettext('Sorry, incorrect password'))
1✔
1306
    return render_template('projects/password.html',
1✔
1307
                            project=project,
1308
                            form=form,
1309
                            short_name=short_name,
1310
                            next=next_url,
1311
                            pro_features=pro_features())
1312

1313

1314
@blueprint.route('/<short_name>/make-random-gold')
1✔
1315
@login_required
1✔
1316
def make_random_task_gold(short_name):
1✔
1317
    project, owner, ps = project_by_shortname(short_name)
1✔
1318
    ensure_authorized_to('update', project)
1✔
1319
    task = select_task_for_gold_mode(project, current_user.id)
1✔
1320
    if not task:
1✔
1321
        flash(gettext('There Are No Tasks Avaiable!'), 'error')
1✔
1322
        return redirect_content_type(url_for('.details', short_name=short_name))
1✔
1323
    return redirect_content_type(
1✔
1324
        url_for(
1325
            '.task_presenter',
1326
            short_name=short_name,
1327
            task_id=task.id,
1328
            mode='gold',
1329
            bulk=True
1330
        )
1331
    )
1332

1333

1334
@blueprint.route('/<short_name>/task/<int:task_id>')
1✔
1335
@blueprint.route('/<short_name>/task/<int:task_id>/<int:task_submitter_id>')
1✔
1336
@login_required
1✔
1337
def task_presenter(short_name, task_id, task_submitter_id=None):
1✔
1338
    """
1339
    Displaying task presenter code. There are two endpoints. One for submitting
1340
    task (including read-only viewing tasks and cherry-pick modes) and the other
1341
    for viewing user's completed response (with task_submitter_id in the
1342
    endpoint)
1343
    :param short_name: project's short name
1344
    :param task_id: task ID
1345
    :param task_submitter_id: task submitter's user id. It is received only upon
1346
    viewing the task answers by the user
1347
    """
1348
    mode = request.args.get('mode')
1✔
1349
    project, owner, ps = project_by_shortname(short_name)
1✔
1350
    ensure_authorized_to('read', project, forbidden_code_override=423)
1✔
1351
    task = task_repo.get_task(id=task_id)
1✔
1352
    if task is None:
1✔
UNCOV
1353
        raise abort(404)
×
1354
    if project.needs_password():
1✔
1355
        redirect_to_password = _check_if_redirect_to_password(project)
1✔
1356
        if redirect_to_password:
1✔
1357
            return redirect_to_password
1✔
1358
    else:
1359
        ensure_authorized_to('read', project, forbidden_code_override=423)
1✔
1360

1361
    if current_user.is_anonymous:
1✔
UNCOV
1362
        if not project.allow_anonymous_contributors:
×
1363
            msg = ("Oops! You have to sign in to participate in "
×
1364
                   "<strong>%s</strong>"
1365
                   "project" % project.name)
UNCOV
1366
            flash(Markup(gettext(msg)), 'warning')
×
1367
            return redirect(url_for('account.signin',
×
1368
                                    next=url_for('.presenter',
1369
                                    short_name=project.short_name)))
1370
        else:
UNCOV
1371
            msg_1 = gettext(
×
1372
                "Ooops! You are an anonymous user and will not "
1373
                "get any credit"
1374
                " for your contributions.")
UNCOV
1375
            msg_2 = gettext('Sign in now!')
×
1376
            next_url = url_for('project.task_presenter',
×
1377
                                short_name=short_name, task_id=task_id)
UNCOV
1378
            url = url_for('account.signin', next=next_url)
×
1379
            markup = Markup('{{}} <a href="{}">{{}}</a>'.format(url))
×
1380
            flash(markup.format(msg_1, msg_2), "warning")
×
1381

1382
    scheduler = project.info.get('sched', "default")
1✔
1383
    if (not (current_user.admin or current_user.id in project.owners_ids)) or request.args.get('view') == 'tasklist':
1✔
1384
        # Allow lock when scheduler is task_queue and user is a worker or user is admin/subadminm/coowner in task view.
1385
        if scheduler == sched.Schedulers.task_queue and mode == "cherry_pick":
1✔
UNCOV
1386
            lock_task_for_user(task_id, project.id, current_user.id)
×
1387
        elif project.info.get("allow_taskrun_edit") and task_submitter_id:
1✔
1388
            # with project with edit submissions enabled and task_submitter_id passed
1389
            # task response would be displayed later
1390
            pass
1✔
1391
        elif not sched.can_read_task(task, current_user):
1✔
UNCOV
1392
            raise abort(403)
×
1393

1394
    title = project_title(project, "Contribute")
1✔
1395
    project_sanitized, owner_sanitized = sanitize_project_owner(project, owner,
1✔
1396
                                                                current_user,
1397
                                                                ps)
1398

1399
    user_id_or_ip = get_user_id_or_ip()
1✔
1400
    user_id = user_id_or_ip['user_id'] or user_id_or_ip['external_uid'] or user_id_or_ip['user_ip']
1✔
1401
    template_args = {
1✔
1402
        "project": project_sanitized,
1403
        "title": title,
1404
        "owner": owner_sanitized,
1405
        "mode": mode,
1406
        "bulk": request.args.get('bulk', False),
1407
        "user_id": user_id
1408
    }
1409

1410
    if task_submitter_id:
1✔
1411
        taskruns = task_repo.filter_task_runs_by(task_id=task_id,
1✔
1412
                                                 project_id=project.id,
1413
                                                 user_id=task_submitter_id)
1414
        user_response = taskruns[0].info
1✔
1415
        tp_code = template_args["project"].get("info", {}).get("task_presenter", '')
1✔
1416

1417
        for response_field, response_value in user_response.items():
1✔
1418
            if type(response_value) is dict:
1✔
1419
                odfoa_response = check_annex_response(response_value)
1✔
1420
                if odfoa_response:
1✔
1421
                    tp_code = process_annex_load(tp_code, odfoa_response)
1✔
1422

1423
        tp_code = process_tp_components(tp_code, user_response)
1✔
1424
        tp_code = process_table_component(tp_code, user_response, task)
1✔
1425
        template_args["project"]["info"]["task_presenter"] = tp_code
1✔
1426

1427
        # with edit submission, pass task run id so that taskrun can be updated
1428
        if request.args.get("mode") == "edit_submission":
1✔
1429
            template_args["taskrun_id"] = taskruns[0].id
1✔
1430
            template_args["taskrun_user_id"] = taskruns[0].user_id
1✔
1431

1432
    def respond(tmpl):
1✔
1433
        response = dict(template=tmpl, **template_args)
1✔
1434
        return handle_content_type(response)
1✔
1435

1436
    if not (task.project_id == project.id):
1✔
1437
        return respond('/projects/task/wrong.html')
1✔
1438

1439
    # bypass lock check for the submitter when submitter can edit their response
1440
    bypass_lock_check = request.args.get("mode") == "edit_submission" and \
1✔
1441
        project.info.get("allow_taskrun_edit") and \
1442
        current_user.id == task_submitter_id
1443

1444
    if not bypass_lock_check:
1✔
1445
        guard = ContributionsGuard(sentinel.master,
1✔
1446
                                timeout=project.info.get('timeout'))
1447
        guard.stamp(task, get_user_id_or_ip())
1✔
1448

1449
        # Verify the worker has an unexpired lock on the task. Otherwise, task will fail to submit.
1450
        timeout, ttl = fetch_lock_for_user(task.project_id, task_id, user_id)
1✔
1451
        remaining_time = float(ttl) - time.time() if ttl else None
1✔
1452
        if (not remaining_time or remaining_time <= 0) and mode != 'read_only':
1✔
1453
            current_app.logger.info("unable to lock task or task expired. \
1✔
1454
                                    project %s, task %s, user %s, remaining time %s, mode %s",
1455
                                    task.project_id, task_id, user_id, remaining_time, mode)
1456
            flash(gettext("Unable to lock task or task expired. Please cancel and begin a new task."), "error")
1✔
1457
        else:
1458
            if mode != 'read_only':
1✔
1459
                # Set the original timeout seconds to display in the message.
1460
                template_args['project']['original_timeout'] = timeout
1✔
1461
                # Set the seconds remaining to display in the message.
1462
                template_args['project']['timeout'] = remaining_time
1✔
1463
                current_app.logger.info("User %s present task %s, remaining time %s, original timeout %s",
1✔
1464
                                        user_id, task_id, remaining_time, timeout)
1465

1466
            if not guard.check_task_presented_timestamp(task, get_user_id_or_ip()):
1✔
1467
                guard.stamp_presented_time(task, get_user_id_or_ip())
1✔
1468

1469
            if has_no_presenter(project):
1✔
1470
                flash(gettext("Sorry, but this project is still a draft and does "
1✔
1471
                            "not have a task presenter."), "error")
1472

1473
    return respond('/projects/presenter.html')
1✔
1474

1475

1476
@blueprint.route('/<short_name>/presenter')
1✔
1477
@blueprint.route('/<short_name>/newtask')
1✔
1478
@login_required
1✔
1479
def presenter(short_name):
1✔
1480

1481
    def respond(tmpl):
1✔
1482
        if (current_user.is_anonymous):
1✔
UNCOV
1483
            msg_1 = gettext(msg)
×
1484
            flash(msg_1, "warning")
×
1485
        resp = make_response(render_template(tmpl, **template_args))
1✔
1486
        return resp
1✔
1487

1488
    project, owner, ps = project_by_shortname(short_name)
1✔
1489
    project.timeout = project.info.get('timeout', DEFAULT_TASK_TIMEOUT)
1✔
1490

1491
    ensure_authorized_to('read', project, forbidden_code_override=423)
1✔
1492

1493
    if project.needs_password():
1✔
1494
        redirect_to_password = _check_if_redirect_to_password(project)
1✔
1495
        if redirect_to_password:
1✔
1496
            return redirect_to_password
1✔
1497

1498
    title = project_title(project, "Contribute")
1✔
1499
    user_id_or_ip = get_user_id_or_ip()
1✔
1500
    user_id = user_id_or_ip['user_id'] or user_id_or_ip['external_uid'] or user_id_or_ip['user_ip']
1✔
1501
    template_args = {"project": project, "title": title, "owner": owner,
1✔
1502
                     "invite_new_volunteers": False, "user_id": user_id}
1503

1504
    current_app.logger.info("User %s request task, original timeout %s", user_id, project.timeout)
1✔
1505

1506
    if request.args.get("mode"):
1✔
UNCOV
1507
        template_args["mode"] = request.args.get("mode")
×
1508

1509
    if not project.allow_anonymous_contributors and current_user.is_anonymous:
1✔
UNCOV
1510
        msg = "Oops! You have to sign in to participate in <strong>%s</strong> \
×
1511
               project" % project.name
UNCOV
1512
        flash(Markup(gettext(msg)), 'warning')
×
1513
        return redirect(url_for('account.signin',
×
1514
                        next=url_for('.presenter',
1515
                                     short_name=project.short_name)))
1516

1517
    msg = "Ooops! You are an anonymous user and will not \
1✔
1518
           get any credit for your contributions. Sign in \
1519
           now!"
1520

1521
    if project.info.get("tutorial") and \
1✔
1522
            request.cookies.get(project.short_name + "tutorial") is None:
1523
        resp = respond('/projects/tutorial.html')
1✔
1524
        resp.set_cookie(project.short_name + 'tutorial', 'seen')
1✔
1525
        return resp
1✔
1526
    else:
1527
        if has_no_presenter(project):
1✔
UNCOV
1528
            flash(gettext("Sorry, but this project is still a draft and does "
×
1529
                          "not have a task presenter."), "error")
1530

1531
        # Set the original timeout seconds to display in the message.
1532
        timeout = project.timeout
1✔
1533
        template_args['project'].original_timeout = timeout
1✔
1534

1535
        # Get locked task for this project.
1536
        task_id, remaining_time = get_task_id_and_duration_for_project_user(project.id, user_id)
1✔
1537
        # If this user already has a locked task, take the timeout for the first one being served else new.
1538
        template_args['project'].timeout = remaining_time if task_id and remaining_time > 10 else timeout
1✔
1539

1540
        current_app.logger.info("User %s present task %s, remaining time %s, original timeout %s",
1✔
1541
                                user_id, task_id, template_args['project'].timeout,
1542
                                template_args['project'].original_timeout)
1543

1544
        # Save saved_task_position (either "last" or "first" to Redis)
1545
        saved_task_position = request.args.get('saved_task_position')
1✔
1546
        if saved_task_position:
1✔
1547
            saved_task_position = saved_task_position.lower()
1✔
1548
            if saved_task_position in list(SavedTaskPositionEnum):
1✔
1549
                position_key = PARTIAL_ANSWER_POSITION_KEY.format(project_id=project.id, user_id=user_id)
1✔
1550
                sentinel.master.setex(position_key, ONE_DAY, saved_task_position)
1✔
1551

1552
        return respond('/projects/presenter.html')
1✔
1553

1554

1555
@blueprint.route('/<short_name>/tutorial')
1✔
1556
def tutorial(short_name):
1✔
1557
    project, owner, ps = project_by_shortname(short_name)
1✔
1558
    ensure_authorized_to('read', project, forbidden_code_override=423)
1✔
1559
    title = project_title(project, "Tutorial")
1✔
1560

1561
    if project.needs_password():
1✔
UNCOV
1562
        redirect_to_password = _check_if_redirect_to_password(project)
×
1563
        if redirect_to_password:
×
1564
            return redirect_to_password
×
1565

1566
    project_sanitized, owner_sanitized = sanitize_project_owner(project, owner,
1✔
1567
                                                                current_user,
1568
                                                                ps)
1569

1570
    response = dict(template='/projects/tutorial.html', title=title,
1✔
1571
                    project=project_sanitized, owner=owner_sanitized)
1572

1573
    return handle_content_type(response)
1✔
1574

1575

1576
@blueprint.route('/<short_name>/<int:task_id>/results.json')
1✔
1577
@login_required
1✔
1578
def export(short_name, task_id):
1✔
1579
    """Return a file with all the TaskRuns for a given Task"""
1580
    # Check if the project exists and current_user has valid access to it
1581
    project, owner, ps = allow_deny_project_info(short_name)
1✔
1582
    ensure_authorized_to('read', project)
1✔
1583

1584
    if project.needs_password():
1✔
1585
        redirect_to_password = _check_if_redirect_to_password(project)
1✔
1586
        if redirect_to_password:
1✔
UNCOV
1587
            return redirect_to_password
×
1588

1589
    # Check if the task belongs to the project and exists
1590
    task = task_repo.get_task_by(project_id=project.id, id=task_id)
1✔
1591
    if task:
1✔
1592
        taskruns = task_repo.filter_task_runs_by(task_id=task_id, project_id=project.id)
1✔
1593
        taskruns_info = [tr.dictize() for tr in taskruns]
1✔
1594
        can_know_task_is_gold = current_user.subadmin or current_user.admin
1✔
1595
        gold_answers = task.gold_answers if can_know_task_is_gold and task.calibration and task.gold_answers else {}
1✔
1596
        results = dict(taskruns_info=taskruns_info, gold_answers=gold_answers)
1✔
1597
        return Response(json.dumps(results), mimetype='application/json')
1✔
1598
    else:
1599
        return abort(404)
1✔
1600

1601

1602
@blueprint.route('/<short_name>/<int:task_id>/result_status')
1✔
1603
@login_required
1✔
1604
def export_statuses(short_name, task_id):
1✔
1605
    """Return a file with all TaskRun statuses for a given Task"""
1606
    project, owner, ps = allow_deny_project_info(short_name)
1✔
1607
    ensure_authorized_to('read', project)
1✔
1608

1609
    if project.needs_password():
1✔
1610
        redirect_to_password = _check_if_redirect_to_password(project)
1✔
1611
        if redirect_to_password:
1✔
UNCOV
1612
            return redirect_to_password
×
1613

1614
    task = task_repo.get_task(task_id)
1✔
1615

1616
    if not task:
1✔
1617
        return abort(404)
1✔
1618

1619
    locks = _get_locks(project.id, task_id)
1✔
1620
    users_completed = [tr.user_id for tr in task.task_runs]
1✔
1621
    users = user_repo.get_users(
1✔
1622
            set(users_completed + list(locks.keys())))
1623
    user_details = [dict(user_id=user.id,
1✔
1624
                         lock_ttl=locks.get(user.id),
1625
                         user_email=user.email_addr)
1626
                    for user in users]
1627

1628
    for user_detail in user_details:
1✔
1629
        if user_detail['user_id'] in users_completed:
1✔
1630
            user_detail['status'] = 'Completed'
1✔
1631
        elif user_detail['lock_ttl']:
1✔
1632
            user_detail['status'] = 'Locked'
1✔
1633

1634
    # gold tasks may have more answers than redundancy 1
1635
    # in rare case, task may have answers more than redundancy
1636
    redundancy = max(task.n_answers, len(task.task_runs))
1✔
1637
    tr_statuses = dict(redundancy=redundancy,
1✔
1638
                       user_details=user_details)
1639

1640
    return jsonify(tr_statuses)
1✔
1641

1642

1643
def _get_locks(project_id, task_id):
1✔
1644
    _sched, timeout = sched.get_project_scheduler_and_timeout(
1✔
1645
            project_id)
1646
    locks = sched.get_locks(task_id, timeout)
1✔
1647
    now = time.time()
1✔
1648
    lock_ttls = {int(k): float(v) - now
1✔
1649
                 for k, v in locks.items()}
1650
    return lock_ttls
1✔
1651

1652

1653
@blueprint.route('/<short_name>/tasks/')
1✔
1654
@login_required
1✔
1655
def tasks(short_name):
1✔
1656
    project, owner, ps = project_by_shortname(short_name)
1✔
1657
    ensure_authorized_to('read', project, forbidden_code_override=423)
1✔
1658
    title = project_title(project, "Tasks")
1✔
1659

1660
    if project.needs_password():
1✔
1661
        redirect_to_password = _check_if_redirect_to_password(project)
1✔
1662
        if redirect_to_password:
1✔
UNCOV
1663
            return redirect_to_password
×
1664

1665
    pro = pro_features()
1✔
1666
    project = add_custom_contrib_button_to(project, get_user_id_or_ip())
1✔
1667
    feature_handler = ProFeatureHandler(current_app.config.get('PRO_FEATURES'))
1✔
1668
    autoimporter_enabled = feature_handler.autoimporter_enabled_for(current_user)
1✔
1669

1670
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
1671
                                                                owner,
1672
                                                                current_user,
1673
                                                                ps)
1674

1675
    response = dict(template='/projects/tasks.html',
1✔
1676
                    title=title,
1677
                    project=project_sanitized,
1678
                    owner=owner_sanitized,
1679
                    autoimporter_enabled=autoimporter_enabled,
1680
                    n_tasks=ps.n_tasks,
1681
                    n_task_runs=ps.n_task_runs,
1682
                    overall_progress=ps.overall_progress,
1683
                    last_activity=ps.last_activity,
1684
                    n_completed_tasks=ps.n_completed_tasks,
1685
                    n_volunteers=ps.n_volunteers,
1686
                    pro_features=pro)
1687

1688
    return handle_content_type(response)
1✔
1689

1690

1691
@blueprint.route('/<short_name>/tasks/browse')
1✔
1692
@blueprint.route('/<short_name>/tasks/browse/<int:page>')
1✔
1693
@blueprint.route('/<short_name>/tasks/browse/<int:page>/<int:records_per_page>')
1✔
1694
@login_required
1✔
1695
def tasks_browse(short_name, page=1, records_per_page=None):
1✔
1696
    project, owner, ps = project_by_shortname(short_name)
1✔
1697
    ensure_authorized_to('read', project, forbidden_code_override=423)
1✔
1698

1699
    title = project_title(project, "Tasks")
1✔
1700
    pro = pro_features()
1✔
1701
    allowed_records_per_page = [10, 20, 30, 50, 70, 100]
1✔
1702
    admin_subadmin_coowner = current_user.subadmin or current_user.admin or current_user.id in project.owners_ids
1✔
1703
    # regular user who's either not admin, subadmin or a subadmin who's not coowner to the project
1704
    regular_user = not (current_user.admin or current_user.subadmin) or (current_user.subadmin and current_user.id not in project.owners_ids)
1✔
1705
    allow_taskrun_edit = project.info.get("allow_taskrun_edit") or False
1✔
1706

1707
    try:
1✔
1708
        columns = get_searchable_columns(project.id)
1✔
UNCOV
1709
    except Exception:
×
1710
        current_app.logger.exception('Error getting columns')
×
1711
        columns = []
×
1712

1713
    scheduler = project.info.get('sched', "default")
1✔
1714

1715
    try:
1✔
1716
        args = {}
1✔
1717
        view_type = request.args.get('view')
1✔
1718
        task_browse_default_records_per_page = 10
1✔
1719
        task_list_default_records_per_page = 30
1✔
1720
        if view_type not in ["tasklist", "edit_submission"] and admin_subadmin_coowner:
1✔
1721
            # owners and (sub)admin have full access, default size page for owner view is 10
1722
            per_page = records_per_page if records_per_page in allowed_records_per_page else task_browse_default_records_per_page
1✔
1723
            # parse args
1724
            args = parse_tasks_browse_args(request.args.to_dict())
1✔
1725
        elif (view_type != 'tasklist' and allow_taskrun_edit and regular_user) or (view_type == "edit_submission" and admin_subadmin_coowner):
1✔
1726
            # browse tasks for a regular user to be available when
1727
            # 1. project is configured to allow editing of task runs
1728
            # 2. browse task request is not for task list
1729
            dict_args = request.args.to_dict()
1✔
1730

1731
            # show columns that are permitted for regular users
1732
            if regular_user:
1✔
1733
                dict_args["display_columns"] = ["task_id", "priority", "finish_time", "created"]
1✔
1734
                # show task.info columns that are configured under tasklist_columns
1735
                dict_args["display_info_columns"] = project.info.get('tasklist_columns', [])
1✔
1736
                # restrict filter columns to the columns configured under project settings
1737
                columns = dict_args["display_info_columns"]
1✔
1738
            args = parse_tasks_browse_args(dict_args)
1✔
1739
            args["user_id"] = current_user.id
1✔
1740
            args["allow_taskrun_edit"] = True
1✔
1741
            per_page = records_per_page if records_per_page in allowed_records_per_page else task_browse_default_records_per_page
1✔
1742
        elif scheduler == Schedulers.task_queue:
1✔
1743
            # worker can access limited tasks only when task_queue_scheduler is selected
1744
            user = cached_users.get_user_by_id(current_user.id)
1✔
1745
            user_pref = user.user_pref or {} if user else {}
1✔
1746
            user_email = user.email_addr if user else None
1✔
1747
            user_profile = cached_users.get_user_profile_metadata(current_user.id)
1✔
1748
            user_profile = json.loads(user_profile) if user_profile else {}
1✔
1749
            # get task bundling sql filters
1750
            reserve_task_config = project.info.get("reserve_tasks", {}).get("category", [])
1✔
1751
            reserve_task_filter, _ = sched.get_reserve_task_category_info(reserve_task_config, project.id,
1✔
1752
                                                                        project.info.get("timeout"),
1753
                                                                        current_user.id,
1754
                                                                        True)
1755
            # parse args
1756
            dict_args = request.args.to_dict()
1✔
1757
            dict_args["display_info_columns"] = project.info.get('tasklist_columns', [])
1✔
1758
            columns = dict_args["display_info_columns"]
1✔
1759
            args = parse_tasks_browse_args(dict_args)
1✔
1760

1761
            # populate additional args for task list view
1762
            args["filter_by_wfilter_upref"] = dict(current_user_pref=user_pref,
1✔
1763
                                                current_user_email=user_email,
1764
                                                current_user_profile=user_profile,
1765
                                                reserve_filter=reserve_task_filter)
1766
            args["sql_params"] = dict(assign_user=json.dumps({'assign_user': [user_email]}))
1✔
1767
            args["display_columns"] = ['task_id', 'priority', 'created', 'in_progress']
1✔
1768
            args["view"] = view_type
1✔
1769
            args["regular_user"] = regular_user
1✔
1770
            # default page size for worker view is 100
1771
            per_page = records_per_page if records_per_page in allowed_records_per_page else task_list_default_records_per_page
1✔
1772
        elif view_type == 'tasklist' and n_available_tasks_for_user(project, current_user.id) == 0:
1✔
1773
            # When no tasks are available, redirect to browse all tasks view.
1774
            flash("No new tasks available")
1✔
1775
            new_path = '/project/{}/tasks/browse'.format(project.short_name)
1✔
1776
            return redirect_content_type(new_path)
1✔
1777
        else:
1778
            abort(403)
1✔
1779
    except (ValueError, TypeError) as err:
1✔
UNCOV
1780
        current_app.logger.exception(err)
×
1781
        flash(gettext('Invalid filtering criteria'), 'error')
×
1782
        abort(404)
×
1783
    can_know_task_is_gold = current_user.subadmin or current_user.admin
1✔
1784
    if not can_know_task_is_gold:
1✔
1785
        # This has to be a list and not a set because it is JSON stringified in the template
1786
        # and sets are not stringifiable.
1787
        args['display_columns'] = list(set(args['display_columns']) - {'gold_task'})
1✔
1788

1789
    def respond():
1✔
1790
        offset = (page - 1) * per_page
1✔
1791
        args["records_per_page"] = per_page
1✔
1792
        args["offset"] = offset
1✔
1793
        start_time = time.time()
1✔
1794
        force_refresh = scheduler == Schedulers.task_queue and \
1✔
1795
                not (current_user.subadmin or current_user.admin or current_user.id in project["owners_ids"])
1796
        total_count, page_tasks = cached_projects.browse_tasks(
1✔
1797
            project.get('id'),
1798
            args,
1799
            bool(args.get("filter_by_wfilter_upref")),
1800
            current_user.id,
1801
            force_refresh=force_refresh
1802
        )
1803
        current_app.logger.debug("Browse Tasks data loading took %s seconds"
1✔
1804
                                 % (time.time()-start_time))
1805
        first_task_id = cached_projects.first_task_id(project.get('id'))
1✔
1806
        pagination = Pagination(page, per_page, total_count)
1✔
1807

1808
        project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
1809
                                                                    owner,
1810
                                                                    current_user,
1811
                                                                    ps)
1812

1813
        disp_info_columns = args.get('display_info_columns', [])
1✔
1814
        disp_info_columns = [col for col in disp_info_columns if col in columns]
1✔
1815

1816
        # clean up arguments for the url
1817
        args["changed"] = False
1✔
1818
        if args.get("pcomplete_from"):
1✔
UNCOV
1819
            args["pcomplete_from"] = args["pcomplete_from"] * 100
×
1820
        if args.get("pcomplete_to"):
1✔
UNCOV
1821
            args["pcomplete_to"] = args["pcomplete_to"] * 100
×
1822
        args["order_by"] = args.pop("order_by_dict", dict())
1✔
1823
        args.pop("records_per_page", None)
1✔
1824
        args.pop("offset", None)
1✔
1825
        args.pop('filter_by_wfilter_upref', None)
1✔
1826
        args.pop('sql_params', None)
1✔
1827
        args.pop('user_id', None)
1✔
1828

1829
        if disp_info_columns:
1✔
UNCOV
1830
            for task in page_tasks:
×
1831
                task_info = task_repo.get_task(task['id']).info
×
1832
                task['info'] = {}
×
1833
                for col in disp_info_columns:
×
1834
                    task['info'][col] = task_info.get(col, '')
×
1835

1836
        if 'lock_status' in args['display_columns']:
1✔
1837
            def get_users_completed(task):
1✔
1838
                return task['lock_users']
1✔
1839
            # Populate list of user names for "Active Users" column.
1840
            get_users_fullname(page_tasks, lambda task: get_users_completed(task), 'lock_users')
1✔
1841

1842
        if 'completed_by' in args['display_columns']:
1✔
1843
            def get_users_completed(task):
1✔
1844
                # Load task object.
1845
                task_obj = task_repo.get_task(task['id'])
1✔
1846
                # Return task run worker names.
1847
                return [str(tr.user_id) for tr in task_obj.task_runs]
1✔
1848
            # Populate list of user names for "Completed By" column.
1849
            get_users_fullname(page_tasks, lambda task: get_users_completed(task), 'completed_users')
1✔
1850

1851
        taskbrowse_bookmarks = get_bookmarks(current_user.name, short_name, None, None)
1✔
1852

1853
        valid_user_preferences = app_settings.upref_mdata.get_valid_user_preferences() \
1✔
1854
            if app_settings.upref_mdata else {}
1855
        language_options = valid_user_preferences.get('languages')
1✔
1856
        location_options = valid_user_preferences.get('locations')
1✔
1857
        rdancy_upd_exp = current_app.config.get('TASK_EXPIRATION', 60)
1✔
1858

1859
        data = dict(template='/projects/tasks_browse.html',
1✔
1860
                    users=[],
1861
                    project=project_sanitized,
1862
                    owner=owner_sanitized,
1863
                    tasks=page_tasks,
1864
                    title=title,
1865
                    pagination=pagination,
1866
                    n_tasks=ps.n_tasks,
1867
                    overall_progress=ps.overall_progress,
1868
                    n_volunteers=ps.n_volunteers,
1869
                    n_completed_tasks=ps.n_completed_tasks,
1870
                    pro_features=pro,
1871
                    allowed_records_per_page=allowed_records_per_page,
1872
                    records_per_page=per_page,
1873
                    filter_data=args,
1874
                    first_task_id=first_task_id,
1875
                    info_columns=disp_info_columns,
1876
                    filter_columns=[c for c in columns if c not in RESERVED_TASKLIST_COLUMNS],
1877
                    language_options=language_options,
1878
                    location_options=location_options,
1879
                    reserved_options=RESERVED_TASKLIST_COLUMNS,
1880
                    rdancy_upd_exp=rdancy_upd_exp,
1881
                    can_know_task_is_gold=can_know_task_is_gold,
1882
                    allow_taskrun_edit=allow_taskrun_edit,
1883
                    regular_user=regular_user,
1884
                    admin_subadmin_coowner=admin_subadmin_coowner,
1885
                    taskbrowse_bookmarks=taskbrowse_bookmarks)
1886

1887

1888
        return handle_content_type(data)
1✔
1889

1890
    def get_users_fullname(page_tasks, get_users_func, result_field):
1✔
1891
        user_info = {}
1✔
1892
        # Enumerate all tasks in the page.
1893
        for task in page_tasks:
1✔
1894
            users = []
1✔
1895
            # Retrieve the list of user ids to obtain names for (from either the task or task_run, selectable by calling func).
1896
            users_list = get_users_func(task)
1✔
1897
            # For each user id, load the fullname.
1898
            for user_id in users_list:
1✔
1899
                try:
1✔
1900
                    # show fullname for users
1901
                    user_id = int(user_id)
1✔
1902
                    if not user_info.get(user_id):
1✔
1903
                        # Load from cache.
1904
                        user_info[user_id] = cached_users.get_user_by_id(user_id).fullname
1✔
1905
                    users.append(user_info[user_id])
1✔
UNCOV
1906
                except ValueError:
×
1907
                    # Error locating user id.
1908
                    current_app.logger.info("User does not have valid user id in get_users_fullname(): %s", user_id)
×
1909
            task[result_field] = ", ".join(users)
1✔
1910
        return user_info
1✔
1911

1912
    def respond_export(download_type, args):
1✔
1913
        download_specs = download_type.split('-')
1✔
1914
        download_obj = download_specs[0]
1✔
1915
        download_format = download_specs[1]
1✔
1916
        if len(download_specs) > 2:
1✔
UNCOV
1917
            metadata = bool(download_specs[2])
×
1918
        else:
1919
            metadata = False
1✔
1920

1921
        if download_obj not in ('task', 'task_run', 'consensus') or \
1✔
1922
           download_format not in ('csv', 'json'):
UNCOV
1923
            flash(gettext('Invalid download type. Please try again.'), 'error')
×
UNCOV
1924
            return respond()
×
1925
        try:
1✔
1926
            if download_obj == 'task':
1✔
1927
                task = Task(project_id=project.get('id'))
1✔
1928
                ensure_authorized_to('read', task)
1✔
1929
            if download_obj == 'task_run':
1✔
UNCOV
1930
                task_run = TaskRun(project_id=project.get('id'))
×
UNCOV
1931
                ensure_authorized_to('read', task_run)
×
1932

1933
            export_queue.enqueue(export_tasks,
1✔
1934
                                 current_user.email_addr,
1935
                                 short_name,
1936
                                 ty=download_obj,
1937
                                 expanded=metadata,
1938
                                 filetype=download_format,
1939
                                 filters=args,
1940
                                 disclose_gold=can_know_task_is_gold)
1941
            flash(gettext(current_app.config.get('EXPORT_MESSAGE', 'You will be emailed when your export has been completed.')),
1✔
1942
                  'success')
UNCOV
1943
        except Exception:
×
UNCOV
1944
            current_app.logger.exception(
×
1945
                    '{0} Export Failed - Project: {1}, Type: {2}'
1946
                    .format(download_type.upper(), project.short_name, download_obj))
UNCOV
1947
            flash(gettext('There was an error while exporting your data.'),
×
1948
                  'error')
1949

1950
        return respond()
1✔
1951

1952
    if project.needs_password():
1✔
1953
        redirect_to_password = _check_if_redirect_to_password(project)
1✔
1954
        if redirect_to_password:
1✔
UNCOV
1955
            return redirect_to_password
×
1956
    else:
1957
        ensure_authorized_to('read', project)
1✔
1958

1959
    zip_enabled(project, current_user)
1✔
1960

1961
    project = add_custom_contrib_button_to(project, get_user_id_or_ip())
1✔
1962

1963
    download_type = request.args.get('download_type')
1✔
1964

1965
    if download_type:
1✔
1966
        return respond_export(download_type, args)
1✔
1967
    else:
1968
        return respond()
1✔
1969

1970

1971
@crossdomain(origin='*', headers=cors_headers)
1✔
1972
@blueprint.route('/<short_name>/tasks/priorityupdate', methods=['POST'])
1✔
1973
@login_required
1✔
1974
def bulk_priority_update(short_name):
1✔
1975
    try:
1✔
1976
        project, owner, ps = project_by_shortname(short_name)
1✔
1977
        ensure_authorized_to('read', project)
1✔
1978
        admin_or_project_owner(current_user, project)
1✔
1979
        req_data = request.json
1✔
1980
        priority_0 = req_data.get('priority_0', 0)
1✔
1981
        task_ids = req_data.get('taskIds')
1✔
1982
        if task_ids:
1✔
1983
            current_app.logger.info(task_ids)
1✔
1984
            for task_id in task_ids:
1✔
1985
                if task_id != '':
1✔
1986
                    t = task_repo.get_task_by(project_id=project.id,
1✔
1987
                                              id=int(task_id))
1988
                    if t and t.priority_0 != priority_0:
1✔
1989
                        t.priority_0 = priority_0
1✔
1990
                        task_repo.update(t)
1✔
1991
            new_value = json.dumps({
1✔
1992
                'task_ids': task_ids,
1993
                'priority_0': priority_0
1994
            })
1995
        else:
UNCOV
1996
            args = parse_tasks_browse_args(request.json.get('filters'))
×
UNCOV
1997
            task_repo.update_priority(project.id, priority_0, args)
×
1998
            new_value = json.dumps({
×
1999
                'filters': args,
2000
                'priority_0': priority_0
2001
            })
2002

2003
        auditlogger.log_event(project, current_user, 'bulk update priority',
1✔
2004
                              'task.priority_0', 'N/A', new_value)
2005
        return Response('{}', 200, mimetype='application/json')
1✔
UNCOV
2006
    except Exception as e:
×
UNCOV
2007
        return ErrorStatus().format_exception(e, 'priorityupdate', 'POST')
×
2008

2009
@crossdomain(origin='*', headers=cors_headers)
1✔
2010
@blueprint.route('/<short_name>/tasks/assign-workersupdate', methods=['POST'])
1✔
2011
@login_required
1✔
2012
def bulk_update_assign_worker(short_name):
1✔
2013
    response = {}
1✔
2014
    project, owner, ps = project_by_shortname(short_name)
1✔
2015
    admin_or_project_owner(current_user, project)
1✔
2016
    data = json.loads(request.data)
1✔
2017

2018
    if data.get("add") is None and data.get("remove") is None:
1✔
2019
        # read data and return users
2020
        task_id = data.get("taskId")
1✔
2021
        bulk_update = False
1✔
2022
        assign_user_emails = []
1✔
2023
        if task_id:
1✔
2024
            # use filters tp populate user list
2025
            t = task_repo.get_task_by(project_id=project.id,
1✔
2026
                                        id=int(task_id))
2027
            assign_user_email = []
1✔
2028
            if t.user_pref is not None and isinstance(t.user_pref, dict):
1✔
2029
                assign_user_emails = set(t.user_pref.get("assign_user", []))
1✔
2030
        else:
2031
            bulk_update = True
1✔
2032
            args = parse_tasks_browse_args(json.loads(data.get('filters', '{"taskId": null}')))
1✔
2033
            tasks = task_repo.get_tasks_by_filters(project, args)
1✔
2034
            task_ids = [t.id for t in tasks]
1✔
2035
            assign_user_emails = set()
1✔
2036

2037
            for task_id in task_ids:
1✔
2038
                t = task_repo.get_task_by(project_id=project.id,
1✔
2039
                                        id=int(task_id))
2040
                t.user_pref = t.user_pref or {}
1✔
2041
                assign_user_emails = assign_user_emails.union(set(t.user_pref.get("assign_user", [])))
1✔
2042
        assign_users = []
1✔
2043
        for user_email in assign_user_emails:
1✔
2044
            user = user_repo.search_by_email(user_email)
1✔
2045
            if not user:
1✔
2046
                fullname = user_email + ' (user not found)'
1✔
2047
            else:
2048
                fullname = user.fullname
1✔
2049
            assign_users.append({'fullname': fullname, 'email': user_email})
1✔
2050
        response['assign_users'] = assign_users
1✔
2051

2052
        # get a list of all users can be assigned to task
2053
        if bool(data_access_levels):
1✔
UNCOV
2054
            all_users = user_repo.get_users(project.get_project_users())
×
2055
        else:
2056
            all_users = user_repo.get_all()
1✔
2057
        all_user_data = []
1✔
2058
        for user in all_users:
1✔
2059
            # Exclude currently assigned users in the candidate list ONLY for single task update
2060
            if user.email_addr in assign_user_emails and not bulk_update:
1✔
2061
                continue
1✔
2062
            user_data = dict()
1✔
2063
            user_data['fullname'] = user.fullname
1✔
2064
            user_data['email'] = user.email_addr
1✔
2065
            all_user_data.append(user_data)
1✔
2066
        response["all_users"] = all_user_data
1✔
2067
    else:
2068
        # update tasks with assign worker values
2069
        assign_workers = data.get('add', [])
1✔
2070
        remove_workers = data.get('remove', [])
1✔
2071
        assign_users = []
1✔
2072

2073
        assign_worker_emails = [w["email"] for w in assign_workers]
1✔
2074
        remove_worker_emails = [w["email"] for w in remove_workers]
1✔
2075

2076
        task_id = data.get("taskId")
1✔
2077

2078
        if not task_id:
1✔
2079
            # get task_ids from db
2080
            args = parse_tasks_browse_args(json.loads(data.get('filters', '')))
1✔
2081
            tasks = task_repo.get_tasks_by_filters(project, args)
1✔
2082
            task_ids = [t.id for t in tasks]
1✔
2083
        else:
2084
            task_ids = [task_id]
1✔
2085
        for task_id in task_ids:
1✔
2086
            if task_id is not None:
1✔
2087
                t = task_repo.get_task_by(project_id=project.id,
1✔
2088
                                        id=int(task_id))
2089
                # Skip gold task for bulk assign users; allow single assign users for gold task
2090
                if bool(t.gold_answers) and len(task_ids) > 1:
1✔
2091
                    continue
1✔
2092
                # add new users
2093
                user_pref = t.user_pref or {}
1✔
2094
                assign_user = user_pref.get("assign_user", [])
1✔
2095
                assign_user.extend(assign_worker_emails)
1✔
2096
                # remove all duplicates
2097
                assign_user = list(set(assign_user))
1✔
2098

2099
                # remove users
2100
                for remove_user_email in remove_worker_emails:
1✔
2101
                    if remove_user_email in assign_user:
1✔
2102
                        assign_user.remove(remove_user_email)
1✔
2103

2104
                if assign_user:
1✔
2105
                    user_pref["assign_user"] = assign_user
1✔
2106
                    assign_users.append({'taskId': int(task_id), 'assign_user': assign_user})
1✔
2107
                elif "assign_user" in user_pref:
1✔
2108
                    del user_pref["assign_user"]
1✔
2109

2110
                t.user_pref = user_pref
1✔
2111
                flag_modified(t, "user_pref")
1✔
2112

2113
                task_repo.update(t)
1✔
2114
        response['assign_users'] = assign_users
1✔
2115

2116
    return Response(json.dumps(response), 200, mimetype='application/json')
1✔
2117

2118
@crossdomain(origin='*', headers=cors_headers)
1✔
2119
@blueprint.route('/<short_name>/tasks/redundancyupdate', methods=['POST'])
1✔
2120
@login_required
1✔
2121
def bulk_redundancy_update(short_name):
1✔
2122
    try:
1✔
2123
        project, owner, ps = project_by_shortname(short_name)
1✔
2124
        admin_or_project_owner(current_user, project)
1✔
2125
        req_data = request.json
1✔
2126
        n_answers = req_data.get('n_answers', 1)
1✔
2127
        task_ids = req_data.get('taskIds')
1✔
2128
        if task_ids:
1✔
2129
            tasks_updated = _update_task_redundancy(project.id, task_ids, n_answers)
1✔
2130
            if not tasks_updated:
1✔
2131
                flash('Redundancy not updated for tasks containing files that are either completed or older than '
1✔
2132
                      '{} days.'.format(current_app.config.get('TASK_EXPIRATION', 60)))
2133
            new_value = json.dumps({
1✔
2134
                'task_ids': task_ids,
2135
                'n_answers': n_answers
2136
            })
2137

2138
        else:
2139
            args = parse_tasks_browse_args(req_data.get('filters', ''))
1✔
2140
            tasks_not_updated = task_repo.update_tasks_redundancy(project, n_answers, args)
1✔
2141
            notify_redundancy_updates(tasks_not_updated)
1✔
2142
            if tasks_not_updated:
1✔
2143
                flash('Redundancy of some of the tasks could not be updated. An email has been sent with details')
1✔
2144

2145
            new_value = json.dumps({
1✔
2146
                'filters': args,
2147
                'n_answers': n_answers
2148
            })
2149

2150
        auditlogger.log_event(project, current_user, 'bulk update redundancy',
1✔
2151
                              'task.n_answers', 'N/A', new_value)
2152
        return Response('{}', 200, mimetype='application/json')
1✔
UNCOV
2153
    except Exception as e:
×
UNCOV
2154
        return ErrorStatus().format_exception(e, 'redundancyupdate', 'POST')
×
2155

2156

2157
def _update_task_redundancy(project_id, task_ids, n_answers):
1✔
2158
    """
2159
    Update the redundancy for a list of tasks in a given project. Mark tasks
2160
    exported as False for tasks with curr redundancy < new redundancy
2161
    and task was already exported
2162
    """
2163
    tasks_updated = False
1✔
2164
    rdancy_upd_exp = current_app.config.get('TASK_EXPIRATION', 60)
1✔
2165
    for task_id in task_ids:
1✔
2166
        if task_id:
1✔
2167
            t = task_repo.get_task_by(project_id=project_id,
1✔
2168
                                      id=int(task_id))
2169
            if t and t.n_answers == n_answers:
1✔
2170
                tasks_updated = True # no flash error message for same redundancy not updated
1✔
2171
            if t and t.n_answers != n_answers:
1✔
2172
                now = datetime.now()
1✔
2173
                created = datetime.strptime(t.created, '%Y-%m-%dT%H:%M:%S.%f')
1✔
2174
                days_task_created = (now - created).days
1✔
2175

2176
                if len(t.task_runs) < n_answers:
1✔
2177
                    if t.info and ([k for k in t.info if k.endswith('__upload_url')] and
1✔
2178
                        (t.state == 'completed' or days_task_created > rdancy_upd_exp)):
UNCOV
2179
                        continue
×
2180
                    else:
2181
                        t.exported = False
1✔
2182
                t.n_answers = n_answers
1✔
2183
                t.state = 'ongoing'
1✔
2184
                if len(t.task_runs) >= n_answers:
1✔
UNCOV
2185
                    t.state = 'completed'
×
2186
                task_repo.update(t)
1✔
2187
                tasks_updated = True
1✔
2188
    return tasks_updated
1✔
2189

2190
@crossdomain(origin='*', headers=cors_headers)
1✔
2191
@blueprint.route('/<short_name>/tasks/deleteselected', methods=['POST'])
1✔
2192
@login_required
1✔
2193
@admin_or_subadmin_required
1✔
2194
def delete_selected_tasks(short_name):
1✔
2195
    try:
1✔
2196
        # reset cache / memoized
2197
        delete_memoized(get_searchable_columns)
1✔
2198
        delete_memoized(n_available_tasks_for_user)
1✔
2199

2200
        project, owner, ps = project_by_shortname(short_name)
1✔
2201
        ensure_authorized_to('read', project)
1✔
2202
        ensure_authorized_to('update', project)
1✔
2203
        req_data = request.json
1✔
2204
        task_ids = req_data.get('taskIds')
1✔
2205
        if task_ids:
1✔
2206
            for task_id in task_ids:
1✔
2207
                task_repo.delete_task_by_id(project.id, task_id)
1✔
2208
                # delete saved tasks in the project for all users in Redis
2209
                pattern = PARTIAL_ANSWER_KEY.format(project_id=project.id,
1✔
2210
                                                    user_id='*',
2211
                                                    task_id=task_id)
2212
                delete_redis_keys(sentinel=sentinel, pattern=pattern)
1✔
2213
            new_value = json.dumps({
1✔
2214
                'task_ids': task_ids,
2215
            })
2216
            is_async = False
1✔
2217
        else:
UNCOV
2218
            args = parse_tasks_browse_args(request.json.get('filters'))
×
UNCOV
2219
            count = cached_projects.task_count(project.id, args)
×
2220
            is_async = count > MAX_NUM_SYNCHRONOUS_TASKS_DELETE
×
2221
            if is_async:
×
2222
                owners = user_repo.get_users(project.owners_ids)
×
2223
                data = {
×
2224
                    'project_id': project.id, 'project_name': project.name,
2225
                    'curr_user': current_user.email_addr, 'force_reset': True,
2226
                    'coowners': owners, 'filters': args,
2227
                    'current_user_fullname': current_user.fullname,
2228
                    'url': current_app.config.get('SERVER_URL')}
UNCOV
2229
                task_queue.enqueue(delete_bulk_tasks, data)
×
2230
            else:
2231
                task_repo.delete_valid_from_project(project, True, args)
×
2232

2233
            new_value = json.dumps({
×
2234
                'filters': args
2235
            })
2236

2237
        auditlogger.log_event(project, current_user, 'delete tasks',
1✔
2238
                              'task', 'N/A', new_value)
2239
        return Response(json.dumps(dict(enqueued=is_async)), 200,
1✔
2240
                        mimetype='application/json')
UNCOV
2241
    except Exception as e:
×
UNCOV
2242
        return ErrorStatus().format_exception(e, 'deleteselected', 'POST')
×
2243

2244

2245
@blueprint.route('/<short_name>/tasks/delete', methods=['GET', 'POST'])
1✔
2246
@login_required
1✔
2247
def delete_tasks(short_name):
1✔
2248
    """Delete ALL the tasks for a given project"""
2249
    project, owner, ps = project_by_shortname(short_name)
1✔
2250
    ensure_authorized_to('read', project)
1✔
2251
    ensure_authorized_to('update', project)
1✔
2252
    pro = pro_features()
1✔
2253
    if request.method == 'GET':
1✔
2254
        title = project_title(project, "Delete")
1✔
2255
        n_volunteers = cached_projects.n_volunteers(project.id)
1✔
2256
        n_completed_tasks = cached_projects.n_completed_tasks(project.id)
1✔
2257
        project = add_custom_contrib_button_to(project, get_user_id_or_ip())
1✔
2258
        project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
2259
                                                                    owner,
2260
                                                                    current_user,
2261
                                                                    ps)
2262
        response = dict(template='projects/tasks/delete.html',
1✔
2263
                        project=project_sanitized,
2264
                        owner=owner_sanitized,
2265
                        n_tasks=ps.n_tasks,
2266
                        n_task_runs=ps.n_task_runs,
2267
                        n_volunteers=ps.n_volunteers,
2268
                        n_completed_tasks=ps.n_completed_tasks,
2269
                        overall_progress=ps.overall_progress,
2270
                        last_activity=get_last_activity(ps),
2271
                        title=title,
2272
                        pro_features=pro,
2273
                        csrf=generate_csrf())
2274
        return handle_content_type(response)
1✔
2275
    else:
2276
        # reset cache / memoized
2277
        delete_memoized(get_searchable_columns)
1✔
2278
        delete_memoized(n_available_tasks_for_user)
1✔
2279

2280
        # delete all user saved tasks for the project in Redis
2281
        pattern = PARTIAL_ANSWER_PREFIX.format(project_id=project.id, user_id='*')
1✔
2282
        delete_redis_keys(sentinel=sentinel, pattern=pattern)
1✔
2283

2284
        force_reset = request.form.get("force_reset") == 'true'
1✔
2285
        if ps.n_tasks <= MAX_NUM_SYNCHRONOUS_TASKS_DELETE:
1✔
2286
            task_repo.delete_valid_from_project(project, force_reset=force_reset)
1✔
2287
            if not force_reset:
1✔
2288
                msg = gettext("Tasks and taskruns with no associated results have been deleted")
1✔
2289
            else:
UNCOV
2290
                msg = gettext("All tasks, taskruns and results associated with this project have been deleted")
×
2291

2292
            flash(msg, 'success')
1✔
2293
        else:
2294
            owners = user_repo.get_users(project.owners_ids)
1✔
2295
            data = {'project_id': project.id, 'project_name': project.name,
1✔
2296
                    'curr_user': current_user.email_addr, 'force_reset': force_reset,
2297
                    'coowners': owners, 'current_user_fullname': current_user.fullname,
2298
                    'url': current_app.config.get('SERVER_URL')}
2299
            task_queue.enqueue(delete_bulk_tasks, data)
1✔
2300
            flash(gettext("You're trying to delete a large amount of tasks, so please be patient.\
1✔
2301
                    You will receive an email when the tasks deletion is complete."))
2302
        return redirect_content_type(url_for('.tasks', short_name=project.short_name))
1✔
2303

2304

2305
@blueprint.route('/<short_name>/tasks/export')
1✔
2306
@login_required
1✔
2307
def export_to(short_name):
1✔
2308
    """Export Tasks and TaskRuns in the given format"""
2309
    project, owner, ps = allow_deny_project_info(short_name)
1✔
2310
    ensure_authorized_to('read', project)
1✔
2311
    supported_tables = ['task', 'task_run', 'result', 'consensus']
1✔
2312

2313
    title = project_title(project, gettext("Export"))
1✔
2314
    loading_text = gettext("Exporting data..., this may take a while")
1✔
2315
    pro = pro_features()
1✔
2316

2317
    if project.needs_password():
1✔
2318
        redirect_to_password = _check_if_redirect_to_password(project)
1✔
2319
        if redirect_to_password:
1✔
UNCOV
2320
            return redirect_to_password
×
2321

2322
    zip_enabled(project, current_user)
1✔
2323

2324
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
2325
                                                                owner,
2326
                                                                current_user,
2327
                                                                ps)
2328

2329
    disclose_gold = current_user.admin or current_user.subadmin
1✔
2330

2331
    def respond():
1✔
2332
        return render_template('/projects/export.html',
1✔
2333
                               title=title,
2334
                               loading_text=loading_text,
2335
                               ckan_name=current_app.config.get('CKAN_NAME'),
2336
                               project=project_sanitized,
2337
                               owner=owner,
2338
                               n_tasks=ps.n_tasks,
2339
                               n_task_runs=ps.n_task_runs,
2340
                               n_volunteers=ps.n_volunteers,
2341
                               n_completed_tasks=ps.n_completed_tasks,
2342
                               overall_progress=ps.overall_progress,
2343
                               pro_features=pro)
2344
    def respond_json(ty, expanded):
1✔
2345
        if ty not in supported_tables:
1✔
2346
            return abort(404)
1✔
2347

2348
        try:
1✔
2349
            export_queue.enqueue(export_tasks,
1✔
2350
                                 current_user.email_addr,
2351
                                 short_name,
2352
                                 ty,
2353
                                 expanded,
2354
                                 'json',
2355
                                 disclose_gold=disclose_gold)
2356
            flash(gettext(current_app.config.get('EXPORT_MESSAGE', 'You will be emailed when your export has been completed.')),
1✔
2357
                  'success')
UNCOV
2358
        except Exception as e:
×
UNCOV
2359
            current_app.logger.exception(
×
2360
                    'JSON Export Failed - Project: {0}, Type: {1} - Error: {2}'
2361
                    .format(project.short_name, ty, e))
UNCOV
2362
            flash(gettext('There was an error while exporting your data.'),
×
2363
                  'error')
2364

2365
        return respond()
1✔
2366

2367
    def respond_csv(ty, expanded):
1✔
2368
        if ty not in supported_tables:
1✔
UNCOV
2369
            return abort(404)
×
2370

2371
        try:
1✔
2372
            export_queue.enqueue(export_tasks,
1✔
2373
                                 current_user.email_addr,
2374
                                 short_name,
2375
                                 ty,
2376
                                 expanded,
2377
                                 'csv',
2378
                                 disclose_gold=disclose_gold)
2379
            flash(gettext(current_app.config.get('EXPORT_MESSAGE', 'You will be emailed when your export has been completed.')),
1✔
2380
                  'success')
UNCOV
2381
        except Exception as e:
×
UNCOV
2382
            current_app.logger.exception(
×
2383
                    'CSV Export Failed - Project: {0}, Type: {1} - Error: {2}'
2384
                    .format(project.short_name, ty, e))
UNCOV
2385
            flash(gettext('There was an error while exporting your data.'),
×
2386
                  'error')
2387

2388
        return respond()
1✔
2389

2390
    def create_ckan_datastore(ckan, table, package_id, records):
1✔
UNCOV
2391
        new_resource = ckan.resource_create(name=table,
×
2392
                                            package_id=package_id)
2393
        ckan.datastore_create(name=table,
×
2394
                              resource_id=new_resource['result']['id'])
2395
        ckan.datastore_upsert(name=table,
×
2396
                              records=records,
2397
                              resource_id=new_resource['result']['id'])
2398

2399
    def respond_ckan(ty, expanded):
1✔
2400
        # First check if there is a package (dataset) in CKAN
UNCOV
2401
        msg_1 = gettext("Data exported to ")
×
UNCOV
2402
        msg = msg_1 + "%s ..." % current_app.config['CKAN_URL']
×
2403
        ckan = Ckan(url=current_app.config['CKAN_URL'],
×
2404
                    api_key=current_user.ckan_api)
2405
        project_url = url_for('.details', short_name=project.short_name, _external=True)
×
2406

2407
        try:
×
UNCOV
2408
            package, e = ckan.package_exists(name=project.short_name)
×
2409
            records = task_json_exporter.gen_json(ty, project.id, expanded)
×
2410
            if e:
×
2411
                raise e
×
2412
            if package:
×
2413
                # Update the package
2414
                owner = user_repo.get(project.owner_id)
×
UNCOV
2415
                package = ckan.package_update(project=project, user=owner,
×
2416
                                              url=project_url,
2417
                                              resources=package['resources'])
2418

UNCOV
2419
                ckan.package = package
×
UNCOV
2420
                resource_found = False
×
2421
                for r in package['resources']:
×
2422
                    if r['name'] == ty:
×
2423
                        ckan.datastore_delete(name=ty, resource_id=r['id'])
×
2424
                        ckan.datastore_create(name=ty, resource_id=r['id'])
×
2425
                        ckan.datastore_upsert(name=ty,
×
2426
                                              records=records,
2427
                                              resource_id=r['id'])
UNCOV
2428
                        resource_found = True
×
UNCOV
2429
                        break
×
2430
                if not resource_found:
×
2431
                    create_ckan_datastore(ckan, ty, package['id'], records)
×
2432
            else:
2433
                owner = user_repo.get(project.owner_id)
×
UNCOV
2434
                package = ckan.package_create(project=project, user=owner,
×
2435
                                              url=project_url)
2436
                create_ckan_datastore(ckan, ty, package['id'], records)
×
UNCOV
2437
            flash(msg, 'success')
×
2438
            return respond()
×
2439
        except requests.exceptions.ConnectionError:
×
2440
            msg = "CKAN server seems to be down, try again layer or contact the CKAN admins"
×
2441
            current_app.logger.error(msg)
×
2442
            flash(msg, 'danger')
×
2443
        except Exception as inst:
×
2444
            # print inst
2445
            if len(inst.args) == 3:
×
UNCOV
2446
                t, msg, status_code = inst.args
×
2447
                msg = ("Error: %s with status code: %s" % (t, status_code))
×
2448
            else:  # pragma: no cover
2449
                msg = ("Error: %s" % inst.args[0])
UNCOV
2450
            current_app.logger.error(msg)
×
UNCOV
2451
            flash(msg, 'danger')
×
2452
        finally:
2453
            return respond()
×
2454

2455
    export_formats = ["json", "csv"]
1✔
2456
    if current_user.is_authenticated:
1✔
2457
        if current_user.ckan_api:
1✔
UNCOV
2458
            export_formats.append('ckan')
×
2459

2460
    ty = request.args.get('type')
1✔
2461
    fmt = request.args.get('format')
1✔
2462
    expanded = False
1✔
2463
    if request.args.get('expanded') == 'True':
1✔
UNCOV
2464
        expanded = True
×
2465

2466
    if not (fmt and ty):
1✔
2467
        if len(request.args) >= 1:
1✔
2468
            abort(404)
1✔
2469
        project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
2470
        return respond()
1✔
2471

2472
    if fmt not in export_formats:
1✔
2473
        abort(415)
1✔
2474

2475
    if ty == 'task':
1✔
2476
        task = task_repo.get_task_by(project_id=project.id)
1✔
2477
        if task:
1✔
2478
            ensure_authorized_to('read', task)
1✔
2479
    if ty == 'task_run':
1✔
2480
        task_run = task_repo.get_task_run_by(project_id=project.id)
1✔
2481
        if task_run:
1✔
2482
            ensure_authorized_to('read', task_run)
1✔
2483

2484
    return {"json": respond_json,
1✔
2485
            "csv": respond_csv,
2486
            'ckan': respond_ckan}[fmt](ty, expanded)
2487

2488

2489
@blueprint.route('/export')
1✔
2490
@login_required
1✔
2491
@admin_required
1✔
2492
def export_projects():
1✔
2493
    """Export projects list, only for admins."""
2494
    import datetime
1✔
2495
    info = dict(timestamp=datetime.datetime.now().isoformat(),
1✔
2496
                user_id=current_user.id,
2497
                base_url=request.url_root+'project/')
2498
    export_queue.enqueue(mail_project_report, info, current_user.email_addr)
1✔
2499
    flash(gettext('You will be emailed when your export has been'
1✔
2500
                  ' completed.'), 'success')
2501
    return redirect_content_type(url_for('admin.index'))
1✔
2502

2503

2504
@blueprint.route('/<short_name>/stats')
1✔
2505
@login_required
1✔
2506
def show_stats(short_name):
1✔
2507
    """Returns Project Stats"""
2508
    project, owner, ps = project_by_shortname(short_name)
1✔
2509
    ensure_authorized_to('read', project)
1✔
2510
    n_completed_tasks = cached_projects.n_completed_tasks(project.id)
1✔
2511
    n_pending_tasks = ps.n_tasks-n_completed_tasks
1✔
2512
    title = project_title(project, "Statistics")
1✔
2513
    pro = pro_features(owner)
1✔
2514

2515
    if project.needs_password():
1✔
2516
        redirect_to_password = _check_if_redirect_to_password(project)
1✔
2517
        if redirect_to_password:
1✔
UNCOV
2518
            return redirect_to_password
×
2519

2520
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
2521
                                                                owner,
2522
                                                                current_user,
2523
                                                                ps)
2524
    if not ((ps.n_tasks > 0) and (ps.n_task_runs > 0)):
1✔
2525
        project = add_custom_contrib_button_to(project, get_user_id_or_ip(),
1✔
2526
                                           ps=ps)
2527
        response = dict(template='/projects/non_stats.html',
1✔
2528
                        title=title,
2529
                        project=project_sanitized,
2530
                        owner=owner_sanitized,
2531
                        n_tasks=ps.n_tasks,
2532
                        overall_progress=ps.overall_progress,
2533
                        n_volunteers=ps.n_volunteers,
2534
                        n_completed_tasks=ps.n_completed_tasks,
2535
                        pro_features=pro,
2536
                        private_instance=bool(current_app.config.get('PRIVATE_INSTANCE')))
2537
        return handle_content_type(response)
1✔
2538

2539
    dates_stats = ps.info['dates_stats']
1✔
2540
    hours_stats = ps.info['hours_stats']
1✔
2541
    users_stats = ps.info['users_stats']
1✔
2542

2543
    total_contribs = (users_stats['n_anon'] + users_stats['n_auth'])
1✔
2544
    if total_contribs > 0:
1✔
2545
        anon_pct_taskruns = int((users_stats['n_anon'] * 100) / total_contribs)
1✔
2546
        auth_pct_taskruns = 100 - anon_pct_taskruns
1✔
2547
    else:
UNCOV
2548
        anon_pct_taskruns = 0
×
UNCOV
2549
        auth_pct_taskruns = 0
×
2550

2551
    userStats = dict(
1✔
2552
        anonymous=dict(
2553
            users=users_stats['n_anon'],
2554
            taskruns=users_stats['n_anon'],
2555
            pct_taskruns=anon_pct_taskruns,
2556
            top5=users_stats['anon']['top5']),
2557
        authenticated=dict(
2558
            users=users_stats['n_auth'],
2559
            taskruns=users_stats['n_auth'],
2560
            pct_taskruns=auth_pct_taskruns,
2561
            all_users=users_stats['auth']['all_users']))
2562

2563
    projectStats = dict(
1✔
2564
        userStats=users_stats['users'],
2565
        userAnonStats=users_stats['anon'],
2566
        userAuthStats=users_stats['auth'],
2567
        dayStats=dates_stats,
2568
        n_completed_tasks=n_completed_tasks,
2569
        n_pending_tasks=n_pending_tasks,
2570
        hourStats=hours_stats)
2571

2572
    project_dict = add_custom_contrib_button_to(project, get_user_id_or_ip(),
1✔
2573
                                                ps=ps)
2574
    formatted_contrib_time = round(ps.average_time/60, 2)
1✔
2575

2576
    project_sanitized, owner_sanitized = sanitize_project_owner(project, owner,
1✔
2577
                                                                current_user,
2578
                                                                ps)
2579

2580
    # Handle JSON project stats depending of output
2581
    # (needs to be escaped for HTML)
2582
    if request.headers.get('Content-Type') == 'application/json':
1✔
2583
        handle_projectStats = projectStats
1✔
2584
    else:   # HTML
2585
        handle_projectStats = json.dumps(projectStats)
1✔
2586

2587
    response = dict(template='/projects/stats.html',
1✔
2588
                    title=title,
2589
                    projectStats=handle_projectStats,
2590
                    userStats=userStats,
2591
                    project=project_sanitized,
2592
                    owner=owner_sanitized,
2593
                    n_tasks=ps.n_tasks,
2594
                    overall_progress=ps.overall_progress,
2595
                    n_volunteers=ps.n_volunteers,
2596
                    n_completed_tasks=ps.n_completed_tasks,
2597
                    avg_contrib_time=formatted_contrib_time,
2598
                    pro_features=pro,
2599
                    private_instance=bool(current_app.config.get('PRIVATE_INSTANCE')))
2600

2601
    return handle_content_type(response)
1✔
2602

2603

2604
@blueprint.route('/<short_name>/tasks/settings')
1✔
2605
@login_required
1✔
2606
def task_settings(short_name):
1✔
2607
    """Settings page for tasks of the project"""
2608
    project, owner, ps = project_by_shortname(short_name)
1✔
2609

2610
    ensure_authorized_to('read', project, forbidden_code_override=423)
1✔
2611
    ensure_authorized_to('update', project)
1✔
2612
    pro = pro_features()
1✔
2613
    project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
2614
    return render_template('projects/task_settings.html',
1✔
2615
                           project=project,
2616
                           owner=owner,
2617
                           n_tasks=ps.n_tasks,
2618
                           overall_progress=ps.overall_progress,
2619
                           n_volunteers=ps.n_volunteers,
2620
                           n_completed_tasks=ps.n_completed_tasks,
2621
                           pro_features=pro)
2622

2623

2624
@blueprint.route('/<short_name>/tasks/redundancy', methods=['GET', 'POST'])
1✔
2625
@login_required
1✔
2626
def task_n_answers(short_name):
1✔
2627
    project, owner, ps = project_by_shortname(short_name)
1✔
2628
    title = project_title(project, gettext('Redundancy'))
1✔
2629

2630
    form = TaskRedundancyForm(request.body)
1✔
2631
    default_form = TaskDefaultRedundancyForm(request.body)
1✔
2632
    ensure_authorized_to('read', project)
1✔
2633
    ensure_authorized_to('update', project)
1✔
2634
    pro = pro_features()
1✔
2635
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
2636
                                                                owner,
2637
                                                                current_user,
2638
                                                                ps)
2639
    if request.method == 'GET':
1✔
2640
        response = dict(template='/projects/task_n_answers.html',
1✔
2641
                        title=title,
2642
                        form=form,
2643
                        default_task_redundancy=project.get_default_n_answers(),
2644
                        default_form=default_form,
2645
                        project=project_sanitized,
2646
                        owner=owner_sanitized,
2647
                        pro_features=pro)
2648
        return handle_content_type(response)
1✔
2649
    elif request.method == 'POST':
1✔
2650
        exist_error = False
1✔
2651
        if default_form.default_n_answers.data is not None:
1✔
UNCOV
2652
            if default_form.validate():
×
UNCOV
2653
                project.set_default_n_answers(default_form.default_n_answers.data)
×
2654
                auditlogger.log_event(project, current_user, 'update', 'project.default_n_answers',
×
2655
                        'N/A', default_form.default_n_answers.data)
2656
                msg = gettext('Redundancy updated!')
×
UNCOV
2657
                flash(msg, 'success')
×
2658
            else:
2659
                exist_error = True
×
2660
        if form.n_answers.data is not None:
1✔
2661
            if form.validate():
1✔
2662
                tasks_not_updated = task_repo.update_tasks_redundancy(project, form.n_answers.data)
1✔
2663
                if tasks_not_updated:
1✔
UNCOV
2664
                    notify_redundancy_updates(tasks_not_updated)
×
UNCOV
2665
                    flash('Redundancy of some of the tasks could not be updated. An email has been sent with details')
×
2666
                else:
2667
                    msg = gettext('Redundancy updated!')
1✔
2668
                    flash(msg, 'success')
1✔
2669
                # Log it
2670
                auditlogger.log_event(project, current_user, 'update', 'task.n_answers',
1✔
2671
                                    'N/A', form.n_answers.data)
2672
            else:
2673
                exist_error = True
1✔
2674
        if not exist_error:
1✔
2675
            return redirect_content_type(url_for('.tasks', short_name=project.short_name))
1✔
2676

2677
        flash(gettext('Please correct the errors'), 'error')
1✔
2678
        if not form.n_answers.data:
1✔
2679
            form = TaskRedundancyForm()
1✔
2680
        if not default_form.default_n_answers.data:
1✔
2681
            default_form = TaskDefaultRedundancyForm()
1✔
2682
        response = dict(template='/projects/task_n_answers.html',
1✔
2683
                        title=title,
2684
                        form=form,
2685
                        default_task_redundancy=project.get_default_n_answers(),
2686
                        default_form=default_form,
2687
                        project=project_sanitized,
2688
                        owner=owner_sanitized,
2689
                        pro_features=pro)
2690
        return handle_content_type(response)
1✔
2691

2692

2693
@blueprint.route('/<short_name>/tasks/scheduler', methods=['GET', 'POST'])
1✔
2694
@login_required
1✔
2695
def task_scheduler(short_name):
1✔
2696
    project, owner, ps = project_by_shortname(short_name)
1✔
2697

2698
    title = project_title(project, gettext('Task Scheduler'))
1✔
2699
    form = TaskSchedulerForm(request.body)
1✔
2700
    # order configured tasklist_columns including upref cols first
2701
    task_columns = get_searchable_columns(project.id)
1✔
2702
    tasklist_columns = project.info.get("tasklist_columns", [])
1✔
2703
    configurable_columns = []
1✔
2704
    for col in tasklist_columns:
1✔
2705
        if col in task_columns:
1✔
2706
            configurable_columns.append((col, col))
1✔
2707
        else:
2708
            configurable_columns += [(k, v) for k, v in USER_PREF_COLUMNS if col == k]
1✔
2709
    # add columns that are not configured so that user has option to add them later
2710
    configurable_columns += [(col, col) for col in task_columns if col not in tasklist_columns]
1✔
2711
    configurable_columns += [(k, v) for k, v in USER_PREF_COLUMNS if k not in tasklist_columns]
1✔
2712
    form.set_customized_columns_options(configurable_columns)
1✔
2713

2714
    reserve_category_columns = project.info.get("reserve_tasks", {}).get("category", [])
1✔
2715
    reserve_category_options = [(col, col) for col in reserve_category_columns]
1✔
2716
    reserve_category_options += [(col, col) for col in task_columns if col not in reserve_category_columns]
1✔
2717
    form.set_reserve_category_cols_options(reserve_category_options)
1✔
2718
    pro = pro_features()
1✔
2719

2720
    def respond():
1✔
2721
        project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
2722
                                                                    owner,
2723
                                                                    current_user,
2724
                                                                    ps)
2725
        response = dict(template='/projects/task_scheduler.html',
1✔
2726
                        title=title,
2727
                        form=form,
2728
                        sched_variants=current_app.config.get('AVAILABLE_SCHEDULERS'),
2729
                        project=project_sanitized,
2730
                        owner=owner_sanitized,
2731
                        pro_features=pro,
2732
                        randomizable_scheds=sched.randomizable_scheds())
2733
        return handle_content_type(response)
1✔
2734

2735
    ensure_authorized_to('read', project)
1✔
2736
    ensure_authorized_to('update', project)
1✔
2737

2738
    if request.method == 'GET':
1✔
2739
        if project.info.get('sched'):
1✔
2740
            for sched_name, _ in form.sched.choices:
1✔
2741
                if project.info['sched'] == sched_name:
1✔
2742
                    form.sched.data = sched_name
1✔
2743
                    break
1✔
2744
        form.reserve_category_columns.data = reserve_category_columns
1✔
2745
        form.customized_columns.data = project.info.get('tasklist_columns', [])
1✔
2746
        form.rand_within_priority.data = project.info.get('sched_rand_within_priority', False)
1✔
2747
        form.gold_task_probability.data = project.get_gold_task_probability()
1✔
2748
        return respond()
1✔
2749

2750
    if request.method == 'POST' and form.validate():
1✔
2751
        project = project_repo.get_by_shortname(short_name=project.short_name)
1✔
2752
        if project.info.get('sched'):
1✔
2753
            old_sched = project.info['sched']
1✔
2754
        else:
2755
            old_sched = 'default'
1✔
2756
        if form.sched.data:
1✔
2757
            project.info['sched'] = form.sched.data
1✔
2758

2759
        reserve_category_columns = form.reserve_category_columns.data if form.sched.data in ["task_queue_scheduler"] else []
1✔
2760
        project.info["reserve_tasks"] = dict(category=reserve_category_columns)
1✔
2761
        project.info['tasklist_columns'] = form.customized_columns.data
1✔
2762
        project.info['sched_rand_within_priority'] = form.rand_within_priority.data
1✔
2763
        project.set_gold_task_probability(form.gold_task_probability.data)
1✔
2764
        project_repo.save(project)
1✔
2765
        # Log it
2766
        if old_sched != project.info['sched']:
1✔
2767
            auditlogger.log_event(project, current_user, 'update', 'sched',
1✔
2768
                                  old_sched, project.info['sched'])
2769
        msg = gettext("Project Task Scheduler updated!")
1✔
2770
        flash(msg, 'success')
1✔
2771

2772
        return redirect_content_type(url_for('.tasks', short_name=project.short_name))
1✔
2773
    else:  # pragma: no cover
2774
        flash(gettext('Please correct the errors'), 'error')
2775
        return respond()
2776

2777

2778
@blueprint.route('/<short_name>/tasks/priority', methods=['GET', 'POST'])
1✔
2779
@login_required
1✔
2780
def task_priority(short_name):
1✔
2781
    project, owner, ps = project_by_shortname(short_name)
1✔
2782

2783
    title = project_title(project, gettext('Task Priority'))
1✔
2784
    form = TaskPriorityForm(request.body)
1✔
2785
    pro = pro_features()
1✔
2786

2787
    def respond():
1✔
2788
        project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
2789
                                                                    owner,
2790
                                                                    current_user,
2791
                                                                    ps)
2792
        response = dict(template='/projects/task_priority.html',
1✔
2793
                        title=title,
2794
                        form=form,
2795
                        project=project_sanitized,
2796
                        owner=owner_sanitized,
2797
                        pro_features=pro)
2798
        return handle_content_type(response)
1✔
2799
    ensure_authorized_to('read', project)
1✔
2800
    ensure_authorized_to('update', project)
1✔
2801

2802
    if request.method == 'GET':
1✔
2803
        return respond()
1✔
2804
    if request.method == 'POST' and form.validate():
1✔
2805
        for task_id in form.task_ids.data.split(","):
1✔
2806
            if task_id != '':
1✔
2807
                t = task_repo.get_task_by(project_id=project.id, id=int(task_id))
1✔
2808
                if t:
1✔
2809
                    old_priority = t.priority_0
1✔
2810
                    t.priority_0 = form.priority_0.data
1✔
2811
                    task_repo.update(t)
1✔
2812

2813
                    if old_priority != t.priority_0:
1✔
2814
                        old_value = json.dumps({'task_id': t.id,
1✔
2815
                                                'task_priority_0': old_priority})
2816
                        new_value = json.dumps({'task_id': t.id,
1✔
2817
                                                'task_priority_0': t.priority_0})
2818
                        auditlogger.log_event(project, current_user, 'update',
1✔
2819
                                              'task.priority_0',
2820
                                              old_value, new_value)
2821
                else:  # pragma: no cover
2822
                    flash(gettext(("Ooops, Task.id=%s does not belong to the project" % task_id)), 'danger')
2823
        flash(gettext("Task priority has been changed"), 'success')
1✔
2824
        return respond()
1✔
2825
    else:
2826
        flash(gettext('Please correct the errors'), 'error')
1✔
2827
        return respond()
1✔
2828

2829

2830
@blueprint.route('/<short_name>/tasks/timeout', methods=['GET', 'POST'])
1✔
2831
@login_required
1✔
2832
def task_timeout(short_name):
1✔
2833
    project, owner, ps = project_by_shortname(short_name)
1✔
2834
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
2835
                                                                    owner,
2836
                                                                    current_user,
2837
                                                                    ps)
2838
    title = project_title(project, gettext('Timeout'))
1✔
2839
    form = TaskTimeoutForm(request.body) if request.data else TaskTimeoutForm()
1✔
2840

2841
    ensure_authorized_to('read', project, forbidden_code_override=423)
1✔
2842
    ensure_authorized_to('update', project)
1✔
2843
    pro = pro_features()
1✔
2844
    if request.method == 'GET':
1✔
2845
        timeout = project.info.get('timeout', DEFAULT_TASK_TIMEOUT)
1✔
2846
        form.minutes.data, form.seconds.data = divmod(timeout, 60)
1✔
2847
        return handle_content_type(dict(template='/projects/task_timeout.html',
1✔
2848
                               title=title,
2849
                               form=form,
2850
                               project=project_sanitized,
2851
                               owner=owner_sanitized,
2852
                               pro_features=pro))
2853
    if form.validate() and form.in_range():
1✔
2854
        project = project_repo.get_by_shortname(short_name=project.short_name)
1✔
2855
        if project.info.get('timeout'):
1✔
2856
            old_timeout = project.info['timeout']
1✔
2857
        else:
2858
            old_timeout = DEFAULT_TASK_TIMEOUT
1✔
2859
        project.info['timeout'] = form.minutes.data*60 + form.seconds.data or 0
1✔
2860
        project_repo.save(project)
1✔
2861
        # Log it
2862
        if old_timeout != project.info.get('timeout'):
1✔
2863
            auditlogger.log_event(project, current_user, 'update', 'timeout',
1✔
2864
                                  old_timeout, project.info['timeout'])
2865
        msg = gettext("Project Task Timeout updated!")
1✔
2866
        flash(msg, 'success')
1✔
2867

2868
        return redirect_content_type(url_for('.tasks', short_name=project.short_name))
1✔
2869
    else:
2870
        if not form.in_range():
1✔
2871
            flash(gettext('Timeout should be between {} seconds and {} minutes')
1✔
2872
                          .format(form.min_seconds, form.max_minutes), 'error')
2873
        else:
2874
            flash(gettext('Please correct the errors'), 'error')
1✔
2875
        return handle_content_type(dict(template='/projects/task_timeout.html',
1✔
2876
                               title=title,
2877
                               form=form,
2878
                               project=project_sanitized,
2879
                               owner=owner_sanitized,
2880
                               pro_features=pro))
2881

2882

2883

2884
@blueprint.route('/<short_name>/tasks/task_notification', methods=['GET', 'POST'])
1✔
2885
@login_required
1✔
2886
def task_notification(short_name):
1✔
2887
    project, owner, ps = project_by_shortname(short_name)
1✔
2888
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
2889
                                                                    owner,
2890
                                                                    current_user,
2891
                                                                    ps)
2892
    title = project_title(project, gettext('Task Notification'))
1✔
2893
    form = TaskNotificationForm(request.body) if request.data else TaskNotificationForm()
1✔
2894

2895
    ensure_authorized_to('read', project)
1✔
2896
    ensure_authorized_to('update', project)
1✔
2897
    pro = pro_features()
1✔
2898
    if request.method == 'GET':
1✔
2899
        reminder_info = project.info.get('progress_reminder', {})
1✔
2900
        form.webhook.data = reminder_info.get('webhook')
1✔
2901
        form.remaining.data = reminder_info.get('target_remaining')
1✔
2902
        return handle_content_type(dict(template='/projects/task_notification.html',
1✔
2903
                               title=title,
2904
                               form=form,
2905
                               project=project_sanitized,
2906
                               pro_features=pro))
2907

2908
    remaining = form.remaining.data
1✔
2909
    n_tasks = cached_projects.n_tasks(project.id)
1✔
2910
    if remaining is not None and (remaining < 0 or remaining > n_tasks):
1✔
2911
        flash(gettext('Target number should be between 0 and {}'.format(n_tasks)), 'error')
1✔
2912
        return handle_content_type(dict(template='/projects/task_notification.html',
1✔
2913
                                title=title,
2914
                                form=form,
2915
                                project=project_sanitized,
2916
                                pro_features=pro))
2917
    webhook = form.webhook.data
1✔
2918
    if webhook:
1✔
2919
        scheme, netloc, _, _, _, _ = urllib.parse.urlparse(webhook)
1✔
2920
        if scheme not in ['http', 'https', 'ftp'] or not '.' in netloc:
1✔
2921
            flash(gettext('Invalid webhook URL'), 'error')
1✔
2922
            return handle_content_type(dict(template='/projects/task_notification.html',
1✔
2923
                                    title=title,
2924
                                    form=form,
2925
                                    project=project_sanitized,
2926
                                    pro_features=pro))
2927

2928
    project = project_repo.get_by_shortname(short_name=project.short_name)
1✔
2929

2930
    reminder_info = project.info.get('progress_reminder') or {}
1✔
2931
    reminder_info['target_remaining'] = remaining
1✔
2932
    reminder_info['webhook'] = webhook
1✔
2933
    reminder_info['sent'] = False
1✔
2934

2935
    auditlogger.log_event(project, current_user, 'update', 'task_notification',
1✔
2936
                        project.info.get('progress_reminder'), reminder_info)
2937
    project.info['progress_reminder'] = reminder_info
1✔
2938
    project_repo.save(project)
1✔
2939

2940
    flash(gettext("Task notifications updated."), 'success')
1✔
2941
    return redirect_content_type(url_for('.tasks', short_name=project.short_name))
1✔
2942

2943

2944
@blueprint.route('/<short_name>/blog')
1✔
2945
def show_blogposts(short_name):
1✔
2946
    project, owner, ps = project_by_shortname(short_name)
1✔
2947
    ensure_authorized_to('read', project)
1✔
2948

2949
    if current_user.is_authenticated and current_user.id == owner.id:
1✔
2950
        blogposts = blog_repo.filter_by(project_id=project.id)
1✔
2951
    else:
2952
        blogposts = blog_repo.filter_by(project_id=project.id,
1✔
2953
                                        published=True)
2954
    if project.needs_password():
1✔
2955
        redirect_to_password = _check_if_redirect_to_password(project)
1✔
2956
        if redirect_to_password:
1✔
UNCOV
2957
            return redirect_to_password
×
2958
    else:
2959
        ensure_authorized_to('read', Blogpost, project_id=project.id)
×
2960
    pro = pro_features()
1✔
2961
    project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
2962

2963
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
2964
                                                                owner,
2965
                                                                current_user,
2966
                                                                ps)
2967

2968
    response = dict(template='projects/blog.html',
1✔
2969
                    project=project_sanitized,
2970
                    owner=owner_sanitized,
2971
                    blogposts=blogposts,
2972
                    overall_progress=ps.overall_progress,
2973
                    n_tasks=ps.n_tasks,
2974
                    n_task_runs=ps.n_task_runs,
2975
                    n_completed_tasks=ps.n_completed_tasks,
2976
                    n_volunteers=ps.n_volunteers,
2977
                    pro_features=pro)
2978
    return handle_content_type(response)
1✔
2979

2980

2981
@blueprint.route('/<short_name>/<int:id>')
1✔
2982
def show_blogpost(short_name, id):
1✔
2983
    project, owner, ps = project_by_shortname(short_name)
1✔
2984
    ensure_authorized_to('read', project)
1✔
2985

2986
    blogpost = blog_repo.get_by(id=id, project_id=project.id)
1✔
2987
    if blogpost is None:
1✔
2988
        raise abort(404)
1✔
2989
    if current_user.is_anonymous and blogpost.published is False:
1✔
UNCOV
2990
        raise abort(404)
×
2991
    if (blogpost.published is False and
1✔
2992
            current_user.is_authenticated and
2993
            current_user.id != blogpost.user_id):
2994
        raise abort(404)
1✔
2995
    if project.needs_password():
1✔
2996
        redirect_to_password = _check_if_redirect_to_password(project)
1✔
2997
        if redirect_to_password:
1✔
2998
            return redirect_to_password
1✔
2999
    else:
UNCOV
3000
        ensure_authorized_to('read', blogpost)
×
3001
    pro = pro_features()
1✔
3002
    project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
3003
    return render_template('projects/blog_post.html',
1✔
3004
                           project=project,
3005
                           owner=owner,
3006
                           blogpost=blogpost,
3007
                           overall_progress=ps.overall_progress,
3008
                           n_tasks=ps.n_tasks,
3009
                           n_task_runs=ps.n_task_runs,
3010
                           n_completed_tasks=ps.n_completed_tasks,
3011
                           n_volunteers=ps.n_volunteers,
3012
                           pro_features=pro)
3013

3014

3015
@blueprint.route('/<short_name>/new-blogpost', methods=['GET', 'POST'])
1✔
3016
@login_required
1✔
3017
def new_blogpost(short_name):
1✔
3018
    pro = pro_features()
1✔
3019

3020
    def respond():
1✔
3021
        dict_project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
3022
        response = dict(template='projects/new_blogpost.html',
1✔
3023
                        title=gettext("Write a new post"),
3024
                        form=form,
3025
                        project=project_sanitized,
3026
                        owner=owner_sanitized,
3027
                        overall_progress=ps.overall_progress,
3028
                        n_tasks=ps.n_tasks,
3029
                        n_task_runs=ps.n_task_runs,
3030
                        n_completed_tasks=cached_projects.n_completed_tasks(dict_project.get('id')),
3031
                        n_volunteers=cached_projects.n_volunteers(dict_project.get('id')),
3032
                        pro_features=pro)
3033
        return handle_content_type(response)
1✔
3034

3035
    project, owner, ps = project_by_shortname(short_name)
1✔
3036
    ensure_authorized_to('read', project)
1✔
3037

3038

3039
    form = BlogpostForm(request.form)
1✔
3040
    del form.id
1✔
3041

3042
    project_sanitized, owner_sanitized = sanitize_project_owner(project, owner,
1✔
3043
                                                                current_user,
3044
                                                                ps)
3045

3046
    if request.method != 'POST':
1✔
3047
        ensure_authorized_to('create', Blogpost, project_id=project.id)
1✔
3048
        return respond()
1✔
3049

3050
    if not form.validate():
1✔
UNCOV
3051
        flash(gettext('Please correct the errors'), 'error')
×
UNCOV
3052
        return respond()
×
3053

3054
    blogpost = Blogpost(title=form.title.data,
1✔
3055
                        body=form.body.data,
3056
                        user_id=current_user.id,
3057
                        project_id=project.id)
3058
    ensure_authorized_to('create', blogpost)
1✔
3059
    blog_repo.save(blogpost)
1✔
3060

3061
    msg_1 = gettext('Blog post created!')
1✔
3062
    flash(Markup('<i class="icon-ok"></i> {}').format(msg_1), 'success')
1✔
3063

3064
    return redirect(url_for('.show_blogposts', short_name=short_name))
1✔
3065

3066

3067
@blueprint.route('/<short_name>/<int:id>/update', methods=['GET', 'POST'])
1✔
3068
@login_required
1✔
3069
def update_blogpost(short_name, id):
1✔
3070

3071
    project, owner, ps = project_by_shortname(short_name)
1✔
3072

3073
    pro = pro_features()
1✔
3074
    blogpost = blog_repo.get_by(id=id, project_id=project.id)
1✔
3075
    if blogpost is None:
1✔
3076
        raise abort(404)
1✔
3077

3078
    def respond():
1✔
3079
        project_sanitized, owner_sanitized = sanitize_project_owner(project, owner,
1✔
3080
                                                                current_user,
3081
                                                                ps)
3082
        return render_template('projects/new_blogpost.html',
1✔
3083
                               title=gettext("Edit a post"),
3084
                               form=form, project=project_sanitized, owner=owner,
3085
                               blogpost=blogpost,
3086
                               overall_progress=ps.overall_progress,
3087
                               n_task_runs=ps.n_task_runs,
3088
                               n_completed_tasks=cached_projects.n_completed_tasks(project.id),
3089
                               n_volunteers=cached_projects.n_volunteers(project.id),
3090
                               pro_features=pro)
3091

3092
    form = BlogpostForm()
1✔
3093

3094
    if request.method != 'POST':
1✔
3095
        ensure_authorized_to('update', blogpost)
1✔
3096
        form = BlogpostForm(obj=blogpost)
1✔
3097
        return respond()
1✔
3098

3099
    if not form.validate():
1✔
UNCOV
3100
        flash(gettext('Please correct the errors'), 'error')
×
UNCOV
3101
        return respond()
×
3102

3103
    ensure_authorized_to('update', blogpost)
1✔
3104
    blogpost = Blogpost(id=form.id.data,
1✔
3105
                        title=form.title.data,
3106
                        body=form.body.data,
3107
                        user_id=current_user.id,
3108
                        project_id=project.id,
3109
                        published=form.published.data)
3110
    blog_repo.update(blogpost)
1✔
3111

3112
    msg_1 = gettext('Blog post updated!')
1✔
3113
    flash(Markup('<i class="icon-ok"></i> {}').format(msg_1), 'success')
1✔
3114

3115
    return redirect(url_for('.show_blogposts', short_name=short_name))
1✔
3116

3117

3118
@blueprint.route('/<short_name>/<int:id>/delete', methods=['POST'])
1✔
3119
@login_required
1✔
3120
@admin_required
1✔
3121
def delete_blogpost(short_name, id):
1✔
3122
    project = project_by_shortname(short_name)[0]
1✔
3123
    blogpost = blog_repo.get_by(id=id, project_id=project.id)
1✔
3124
    if blogpost is None:
1✔
3125
        raise abort(404)
1✔
3126

3127
    ensure_authorized_to('delete', blogpost)
1✔
3128
    blog_repo.delete(blogpost)
1✔
3129
    msg_1 = gettext('Blog post deleted!')
1✔
3130
    flash(Markup('<i class="icon-ok"></i> {}').format(msg_1), 'success')
1✔
3131
    return redirect(url_for('.show_blogposts', short_name=short_name))
1✔
3132

3133

3134
def _check_if_redirect_to_password(project):
1✔
3135
    cookie_exp = current_app.config.get('PASSWD_COOKIE_TIMEOUT')
1✔
3136
    passwd_mngr = ProjectPasswdManager(CookieHandler(request, signer, cookie_exp))
1✔
3137
    if passwd_mngr.password_needed(project, get_user_id_or_ip()):
1✔
3138
        return redirect_content_type(url_for('.password_required',
1✔
3139
                                short_name=project.short_name, next=request.path))
3140

3141

3142
@blueprint.route('/<short_name>/auditlog')
1✔
3143
@login_required
1✔
3144
def auditlog(short_name):
1✔
UNCOV
3145
    pro = pro_features()
×
UNCOV
3146
    project, owner, ps = project_by_shortname(short_name)
×
3147

3148
    ensure_authorized_to('read', Auditlog, project_id=project.id)
×
UNCOV
3149
    logs = auditlogger.get_project_logs(project.id)
×
3150
    project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
×
3151
    return render_template('projects/auditlog.html', project=project,
×
3152
                           owner=owner, logs=logs,
3153
                           overall_progress=ps.overall_progress,
3154
                           n_tasks=ps.n_tasks,
3155
                           n_task_runs=ps.n_task_runs,
3156
                           n_completed_tasks=ps.n_completed_tasks,
3157
                           n_volunteers=ps.n_volunteers,
3158
                           pro_features=pro)
3159

3160

3161
@blueprint.route('/<short_name>/publish', methods=['GET', 'POST'])
1✔
3162
@login_required
1✔
3163
def publish(short_name):
1✔
3164

3165
    project, owner, ps = project_by_shortname(short_name)
1✔
3166
    project_sanitized, owner_sanitized = sanitize_project_owner(project, owner,
1✔
3167
                                                                current_user,
3168
                                                                ps)
3169

3170
    pro = pro_features()
1✔
3171
    ensure_authorized_to('publish', project)
1✔
3172
    if request.method == 'GET':
1✔
3173
        sync_form = ProjectSyncForm()
1✔
3174
        template_args = {"project": project_sanitized,
1✔
3175
                         "pro_features": pro,
3176
                         "csrf": generate_csrf(),
3177
                         "sync_form": sync_form,
3178
                         "target_url": current_app.config.get('DEFAULT_SYNC_TARGET'),
3179
                         "server_url": current_app.config.get('SERVER_URL'),}
3180
        response = dict(template = '/projects/publish.html', **template_args)
1✔
3181
        return handle_content_type(response)
1✔
3182

3183
    project.published = not project.published
1✔
3184
    project_repo.save(project)
1✔
3185
    cached_users.delete_published_projects(current_user.id)
1✔
3186
    cached_projects.reset()
1✔
3187

3188
    if not project.published:
1✔
3189
        auditlogger.log_event(project, current_user, 'update', 'published', True, False)
1✔
3190
        flash(gettext('Project unpublished! Volunteers cannot contribute to the project now.'))
1✔
3191
        return redirect(url_for('.publish', short_name=project.short_name))
1✔
3192

3193
    force_reset = request.form.get("force_reset") == 'on'
1✔
3194
    if force_reset:
1✔
3195
        task_repo.delete_taskruns_from_project(project)
1✔
3196
        result_repo.delete_results_from_project(project)
1✔
3197
        webhook_repo.delete_entries_from_project(project)
1✔
3198
        cached_projects.delete_n_task_runs(project.id)
1✔
3199
        cached_projects.delete_n_results(project.id)
1✔
3200

3201
    auditlogger.log_event(project, current_user, 'update', 'published', False, True)
1✔
3202
    flash(gettext('Project published! Volunteers will now be able to help you!'))
1✔
3203
    return redirect(url_for('.publish', short_name=project.short_name))
1✔
3204

3205

3206
def project_event_stream(short_name, channel_type):
1✔
3207
    """Event stream for pub/sub notifications."""
3208
    pubsub = sentinel.master.pubsub()
1✔
3209
    channel = "channel_%s_%s" % (channel_type, short_name)
1✔
3210
    pubsub.subscribe(channel)
1✔
3211
    for message in pubsub.listen():
1✔
3212
        yield 'data: %s\n\n' % message['data']
1✔
3213

3214

3215
@blueprint.route('/<short_name>/privatestream')
1✔
3216
@login_required
1✔
3217
def project_stream_uri_private(short_name):
1✔
3218
    """Returns stream."""
3219
    if current_app.config.get('SSE'):
1✔
3220
        project, owner, ps = project_by_shortname(short_name)
1✔
3221

3222
        if current_user.id in project.owners_ids or current_user.admin:
1✔
3223
            return Response(project_event_stream(short_name, 'private'),
1✔
3224
                            mimetype="text/event-stream",
3225
                            direct_passthrough=True)
3226
        else:
3227
            return abort(403)
1✔
3228
    else:
3229
        return abort(404)
1✔
3230

3231

3232
@blueprint.route('/<short_name>/publicstream')
1✔
3233
def project_stream_uri_public(short_name):
1✔
3234
    """Returns stream."""
3235
    if current_app.config.get('SSE'):
1✔
3236
        project, owner, ps = project_by_shortname(short_name)
1✔
3237
        return Response(project_event_stream(short_name, 'public'),
1✔
3238
                        mimetype="text/event-stream")
3239
    else:
3240
        abort(404)
1✔
3241

3242

3243
@blueprint.route('/<short_name>/webhook', defaults={'oid': None})
1✔
3244
@blueprint.route('/<short_name>/webhook/<int:oid>', methods=['GET', 'POST'])
1✔
3245
@login_required
1✔
3246
def webhook_handler(short_name, oid=None):
1✔
3247
    project, owner, ps = project_by_shortname(short_name)
1✔
3248

3249
    pro = pro_features()
1✔
3250
    if not pro['webhooks_enabled']:
1✔
3251
        raise abort(403)
1✔
3252

3253
    responses = webhook_repo.filter_by(project_id=project.id)
1✔
3254
    if request.method == 'POST' and oid:
1✔
3255
        tmp = webhook_repo.get(oid)
1✔
3256
        if tmp:
1✔
3257
            webhook_queue.enqueue(webhook, project.webhook,
1✔
3258
                                  tmp.payload, tmp.id, True)
3259
            return json.dumps(tmp.dictize())
1✔
3260
        else:
3261
            abort(404)
1✔
3262

3263
    ensure_authorized_to('read', Webhook, project_id=project.id)
1✔
3264
    redirect_to_password = _check_if_redirect_to_password(project)
1✔
3265
    if redirect_to_password:
1✔
UNCOV
3266
        return redirect_to_password
×
3267

3268
    if request.method == 'GET' and request.args.get('all'):
1✔
3269
        for wh in responses:
1✔
3270
            webhook_queue.enqueue(webhook, project.webhook,
1✔
3271
                                  wh.payload, wh.id, True)
3272
        flash('All webhooks enqueued')
1✔
3273

3274
    if request.method == 'GET' and request.args.get('failed'):
1✔
3275
        for wh in responses:
1✔
3276
            if wh.response_status_code != 200:
1✔
3277
                webhook_queue.enqueue(webhook, project.webhook,
1✔
3278
                                      wh.payload, wh.id, True)
3279
        flash('All webhooks enqueued')
1✔
3280

3281
    project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
3282

3283
    return render_template('projects/webhook.html', project=project,
1✔
3284
                           owner=owner, responses=responses,
3285
                           overall_progress=ps.overall_progress,
3286
                           n_tasks=ps.n_tasks,
3287
                           n_task_runs=ps.n_task_runs,
3288
                           n_completed_tasks=ps.n_completed_tasks,
3289
                           n_volunteers=ps.n_volunteers,
3290
                           pro_features=pro)
3291

3292

3293
@blueprint.route('/<short_name>/resetsecretkey', methods=['POST'])
1✔
3294
@login_required
1✔
3295
def reset_secret_key(short_name):
1✔
3296
    """
3297
    Reset Project key.
3298

3299
    """
3300

3301
    project, owner, ps = project_by_shortname(short_name)
1✔
3302

3303

3304
    title = project_title(project, "Results")
1✔
3305

3306
    ensure_authorized_to('update', project)
1✔
3307

3308
    project.secret_key = make_uuid()
1✔
3309
    project_repo.update(project)
1✔
3310
    msg = gettext('New secret key generated')
1✔
3311
    flash(msg, 'success')
1✔
3312
    return redirect_content_type(url_for('.update', short_name=short_name))
1✔
3313

3314

3315
@blueprint.route('/<short_name>/transferownership', methods=['GET', 'POST'])
1✔
3316
@login_required
1✔
3317
def transfer_ownership(short_name):
1✔
3318
    """Transfer project ownership."""
3319

3320
    project, owner, ps = project_by_shortname(short_name)
1✔
3321

3322
    pro = pro_features()
1✔
3323

3324
    title = project_title(project, "Results")
1✔
3325

3326
    ensure_authorized_to('update', project)
1✔
3327

3328
    form = TransferOwnershipForm(request.body)
1✔
3329

3330
    if request.method == 'POST' and form.validate():
1✔
3331
        new_owner = user_repo.filter_by(email_addr=form.email_addr.data)
1✔
3332
        if len(new_owner) == 1:
1✔
3333
            old_owner_id = project.owner_id
1✔
3334
            new_owner_id = new_owner[0].id
1✔
3335

3336
            # Assign new project owner id.
3337
            project.owner_id = new_owner_id
1✔
3338

3339
            # Remove old project owner id from coowners.
3340
            if old_owner_id in project.owners_ids:
1✔
3341
                project.owners_ids.remove(old_owner_id)
1✔
3342

3343
            # Add new project owner id to coowners.
3344
            if new_owner_id not in project.owners_ids:
1✔
3345
                project.owners_ids.append(new_owner_id)
1✔
3346

3347
            # Update project.
3348
            project_repo.update(project)
1✔
3349

3350
            msg = gettext("Project owner updated")
1✔
3351
            flash(msg, 'info')
1✔
3352
            return redirect_content_type(url_for('.details',
1✔
3353
                                                 short_name=short_name))
3354
        else:
3355
            msg = gettext("New project owner not found by email")
1✔
3356
            flash(msg, 'info')
1✔
3357
            return redirect_content_type(url_for('.transfer_ownership',
1✔
3358
                                                 short_name=short_name))
3359
    else:
3360
        owner_serialized = cached_users.get_user_summary(owner.name)
1✔
3361
        project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
3362
        response = dict(template='/projects/transferownership.html',
1✔
3363
                        project=project,
3364
                        owner=owner_serialized,
3365
                        n_tasks=ps.n_tasks,
3366
                        overall_progress=ps.overall_progress,
3367
                        n_task_runs=ps.n_task_runs,
3368
                        last_activity=ps.last_activity,
3369
                        n_completed_tasks=ps.n_completed_tasks,
3370
                        n_volunteers=ps.n_volunteers,
3371
                        title=title,
3372
                        pro_features=pro,
3373
                        form=form,
3374
                        target='.transfer_ownership')
3375
        return handle_content_type(response)
1✔
3376

3377

3378
def is_user_enabled_assigned_project(user, project):
1✔
3379
    # Return true if the user is enabled and assigned to this project or is a sub-admin/admin.
3380
    return user.enabled and (user.id == project.owner_id or user.admin or user.subadmin)
1✔
3381

3382
@blueprint.route('/<short_name>/coowners', methods=['GET', 'POST'])
1✔
3383
@login_required
1✔
3384
def coowners(short_name):
1✔
3385
    """Manage coowners of a project."""
3386
    form = SearchForm(request.body)
1✔
3387
    project, owner, ps = project_by_shortname(short_name)
1✔
3388
    sanitize_project, owner_sanitized = sanitize_project_owner(project, owner, current_user, ps)
1✔
3389
    owners = user_repo.get_users(project.owners_ids)
1✔
3390
    coowners = [{"id": user.id, "fullname": user.fullname} for user in owners]
1✔
3391
    pub_owners = [user.to_public_json() for user in owners]
1✔
3392
    for owner, p_owner in zip(owners, pub_owners):
1✔
3393
        if owner.id == project.owner_id:
1✔
3394
            p_owner['is_creator'] = True
1✔
3395
    contact_owners = [user for user in owners if is_user_enabled_assigned_project(user, project)]
1✔
3396
    contact_users = user_repo.get_users(project.info.get('contacts')) if project.info.get('contacts') else contact_owners
1✔
3397
    contacts_dict = [{"id": user.id, "fullname": user.fullname} for user in contact_users]
1✔
3398

3399
    ensure_authorized_to('read', project)
1✔
3400
    ensure_authorized_to('update', project)
1✔
3401

3402
    response = dict(
1✔
3403
        template='/projects/coowners.html',
3404
        project=sanitize_project,
3405
        coowners=pub_owners,
3406
        coowners_dict=coowners,
3407
        contacts_dict=contacts_dict,
3408
        owner=owner_sanitized,
3409
        title=gettext("Manage Co-owners"),
3410
        form=form,
3411
        found=[],
3412
        pro_features=pro_features(),
3413
        csrf=generate_csrf()
3414
    )
3415

3416
    if request.method == 'POST':
1✔
3417
        if form.user.data:
1✔
3418
            # search users
3419
            query = form.user.data
1✔
3420
            params = json.loads(request.data) if request.data else {}
1✔
3421

3422
            filters = {'enabled': True}
1✔
3423

3424
            # Search all users.
3425
            users = user_repo.search_by_name(query, **filters)
1✔
3426

3427
            # If searching contacts, filter results to only coowners.
3428
            users = [user for user in users if user.id in project.owners_ids] if params.get('contact') else users
1✔
3429

3430
            if not users:
1✔
3431
                markup = Markup('<strong>{}</strong> {} <strong>{}</strong>')
1✔
3432
                flash(markup.format(gettext("Ooops!"),
1✔
3433
                                    gettext("We didn't find any enabled user matching your query:"),
3434
                                    form.user.data))
3435
            else:
3436
                found = []
1✔
3437
                for user in users:
1✔
3438
                    public_user = user.to_public_json()
1✔
3439
                    public_user['is_coowner'] = user.id in project.owners_ids
1✔
3440
                    public_user['is_creator'] = user.id == project.owner_id
1✔
3441
                    public_user['id'] = user.id
1✔
3442
                    found.append(public_user)
1✔
3443
                response['found'] = found
1✔
3444
        else:
3445
            # save coowners
3446
            old_list = project.owners_ids or []
1✔
3447
            new_list = [int(x) for x in json.loads(request.data).get('coowners') or []]
1✔
3448
            overlap_list = [value for value in old_list if value in new_list]
1✔
3449
            auditlogger.log_event(project, current_user, 'update', 'project.coowners',
1✔
3450
                old_list, new_list)
3451
            # delete ids that don't exist anymore
3452
            delete = [value for value in old_list if value not in overlap_list]
1✔
3453
            for _id in delete:
1✔
3454
                # Remove the coowner.
3455
                project.owners_ids.remove(_id)
1✔
3456
            # add ids that weren't there
3457
            add = [value for value in new_list if value not in overlap_list]
1✔
3458
            for _id in add:
1✔
3459
                project.owners_ids.append(_id)
1✔
3460

3461
            # Update coowners_dict in response.
3462
            coowners_dict = [{"id": user.id, "fullname": user.fullname} for user in user_repo.get_users(project.owners_ids)]
1✔
3463
            response['coowners_dict'] = coowners_dict
1✔
3464

3465
            # save contacts
3466
            new_list = [int(x) for x in json.loads(request.data).get('contacts', [])]
1✔
3467

3468
            # remove any contacts that were just removed from coowners.
3469
            for _id in delete:
1✔
3470
                if _id in new_list:
1✔
3471
                    new_list.remove(_id)
1✔
3472

3473
            auditlogger.log_event(project, current_user, 'update', 'project.contacts',
1✔
3474
                project.info.get('contacts'), new_list)
3475
            project.info['contacts'] = new_list
1✔
3476

3477
            project_repo.save(project)
1✔
3478

3479
            # Update contacts_dict in response.
3480
            contacts_dict = [{"id": user.id, "fullname": user.fullname} for user in user_repo.get_users(project.info['contacts'])]
1✔
3481
            response['contacts_dict'] = contacts_dict
1✔
3482

3483
            flash(gettext('Configuration updated successfully'), 'success')
1✔
3484

3485
    return handle_content_type(response)
1✔
3486

3487

3488
@blueprint.route('/<short_name>/add_coowner/<user_name>')
1✔
3489
@login_required
1✔
3490
def add_coowner(short_name, user_name=None):
1✔
3491
    """Add project co-owner."""
3492
    project = project_repo.get_by_shortname(short_name)
1✔
3493
    user = user_repo.get_by_name(user_name)
1✔
3494

3495
    ensure_authorized_to('read', project)
1✔
3496
    ensure_authorized_to('update', project)
1✔
3497

3498
    if project and user:
1✔
3499
        if user.id in project.owners_ids:
1✔
3500
            flash(gettext('User is already an owner'), 'warning')
1✔
3501
        else:
3502
            old_list = project.owners_ids.copy()
1✔
3503
            project.owners_ids.append(user.id)
1✔
3504
            project_repo.update(project)
1✔
3505
            auditlogger.log_event(project, current_user, 'update',
1✔
3506
                'project.coowners', old_list, project.owners_ids)
3507
            flash(gettext('User was added to list of owners'), 'success')
1✔
3508
        return redirect_content_type(url_for(".coowners", short_name=short_name))
1✔
3509
    return abort(404)
1✔
3510

3511

3512
@blueprint.route('/<short_name>/del_coowner/<user_name>')
1✔
3513
@login_required
1✔
3514
def del_coowner(short_name, user_name=None):
1✔
3515
    """Delete project co-owner."""
3516
    project = project_repo.get_by_shortname(short_name)
1✔
3517
    user = user_repo.get_by_name(user_name)
1✔
3518

3519
    ensure_authorized_to('read', project)
1✔
3520
    ensure_authorized_to('update', project)
1✔
3521

3522
    if project and user:
1✔
3523
        if user.id == project.owner_id:
1✔
3524
            flash(gettext('Cannot remove project creator'), 'error')
1✔
3525
        elif user.id not in project.owners_ids:
1✔
3526
            flash(gettext('User is not a project owner'), 'error')
1✔
3527
        else:
3528
            old_list = project.owners_ids.copy()
1✔
3529
            project.owners_ids.remove(user.id)
1✔
3530
            project_repo.update(project)
1✔
3531
            auditlogger.log_event(project, current_user, 'update',
1✔
3532
                'project.coowners', old_list, project.owners_ids)
3533
            flash(gettext('User was deleted from the list of owners'),
1✔
3534
                  'success')
3535
        return redirect_content_type(url_for('.coowners', short_name=short_name))
1✔
3536
    return abort(404)
1✔
3537

3538

3539
@blueprint.route('/<short_name>/projectreport/export', methods=['GET', 'POST'])
1✔
3540
@login_required
1✔
3541
@admin_or_subadmin_required
1✔
3542
def export_project_report(short_name):
1✔
3543
    """Export project report for a given project short name."""
3544

3545
    project, owner, ps = project_by_shortname(short_name)
1✔
3546
    project_sanitized, owner_sanitized = sanitize_project_owner(
1✔
3547
        project, owner, current_user, ps)
3548
    ensure_authorized_to('read', project)
1✔
3549

3550
    def respond():
1✔
3551
        response = dict(
1✔
3552
            template='/projects/project_report.html',
3553
            project=project_sanitized,
3554
            form=form,
3555
            csrf=generate_csrf()
3556
        )
3557
        return handle_content_type(response)
1✔
3558

3559
    form = ProjectReportForm(request.body)
1✔
3560
    if request.method == 'GET':
1✔
3561
        return respond()
1✔
3562

3563
    if not form.validate():
1✔
UNCOV
3564
        flash("Please correct the error", 'message')
×
UNCOV
3565
        return respond()
×
3566

3567
    start_date = form.start_date.data
1✔
3568
    end_date = form.end_date.data
1✔
3569
    kwargs = {}
1✔
3570
    if start_date:
1✔
UNCOV
3571
        kwargs["start_date"] = start_date.strftime("%Y-%m-%dT00:00:00")
×
3572
    if end_date:
1✔
3573
        kwargs["end_date"] = end_date.strftime("%Y-%m-%dT23:59:59")
×
3574

3575
    try:
1✔
3576
        project_report_csv_exporter = ProjectReportCsvExporter()
1✔
3577
        res = project_report_csv_exporter.response_zip(project, "project", **kwargs)
1✔
3578
        return res
1✔
UNCOV
3579
    except Exception:
×
UNCOV
3580
        current_app.logger.exception("Project report export failed")
×
3581
        flash(gettext('Error generating project report.'), 'error')
×
3582
    return abort(500)
×
3583

3584

3585
@blueprint.route('/<short_name>/syncproject', methods=['POST'])
1✔
3586
@login_required
1✔
3587
@admin_or_subadmin_required
1✔
3588
def sync_project(short_name):
1✔
3589
    """Sync project."""
3590
    project, owner, ps = project_by_shortname(short_name)
1✔
3591
    title = project_title(project, "Sync")
1✔
3592

3593
    source_url = current_app.config.get('SERVER_URL')
1✔
3594
    sync_form = ProjectSyncForm()
1✔
3595
    target_key = sync_form.target_key.data
1✔
3596

3597
    success_msg = Markup(
1✔
3598
        '{} <strong><a href="{}" target="_blank">{}</a></strong>')
3599
    success_body = (
1✔
3600
        'A project that you an owner/co-owner of has been'
3601
        ' {action}ed with a project on another server.\n\n'
3602
        '    Project Short Name: {short_name}\n'
3603
        '    Source URL: {source_url}\n'
3604
        '    User who performed sync: {syncer}')
3605
    default_sync_target = current_app.config.get('DEFAULT_SYNC_TARGET')
1✔
3606

3607
    try:
1✔
3608
        # Validate the ability to sync
3609
        able_to_sync = source_url != default_sync_target
1✔
3610
        auth_to_sync = (current_user.admin or
1✔
3611
                (current_user.subadmin and
3612
                    current_user.id in project.owners_ids))
3613
        if not able_to_sync:
1✔
UNCOV
3614
            msg = Markup('Cannot sync a project with itself')
×
3615
        if able_to_sync and not auth_to_sync:
1✔
3616
            msg = Markup('Only admins and subadmin/co-owners '
×
3617
                         'can sync projects')
3618
        if not able_to_sync or not auth_to_sync:
1✔
UNCOV
3619
            flash(msg, 'error')
×
UNCOV
3620
            return redirect_content_type(
×
3621
                url_for('.publish', short_name=short_name))
3622

3623
        # Perform sync
3624
        is_new_project = False
1✔
3625
        project_syncer = ProjectSyncer(
1✔
3626
            default_sync_target, target_key, current_app.config.get('DEFAULT_SYNC_TARGET_PROXIES'))
3627
        synced_url = '{}/project/{}'.format(
1✔
3628
            project_syncer.target_url, project.short_name)
3629
        if request.body.get('btn') == 'sync':
1✔
3630
            action = 'sync'
1✔
3631
            is_new_project, res = project_syncer.sync(project)
1✔
3632
        elif request.body.get('btn') == 'undo':
1✔
3633
            action = 'unsync'
1✔
3634
            res = project_syncer.undo_sync(project)
1✔
3635

3636
        # Nothing to revert
3637
        if not res and action == 'unsync':
1✔
3638
            msg = gettext('There is nothing to revert.')
1✔
3639
            flash(msg, 'warning')
1✔
3640
        # Success
3641
        elif res.ok:
1✔
3642
            if action == 'sync':
1✔
3643
                sync_msg = gettext('Project sync completed! ')
1✔
3644
                subject = 'Your project has been synced'
1✔
3645
            elif action == 'unsync':
1✔
3646
                sync_msg = gettext('Last sync has been reverted! ')
1✔
3647
                subject = 'Your synced project has been reverted'
1✔
3648

3649
            msg = success_msg.format(
1✔
3650
                sync_msg, synced_url, 'Synced Project Link')
3651
            flash(msg, 'success')
1✔
3652

3653
            body = success_body.format(
1✔
3654
                action=action,
3655
                short_name=project.short_name,
3656
                source_url=source_url,
3657
                syncer=current_user.email_addr)
3658
            owners = project_syncer.get_target_owners(project, is_new_project)
1✔
3659
            email = dict(recipients=owners,
1✔
3660
                         subject=subject,
3661
                         body=body)
3662
            mail_queue.enqueue(send_mail, email)
1✔
UNCOV
3663
        elif res.status_code == 415:
×
UNCOV
3664
            current_app.logger.error(
×
3665
                'A request error occurred while syncing {}: {}'
3666
                .format(project.short_name, str(res.__dict__)))
UNCOV
3667
            msg = gettext(
×
3668
                'This project already exists on the production server, '
3669
                'but you are not an owner.')
UNCOV
3670
            flash(msg, 'error')
×
3671
        else:
3672
            current_app.logger.info('response code: {}'.format(str(res.status_code)))
×
UNCOV
3673
            current_app.logger.error(
×
3674
                'A request error occurred while syncing {}: {}'
3675
                .format(project.short_name, str(res.__dict__)))
UNCOV
3676
            msg = gettext(
×
3677
                'Error: Ensure your production API key is used, your production account is sub-admin and enabled, and the target project is enabled for project syncing.')
3678
            flash(msg, 'error')
×
3679
    except SyncUnauthorized as err:
1✔
3680
        current_app.logger.info(
1✔
3681
            'Exception SyncUnauthorized: An error occurred while syncing {}'
3682
            .format(project.short_name))
3683
        if err.sync_type == 'ProjectSyncer':
1✔
3684
            msg = gettext('Project sync failed. Ensure your production account is sub-admin.')
1✔
3685
            flash(msg, 'error')
1✔
3686
        elif err.sync_type == 'CategorySyncer':
1✔
3687
            msg = gettext('You are not authorized to create a new '
1✔
3688
                          'category. Please change the category to '
3689
                          'one that already exists on the production server '
3690
                          'or contact an admin.')
3691
            flash(msg, 'error')
1✔
3692
    except NotEnabled:
1✔
3693
        current_app.logger.info(
1✔
3694
            'Exception NotEnabled: An error occurred while syncing {}'
3695
            .format(project.short_name))
3696
        msg = 'The current project is not enabled for syncing. '
1✔
3697
        enable_msg = Markup('{} <strong><a href="{}/update" '
1✔
3698
                            'target="_blank">{}</a></strong>')
3699
        flash(enable_msg.format(msg, synced_url, 'Enable Here'),
1✔
3700
              'error')
3701
    except Exception as exception_type:
1✔
3702
        current_app.logger.exception(
1✔
3703
            'Final Exception: An error occurred while syncing {}'
3704
            .format(project.short_name))
3705
        current_app.logger.info(
1✔
3706
            'Final Exception: An error occurred while syncing. exception type is {}'
3707
            .format(str(exception_type)))
3708
        msg = gettext('An unexpected error occurred while trying to sync your project.')
1✔
3709
        flash(msg, 'error')
1✔
3710
    return redirect_content_type(
1✔
3711
        url_for('.publish', short_name=short_name))
3712

3713
def remove_restricted_keys(forms):
1✔
3714
    for restricted_key in list(ProjectAPI.restricted_keys):
1✔
3715
        restricted_key = restricted_key.split("::")[-1]
1✔
3716
        if not current_user.admin:
1✔
UNCOV
3717
            forms.pop(restricted_key, None)
×
3718

3719
@blueprint.route('/<short_name>/project-config', methods=['GET', 'POST'])
1✔
3720
@login_required
1✔
3721
@admin_or_subadmin_required
1✔
3722
def project_config(short_name):
1✔
3723
    ''' external config and data access config'''
3724
    project, owner, ps = project_by_shortname(short_name)
1✔
3725
    ensure_authorized_to('read', project)
1✔
3726
    ensure_authorized_to('update', project)
1✔
3727
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
3728
                                                            owner,
3729
                                                            current_user,
3730
                                                            ps)
3731
    forms = copy.deepcopy(current_app.config.get('EXTERNAL_CONFIGURATIONS_VUE', {}))
1✔
3732
    remove_restricted_keys(forms)
1✔
3733
    error_msg = None
1✔
3734

3735
    def generate_input_forms_and_external_config_dict():
1✔
3736
        '''
3737
        external configuration form is a tree-like distionary structure
3738
        extract input fields from form
3739
        '''
3740
        '''
3741
        generate a flat key-value dict of ext_config
3742
        '''
3743
        input_forms = []
1✔
3744
        ext_config_field_name = []
1✔
3745
        for _, content in six.iteritems(forms):
1✔
3746
            input_forms.append(content)
1✔
3747
            for field in content.get('fields', []):
1✔
3748
                ext_config_field_name.append(field['name'])
1✔
3749
        ext_config_dict = flatten_ext_config()
1✔
3750
        return input_forms, ext_config_dict
1✔
3751

3752
    def flatten_ext_config():
1✔
3753
        '''
3754
        update dict with values from project.info.ext_config
3755
        '''
3756
        config = {}
1✔
3757
        for _, fields in six.iteritems(ext_config):
1✔
3758
            for key, value in six.iteritems(fields):
1✔
3759
                config[key] = value
1✔
3760
        return {k: v for k, v in six.iteritems(config) if v}
1✔
3761

3762
    def integrate_ext_config(config_dict):
1✔
3763
        '''
3764
        take flat key-value pair and integrate to new ext_config objects
3765
        '''
3766
        new_config = {}
1✔
3767
        for fieldname, content in six.iteritems(forms):
1✔
3768
            cf = {}
1✔
3769
            for field in content.get('fields') or {}:
1✔
3770
                name = field['name']
1✔
3771
                if config_dict.get(name) and config_dict[name]:
1✔
3772
                    cf[name] = config_dict[name]
1✔
3773
            if cf:
1✔
3774
                new_config[fieldname] = cf
1✔
3775
        return new_config
1✔
3776

3777
    if request.method == 'POST':
1✔
3778
        try:
1✔
3779
            data = json.loads(request.data)
1✔
3780
            if (data.get('config')):
1✔
3781
                project.info['ext_config'] = integrate_ext_config(data.get('config'))
1✔
3782
            if bool(data_access_levels):
1✔
3783
                # for private gigwork
UNCOV
3784
                project.info['data_access'] = data.get('data_access')
×
3785
            project.info['completed_tasks_cleanup_days'] = data.get('completed_tasks_cleanup_days')
1✔
3786
            project.info["allow_taskrun_edit"] = data.get("allow_taskrun_edit")
1✔
3787
            project.info["reset_presented_time"] = data.get("reset_presented_time")
1✔
3788
            project_repo.save(project)
1✔
3789
            flash(gettext('Configuration updated successfully'), 'success')
1✔
3790
        except Exception as e:
1✔
3791
            error_msg = str(e)
1✔
3792
            current_app.logger.error('project-config post error. project id %d, data %s, error %s ',
1✔
3793
                    project.id, request.data, str(e))
3794
            flash(gettext('An error occurred.'), 'error')
1✔
3795

3796

3797
    ext_config = project.info.get('ext_config', {})
1✔
3798
    input_forms, ext_config_dict = generate_input_forms_and_external_config_dict()
1✔
3799
    data_access = project.info.get('data_access') or []
1✔
3800
    completed_tasks_cleanup_days = project.info.get('completed_tasks_cleanup_days')
1✔
3801
    allow_taskrun_edit = project.info.get("allow_taskrun_edit") or False
1✔
3802
    reset_presented_time = project.info.get("reset_presented_time") or False
1✔
3803
    response = dict(template='/projects/summary.html',
1✔
3804
                    external_config_dict=json.dumps(ext_config_dict),
3805
                    authorized_services=ext_config.get('authorized_services', {}).get(current_app.config.get('AUTHORIZED_SERVICES_KEY', ''), []),
3806
                    is_admin=current_user.admin,
3807
                    forms=input_forms,
3808
                    data_access=json.dumps(data_access),
3809
                    valid_access_levels=data_access_levels.get('valid_access_levels'),
3810
                    csrf=generate_csrf(),
3811
                    completed_tasks_cleanup_days=completed_tasks_cleanup_days,
3812
                    allow_taskrun_edit=allow_taskrun_edit,
3813
                    reset_presented_time=reset_presented_time,
3814
                    error_msg=error_msg
3815
                    )
3816

3817
    return handle_content_type(response)
1✔
3818

3819
@blueprint.route('/<short_name>/ext-config', methods=['GET', 'POST'])
1✔
3820
@login_required
1✔
3821
@admin_or_subadmin_required
1✔
3822
def ext_config(short_name):
1✔
3823
    from pybossa.forms.dynamic_forms import form_builder
1✔
3824

3825
    project, owner, ps = project_by_shortname(short_name)
1✔
3826
    sanitize_project, _ = sanitize_project_owner(project, owner, current_user, ps)
1✔
3827

3828
    ext_conf = project.info.get('ext_config', {})
1✔
3829

3830
    ensure_authorized_to('read', project)
1✔
3831
    ensure_authorized_to('update', project)
1✔
3832

3833
    forms = current_app.config.get('EXTERNAL_CONFIGURATIONS', {})
1✔
3834

3835
    form_classes = []
1✔
3836
    for form_name, form_config in forms.items():
1✔
3837
        display = form_config['display']
1✔
3838
        form = form_builder(form_name, iter(form_config['fields'].items()))
1✔
3839
        form_classes.append((form_name, display, form))
1✔
3840

3841
    if request.method == 'POST':
1✔
3842
        for form_name, display, form_class in form_classes:
1✔
3843
            if form_name in request.body:
1✔
3844
                form = form_class()
1✔
3845
                if not form.validate():
1✔
UNCOV
3846
                    flash(gettext('Please correct the errors', 'error'))
×
3847
                ext_conf[form_name] = {k: v for k, v in six.iteritems(form.data) if v}
1✔
3848
                ext_conf[form_name].pop('csrf_token', None)     #Fflask-wtf v0.14.2 issue 102
1✔
3849

3850
                target_bucket = ext_conf.get('gigwork_poller', {}).get('target_bucket')
1✔
3851
                if not target_bucket:
1✔
3852
                    ext_conf.pop('gigwork_poller', None)
1✔
3853

3854
                hdfs_path = ext_conf.get('hdfs', {}).get('path')
1✔
3855
                if not hdfs_path:
1✔
3856
                    ext_conf.pop('hdfs', None)
1✔
3857

3858
                if not ext_conf and current_app.config.get('PRIVATE_INSTANCE'):
1✔
UNCOV
3859
                    flash(gettext('At least one response file & consensus location must be provided. {} was not updated').format(display), 'error')
×
3860
                else:
3861
                    project.info['ext_config'] = ext_conf
1✔
3862
                    try:
1✔
3863
                        project_repo.save(project)
1✔
3864
                        current_app.logger.info('Project id {} external configurations set. {} {}'.format(
1✔
3865
                                project.id, form_name, form.data))
3866
                        flash(gettext('Configuration for {} was updated').format(display), 'success')
1✔
3867
                    except Exception as e:
1✔
3868
                        current_app.logger.error('ext-config post error. project id %d, data %s, error %s ',
1✔
3869
                                project.id, request.data, str(e))
3870
                        flash(gettext(f'An error occurred. {e}'), 'error')
1✔
3871

3872
    sanitize_project, _ = sanitize_project_owner(project, owner, current_user, ps)
1✔
3873
    template_forms = [(name, disp, cl(MultiDict(ext_conf.get(name, {}))))
1✔
3874
                        for name, disp, cl in form_classes]
3875

3876
    response = dict(
1✔
3877
        template='/projects/external_config.html',
3878
        project=sanitize_project,
3879
        title=gettext("Configure external services"),
3880
        forms=template_forms,
3881
        authorized_services=ext_conf.get('authorized_services', {}).get(current_app.config.get('AUTHORIZED_SERVICES_KEY', ''), []),
3882
        pro_features=pro_features()
3883
    )
3884

3885
    return handle_content_type(response)
1✔
3886

3887

3888
def notify_redundancy_updates(tasks_not_updated):
1✔
3889
    if tasks_not_updated:
1✔
3890
        body = ('Redundancy could not be updated for tasks containing files that are '
1✔
3891
            'either completed or older than {} days.\nTask Ids\n{}')
3892
        body = body.format(task_repo.rdancy_upd_exp, tasks_not_updated)
1✔
3893
        email = dict(subject='Tasks redundancy update status',
1✔
3894
                   recipients=[current_user.email_addr],
3895
                   body=body)
3896
        mail_queue.enqueue(send_mail, email)
1✔
3897

3898

3899
@blueprint.route('/<short_name>/assign-users', methods=['GET', 'POST'])
1✔
3900
@login_required
1✔
3901
@admin_or_subadmin_required
1✔
3902
def assign_users(short_name):
1✔
3903
    """Assign users to project based on projects data access levels."""
3904
    project, owner, ps = project_by_shortname(short_name)
1✔
3905
    ensure_authorized_to('read', project)
1✔
3906
    ensure_authorized_to('update', project)
1✔
3907
    access_levels = project.info.get('data_access', None)
1✔
3908

3909
    users = cached_users.get_users_for_data_access(access_levels)
1✔
3910
    if not users:
1✔
3911
        current_app.logger.info(
1✔
3912
            'Project id {} no user matching data access level {} for this project.'.format(project.id, access_levels))
3913
        flash('Cannot assign users. There is no user matching data access level for this project', 'warning')
1✔
3914
        return redirect_content_type(url_for('.settings', short_name=project.short_name))
1✔
3915

3916
    # Update users with last_name for sorting.
3917
    for user in users:
1✔
3918
        user['last_name'] = get_last_name(user.get('fullname'))
1✔
3919

3920
    form = DataAccessForm(request.body)
1✔
3921
    project_users = json.loads(request.data).get("select_users", []) if request.data else request.form.getlist('select_users')
1✔
3922

3923
    if request.method == 'GET':
1✔
3924
        project_sanitized, owner_sanitized = sanitize_project_owner(
1✔
3925
            project, owner, current_user, ps)
3926
        project_users = project.get_project_users()
1✔
3927
        project_users = list(map(str, project_users))
1✔
3928

3929
        response = dict(
1✔
3930
            template='/projects/assign_users.html',
3931
            project=project_sanitized,
3932
            title=gettext("Assign Users to Project"),
3933
            project_users=project_users,
3934
            form=form,
3935
            all_users=users,
3936
            pro_features=pro_features()
3937
        )
3938
        return handle_content_type(response)
1✔
3939

3940
    project_users = list(map(int, project_users))
1✔
3941
    project.set_project_users(project_users)
1✔
3942
    project_repo.save(project)
1✔
3943
    auditlogger.log_event(project, current_user, 'update', 'project.assign_users',
1✔
3944
              'N/A', project_users)
3945
    if not project_users:
1✔
3946
        msg = gettext('Users unassigned or no user assigned to project')
1✔
3947
        current_app.logger.info('Project id {} users unassigned from project.'.format(project.id))
1✔
3948
    else:
UNCOV
3949
        msg = gettext('Users assigned to project')
×
UNCOV
3950
        current_app.logger.info('Project id {} users assigned to project. users {}'.format(project.id, project_users))
×
3951

3952
    flash(msg, 'success')
1✔
3953
    return redirect_content_type(url_for('.settings', short_name=project.short_name))
1✔
3954

3955
def process_quiz_mode_request(project):
1✔
3956

3957
    current_quiz_config = project.get_quiz()
1✔
3958

3959
    if request.method == 'GET':
1✔
3960
        return ProjectQuizForm(**current_quiz_config)
1✔
3961
    try:
1✔
3962
        form = ProjectQuizForm(request.body)
1✔
3963
        if not form.validate():
1✔
3964
            flash('Please correct the errors on this form.', 'message')
1✔
3965
            return form
1✔
3966

3967
        new_quiz_config = form.data
1✔
3968
        new_quiz_config['short_circuit'] = (new_quiz_config['completion_mode'] == 'short_circuit')
1✔
3969
        project.set_quiz(new_quiz_config)
1✔
3970
        project_repo.update(project)
1✔
3971

3972
        auditlogger.log_event(
1✔
3973
            project,
3974
            current_user,
3975
            'update',
3976
            'project.quiz',
3977
            json.dumps(current_quiz_config),
3978
            json.dumps(new_quiz_config)
3979
        )
3980

3981
        if request.data:
1✔
3982
            users = json.loads(request.data).get('users', [])
1✔
3983
            for u in users:
1✔
3984
                user = user_repo.get(u['id'])
1✔
3985
                if u['quiz']['config'].get('reset', False):
1✔
3986
                    user.reset_quiz(project)
1✔
3987
                quiz = user.get_quiz_for_project(project)
1✔
3988
                if u['quiz']['config']['enabled']:
1✔
3989
                    quiz['status'] = 'in_progress'
1✔
3990
                quiz['config']['enabled'] = u['quiz']['config']['enabled']
1✔
3991
                user_repo.update(user)
1✔
3992

3993
        flash(gettext('Configuration updated successfully'), 'success')
1✔
3994

3995
    except Exception as e:
1✔
3996
        flash(gettext('An error occurred.'), 'error')
1✔
3997

3998
    return form
1✔
3999

4000

4001
@blueprint.route('/<short_name>/quiz-mode', methods=['GET', 'POST'])
1✔
4002
@login_required
1✔
4003
@admin_or_subadmin_required
1✔
4004
def quiz_mode(short_name):
1✔
4005
    project, owner, ps = project_by_shortname(short_name)
1✔
4006
    ensure_authorized_to('read', project)
1✔
4007
    ensure_authorized_to('update', project)
1✔
4008

4009
    form = process_quiz_mode_request(project)
1✔
4010

4011
    all_user_quizzes = user_repo.get_all_user_quizzes_for_project(project.id)
1✔
4012
    all_user_quizzes = [dict(row) for row in all_user_quizzes]
1✔
4013
    quiz_mode_choices = [
1✔
4014
            ('all_questions', 'Present all the quiz questions'),
4015
            ('short_circuit', 'End as soon as pass/fail status is known') ]
4016
    project_sanitized, _ = sanitize_project_owner(project, owner, current_user, ps)
1✔
4017

4018
    return handle_content_type(dict(
1✔
4019
        form=form,
4020
        all_user_quizzes=all_user_quizzes,
4021
        quiz_mode_choices=quiz_mode_choices,
4022
        n_gold_unexpired=n_unexpired_gold_tasks(project.id),
4023
        csrf=generate_csrf()
4024
    ))
4025

4026

4027
def _answer_field_has_changed(new_field, old_field):
1✔
4028
    if new_field['type'] != old_field['type']:
1✔
4029
        return True
1✔
4030
    if new_field['config'] != old_field['config']:
1✔
4031
        return True
1✔
4032
    return False
1✔
4033

4034

4035
def _changed_answer_fields_iter(new_config, old_config):
1✔
4036
    deleted = set(old_config.keys()) - set(new_config.keys())
1✔
4037
    for field in deleted:
1✔
4038
        yield field
1✔
4039

4040
    for field, new_val in new_config.items():
1✔
4041
        if field not in old_config:
1✔
4042
            continue
1✔
4043
        old_val = old_config[field]
1✔
4044
        if _answer_field_has_changed(new_val, old_val):
1✔
4045
            yield field
1✔
4046

4047

4048
def delete_stats_for_changed_fields(project_id, new_config, old_config):
1✔
4049
    for field in _changed_answer_fields_iter(new_config, old_config):
1✔
4050
        performance_stats_repo.bulk_delete(project_id, field)
1✔
4051

4052

4053
@blueprint.route('/<short_name>/answerfieldsconfig', methods=['GET', 'POST'])
1✔
4054
@login_required
1✔
4055
@admin_or_subadmin_required
1✔
4056
def answerfieldsconfig(short_name):
1✔
4057
    """Returns Project Stats"""
4058
    project, owner, ps = project_by_shortname(short_name)
1✔
4059
    pro = pro_features()
1✔
4060
    ensure_authorized_to('update', project)
1✔
4061

4062
    answer_fields_key = 'answer_fields'
1✔
4063
    consensus_config_key = 'consensus_config'
1✔
4064
    if request.method == 'POST':
1✔
4065
        try:
1✔
4066
            body = json.loads(request.data) or {}
1✔
4067
            key = 'answer_fields_configutation'
1✔
4068
            data = body
1✔
4069
            answer_fields = body.get(answer_fields_key) or {}
1✔
4070
            consensus_config = body.get(consensus_config_key) or {}
1✔
4071
            delete_stats_for_changed_fields(
1✔
4072
                project.id,
4073
                answer_fields,
4074
                project.info.get(answer_fields_key) or {}
4075
            )
4076
            project.info[answer_fields_key] = answer_fields
1✔
4077
            project.info[consensus_config_key] = consensus_config
1✔
4078
            project_repo.save(project)
1✔
4079
            auditlogger.log_event(project, current_user, 'update', 'project.' + answer_fields_key,
1✔
4080
              'N/A', project.info[answer_fields_key])
4081
            auditlogger.log_event(project, current_user, 'update', 'project.' + consensus_config_key,
1✔
4082
              'N/A', project.info[consensus_config_key])
4083
            flash(gettext('Configuration updated successfully'), 'success')
1✔
4084
        except Exception:
1✔
4085
            flash(gettext('An error occurred.'), 'error')
1✔
4086
    project_sanitized, owner_sanitized = sanitize_project_owner(
1✔
4087
        project, owner, current_user, ps)
4088

4089
    answer_fields = project.info.get(answer_fields_key , {})
1✔
4090
    consensus_config = project.info.get(consensus_config_key , {})
1✔
4091
    response = {
1✔
4092
        'template': '/projects/answerfieldsconfig.html',
4093
        'project': project_sanitized,
4094
        answer_fields_key : json.dumps(answer_fields),
4095
        consensus_config_key : json.dumps(consensus_config),
4096
        'pro_features': pro,
4097
        'csrf': generate_csrf()
4098
    }
4099
    return handle_content_type(response)
1✔
4100

4101

4102
@blueprint.route('/<short_name>/performancestats', methods=['GET', 'DELETE'])
1✔
4103
@login_required
1✔
4104
def show_performance_stats(short_name):
1✔
4105
    """Returns Project Stats"""
4106
    project, owner, ps = project_by_shortname(short_name)
1✔
4107
    ensure_authorized_to('read', project)
1✔
4108
    title = project_title(project, "Performance Statistics")
1✔
4109
    pro = pro_features(owner)
1✔
4110

4111
    if request.method == 'DELETE':
1✔
4112
        ensure_authorized_to('update', project)
1✔
4113
        performance_stats_repo.bulk_delete(
1✔
4114
            project_id=project.id,
4115
            field=request.args['field'],
4116
            user_id=request.args.get('user_id')
4117
        )
4118
        return Response('', 204)
1✔
4119

4120
    answer_fields = project.info.get('answer_fields', {})
1✔
4121
    project_sanitized, owner_sanitized = sanitize_project_owner(project,
1✔
4122
                                                                owner,
4123
                                                                current_user,
4124
                                                                ps)
4125
    _, _, user_ids = stats.stats_users(project.id)
1✔
4126

4127
    can_update = current_user.admin or \
1✔
4128
        (current_user.subadmin and current_user.id in project.owners_ids)
4129

4130
    if can_update:
1✔
4131
        users = {uid: cached_users.get_user_info(uid)['name'] for uid, _ in user_ids}
1✔
4132
    else:
4133
        users = {current_user.id: current_user.name}
1✔
4134

4135
    response = dict(template='/projects/performancestats.html',
1✔
4136
                    title=title,
4137
                    project=project_sanitized,
4138
                    answer_fields=answer_fields,
4139
                    owner=owner_sanitized,
4140
                    can_update=can_update,
4141
                    contributors=users,
4142
                    csrf=generate_csrf(),
4143
                    pro_features=pro)
4144

4145
    return handle_content_type(response)
1✔
4146

4147

4148
@blueprint.route('/<short_name>/enrichment', methods=['GET', 'POST'])
1✔
4149
@login_required
1✔
4150
@admin_or_subadmin_required
1✔
4151
def configure_enrichment(short_name):
1✔
4152
    project, owner, ps = project_by_shortname(short_name)
1✔
4153
    project_sanitized, owner_sanitized = sanitize_project_owner(
1✔
4154
        project, owner, current_user, ps)
4155

4156
    if request.method != 'POST':
1✔
4157
        ensure_authorized_to('read', Project)
1✔
4158
        pro = pro_features()
1✔
4159
        enrichment_types = current_app.config.get('ENRICHMENT_TYPES', {})
1✔
4160
        dict_project = add_custom_contrib_button_to(project, get_user_id_or_ip(), ps=ps)
1✔
4161
        enrichments = project_sanitized.get('info', {}).get('enrichments', [])
1✔
4162
        response = dict(template='projects/enrichment.html',
1✔
4163
                        title=gettext("Configure enrichment"),
4164
                        enrichments=json.dumps(enrichments),
4165
                        project=project_sanitized,
4166
                        pro_features=pro,
4167
                        csrf=generate_csrf(),
4168
                        enrichment_types=enrichment_types)
4169
        return handle_content_type(response)
1✔
4170

4171
    ensure_authorized_to('update', Project)
1✔
4172
    data = json.loads(request.data)
1✔
4173
    if data and data.get('enrich_data'):
1✔
4174
        project.info['enrichments'] = data['enrich_data']
1✔
4175
        project_repo.save(project)
1✔
4176
        auditlogger.log_event(project, current_user, 'update', 'project',
1✔
4177
                                'enrichment', json.dumps(project.info['enrichments']))
4178
        flash(gettext("Success! Project data enrichment updated"))
1✔
4179
    return redirect_content_type(url_for('.settings', short_name=project.short_name))
1✔
4180

4181

4182
@blueprint.route('/<short_name>/annotconfig', methods=['GET', 'POST'])
1✔
4183
@login_required
1✔
4184
@admin_or_subadmin_required
1✔
4185
def annotation_config(short_name):
1✔
4186
    project, owner, ps = project_by_shortname(short_name)
1✔
4187
    project_sanitized, owner_sanitized = sanitize_project_owner(
1✔
4188
        project, owner, current_user, ps)
4189
    ensure_authorized_to('read', project)
1✔
4190

4191
    annotation_config = deepcopy(project.info.get('annotation_config', {}))
1✔
4192
    if request.method != 'POST':
1✔
4193
        pro = pro_features()
1✔
4194
        annotation_config.pop('amp_store', None)
1✔
4195
        annotation_config.pop('amp_pvf', None)
1✔
4196
        form = AnnotationForm(**annotation_config)
1✔
4197
        response = dict(template='projects/annotations.html',
1✔
4198
                        form=form,
4199
                        project=project_sanitized,
4200
                        pro_features=pro,
4201
                        csrf=generate_csrf())
4202
        return handle_content_type(response)
1✔
4203

4204
    ensure_authorized_to('update', Project)
1✔
4205
    form = AnnotationForm(request.body)
1✔
4206
    if not form.validate():
1✔
UNCOV
4207
        flash("Please fix annotation configuration errors", 'message')
×
UNCOV
4208
        current_app.logger.error('Annotation config errors for project {}, error {}'.format(project.id, form.errors))
×
4209
        return form
×
4210

4211
    annotation_fields = [
1✔
4212
        'dataset_description',
4213
        'provider',
4214
        'restrictions_and_permissioning',
4215
        'sampling_method',
4216
        'sampling_script'
4217
    ]
4218

4219
    for field_name in annotation_fields:
1✔
4220
        value = getattr(form, field_name).data
1✔
4221
        if value:
1✔
4222
            annotation_config[field_name] = value
1✔
4223
        else:
UNCOV
4224
            annotation_config.pop(field_name, None)
×
4225
    if not annotation_config:
1✔
4226
        project.info.pop('annotation_config', None)
×
4227
    else:
4228
        project.info['annotation_config'] = annotation_config
1✔
4229
    project_repo.save(project)
1✔
4230
    auditlogger.log_event(project, current_user, 'update', 'project',
1✔
4231
                            'annotation_config', json.dumps(project.info.get('annotation_config')))
4232
    flash(gettext('Project annotation configurations updated'), 'success')
1✔
4233
    return redirect_content_type(url_for('.settings', short_name=project.short_name))
1✔
4234

4235
@blueprint.route('/<short_name>/contact', methods=['POST'])
1✔
4236
@login_required
1✔
4237
def contact(short_name):
1✔
4238
    result = project_by_shortname(short_name)
1✔
4239
    project = result[0]
1✔
4240

4241
    subject = current_app.config.get('CONTACT_SUBJECT', 'GIGwork message for project {short_name} by {email}')
1✔
4242
    subject = subject.replace('{short_name}', short_name).replace('{email}', current_user.email_addr)
1✔
4243
    body_header = current_app.config.get('CONTACT_BODY', 'A GIGwork support request has been sent for the project: {project_name}.')
1✔
4244

4245
    success_body = body_header + '\n\n' + (
1✔
4246
        '    User: {fullname} ({user_name}, {user_id})\n'
4247
        '    Email: {email}\n'
4248
        '    Message: {message}\n\n'
4249
        '    Project Name: {project_name}\n'
4250
        '    Project Short Name: {project_short_name} ({project_id})\n'
4251
        '    Referring Url: {referrer}\n'
4252
        '    Is Admin: {user_admin}\n'
4253
        '    Is Subadmin: {user_subadmin}\n'
4254
        '    Project Owner: {owner}\n'
4255
        '    Total Tasks: {total_tasks}\n'
4256
        '    Total Task Runs: {total_task_runs}\n'
4257
        '    Available Task Runs: {remaining_task_runs}\n'
4258
        '    Tasks Available to User: {tasks_available_user}\n'
4259
        '    Tasks Completed by User: {tasks_completed_user}\n'
4260
    )
4261

4262
    body = success_body.format(
1✔
4263
        fullname=current_user.fullname,
4264
        user_name=current_user.name,
4265
        user_id=current_user.id,
4266
        email=request.body.get("email", None),
4267
        message=request.body.get("message", None),
4268
        project_name=project.name,
4269
        project_short_name=project.short_name,
4270
        project_id=project.id,
4271
        referrer=request.headers.get("Referer"),
4272
        user_admin=current_user.admin,
4273
        user_subadmin=current_user.subadmin,
4274
        owner=request.body.get("projectOwner", False),
4275
        total_tasks=request.body.get("totalTasks", 0),
4276
        total_task_runs=request.body.get("totalTaskRuns", 0),
4277
        remaining_task_runs=request.body.get("remainingTasksRuns", 0),
4278
        tasks_available_user=request.body.get("tasksAvailableUser", 0),
4279
        tasks_completed_user=request.body.get("tasksCompletedUser", 0)
4280
    )
4281

4282
    # Use the customized list of contacts for the project or default to owners.
4283
    contact_ids = project.info.get('contacts', project.owners_ids)
1✔
4284
    # Load the record for each contact id.
4285
    contact_users = user_repo.get_users(contact_ids)
1✔
4286
    # Get the email address for each contact that was added manually or that is enabled and assigned to this project or is a sub-admin/admin.
4287
    recipients = [contact.email_addr for contact in contact_users if project.info.get('contacts') or is_user_enabled_assigned_project(contact, project)]
1✔
4288

4289
    # Send email.
4290
    email = dict(recipients=recipients,
1✔
4291
                 subject=subject,
4292
                 body=body)
4293
    mail_queue.enqueue(send_mail, email)
1✔
4294

4295
    current_app.logger.info('Contact email sent from user id {} ({}) to recipients {} for project id {} ({}, {})'.format(
1✔
4296
        current_user.id, current_user.name, recipients, project.id, project.name, short_name))
4297

4298
    response = {
1✔
4299
        'success': True
4300
    }
4301

4302
    return handle_content_type(response)
1✔
4303

4304
def get_last_activity(project):
1✔
4305
    last_submission_task_date = latest_submission_task_date(project.id)
1✔
4306
    last_submission_task_date = last_submission_task_date[0:10] if last_submission_task_date else 'None'
1✔
4307

4308
    return project.last_activity if project.last_activity != '0' and project.last_activity != 'None' else last_submission_task_date
1✔
4309

4310
def get_locked_tasks(project, task_id=None):
1✔
4311
    """Returns a list of locked tasks for a project."""
4312
    locked_tasks = []
1✔
4313

4314
    # Get locked tasks for this project.
4315
    locks = get_locked_tasks_project(project.id)
1✔
4316
    for lock in locks:
1✔
4317
        # Get user details for the lock.
4318
        user_id = lock.get('user_id')
1✔
4319
        lock_task_id = lock.get('task_id')
1✔
4320
        seconds_remaining = lock.get('seconds_remaining')
1✔
4321

4322
        if not task_id or task_id == int(lock_task_id):
1✔
4323
            user = cached_users.get_user_by_id(user_id)
1✔
4324
            data = {
1✔
4325
                "user_id": user_id,
4326
                "task_id": lock_task_id,
4327
                "seconds_remaining": seconds_remaining,
4328
                "name": user.name,
4329
                "fullname": user.fullname,
4330
                "email": user.email_addr,
4331
                "admin": user.admin,
4332
                "subadmin": user.subadmin,
4333
            }
4334
            locked_tasks.append(data)
1✔
4335

4336
    return locked_tasks
1✔
4337

4338
@blueprint.route('/<short_name>/locks/', methods=['GET'], defaults={'task_id': ''})
1✔
4339
@blueprint.route('/<short_name>/locks/<int:task_id>/', methods=['GET'])
1✔
4340
@login_required
1✔
4341
@admin_or_subadmin_required
1✔
4342
def locks(short_name, task_id):
1✔
4343
    """View locked task(s) for a project."""
4344
    form = SearchForm(request.body)
1✔
4345

4346
    # Get the project.
4347
    project, owner, ps = project_by_shortname(short_name)
1✔
4348

4349
    # Security check.
4350
    ensure_authorized_to('read', project)
1✔
4351

4352
    # Retrieve locked tasks.
4353
    locked_tasks = get_locked_tasks(project, task_id)
1✔
4354

4355
    status_code = 404 if task_id and not len(locked_tasks) else 200
1✔
4356

4357
    return jsonify(locked_tasks), status_code
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc