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

bloomberg / pybossa / 12266177335

10 Dec 2024 10:56PM UTC coverage: 94.108% (+0.02%) from 94.084%
12266177335

Pull #1013

github

dchhabda
remove incremental sched
Pull Request #1013: RDISCROWD-7806: Remove bfs, dfs unused schedulers

1 of 1 new or added line in 1 file covered. (100.0%)

2 existing lines in 2 files now uncovered.

17443 of 18535 relevant lines covered (94.11%)

0.94 hits per line

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

97.11
/pybossa/api/__init__.py
1
# -*- coding: utf8 -*-
2
# This file is part of PYBOSSA.
3
#
4
# Copyright (C) 2015 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
"""
1✔
19
PYBOSSA api module for exposing domain objects via an API.
20

21
This package adds GET, POST, PUT and DELETE methods for:
22
    * projects,
23
    * categories,
24
    * tasks,
25
    * task_runs,
26
    * users,
27
    * global_stats,
28
"""
29

30
from functools import partial
1✔
31
import json
1✔
32
import jwt
1✔
33
from flask import Blueprint, request, abort, Response, make_response
1✔
34
from flask import current_app
1✔
35
from flask_login import current_user, login_required
1✔
36
from time import time
1✔
37
from datetime import datetime, timedelta
1✔
38
from werkzeug.exceptions import NotFound
1✔
39
from pybossa.util import jsonpify, get_user_id_or_ip, fuzzyboolean, \
1✔
40
    PARTIAL_ANSWER_KEY, SavedTaskPositionEnum, PARTIAL_ANSWER_POSITION_KEY, \
41
    get_user_saved_partial_tasks
42
from pybossa.util import get_disqus_sso_payload, grant_access_with_api_key
1✔
43
import dateutil.parser
1✔
44
import pybossa.model as model
1✔
45
from pybossa.core import csrf, ratelimits, sentinel, anonymizer
1✔
46
from pybossa.ratelimit import ratelimit
1✔
47
from pybossa.cache.projects import n_tasks, n_completed_tasks
1✔
48
import pybossa.sched as sched
1✔
49
from pybossa.util import sign_task, can_update_user_info
1✔
50
from pybossa.error import ErrorStatus
1✔
51
from .global_stats import GlobalStatsAPI
1✔
52
from .task import TaskAPI
1✔
53
from .task_run import TaskRunAPI, preprocess_task_run
1✔
54
from .project import ProjectAPI
1✔
55
from .auditlog import AuditlogAPI
1✔
56
from .announcement import AnnouncementAPI
1✔
57
from .blogpost import BlogpostAPI
1✔
58
from .category import CategoryAPI
1✔
59
from .favorites import FavoritesAPI
1✔
60
from pybossa.api.performance_stats import PerformanceStatsAPI
1✔
61
from .user import UserAPI
1✔
62
from .token import TokenAPI
1✔
63
from .result import ResultAPI
1✔
64
from rq import Queue
1✔
65
from .project_stats import ProjectStatsAPI
1✔
66
from .helpingmaterial import HelpingMaterialAPI
1✔
67
from pybossa.core import auditlog_repo, project_repo, task_repo, user_repo
1✔
68
from pybossa.contributions_guard import ContributionsGuard
1✔
69
from pybossa.auth import jwt_authorize_project
1✔
70
from werkzeug.exceptions import MethodNotAllowed, Forbidden
1✔
71
from .completed_task import CompletedTaskAPI
1✔
72
from .completed_task_run import CompletedTaskRunAPI
1✔
73
from pybossa.cache.helpers import (n_available_tasks, n_available_tasks_for_user,
1✔
74
    n_unexpired_gold_tasks)
75
from pybossa.sched import (get_scheduler_and_timeout, has_lock, release_lock, Schedulers,
1✔
76
                           fetch_lock_for_user, release_reserve_task_lock_by_id)
77
from pybossa.jobs import send_mail
1✔
78
from pybossa.api.project_by_name import ProjectByNameAPI, project_name_to_oid
1✔
79
from pybossa.api.project_details import ProjectDetailsAPI
1✔
80
from pybossa.api.project_locks import ProjectLocksAPI
1✔
81
from pybossa.api.pwd_manager import get_pwd_manager
1✔
82
from pybossa.data_access import data_access_levels
1✔
83
from pybossa.task_creator_helper import set_gold_answers
1✔
84
from pybossa.auth.task import TaskAuth
1✔
85
from pybossa.service_validators import ServiceValidators
1✔
86
import requests
1✔
87
from sqlalchemy.sql import text
1✔
88
from sqlalchemy.orm.attributes import flag_modified
1✔
89
from pybossa.core import db
1✔
90
from pybossa.cache import users as cached_users, ONE_MONTH
1✔
91
from pybossa.cache.task_browse_helpers import get_searchable_columns
1✔
92
from pybossa.cache.users import get_user_pref_metadata
1✔
93
from pybossa.view.projects import get_locked_tasks
1✔
94
from pybossa.redis_lock import EXPIRE_LOCK_DELAY
1✔
95
from pybossa.api.bulktasks import BulkTasksAPI
1✔
96

97
task_fields = [
1✔
98
    "id",
99
    "state",
100
    "n_answers",
101
    "created",
102
    "calibration",
103
]
104

105
blueprint = Blueprint('api', __name__)
1✔
106

107
error = ErrorStatus()
1✔
108
mail_queue = Queue('email', connection=sentinel.master)
1✔
109

110

111
@blueprint.route('/')
1✔
112
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
113
def index():  # pragma: no cover
114
    """Return dummy text for welcome page."""
115
    return 'The %s API' % current_app.config.get('BRAND')
116

117

118
@blueprint.before_request
1✔
119
def _api_authentication_with_api_key():
1✔
120
    """ Allow API access with valid api_key."""
121
    secure_app_access = current_app.config.get('SECURE_APP_ACCESS', False)
1✔
122
    if secure_app_access:
1✔
123
        grant_access_with_api_key(secure_app_access)
1✔
124

125

126
def register_api(view, endpoint, url, pk='id', pk_type='int'):
1✔
127
    """Register API endpoints.
128

129
    Registers new end points for the API using classes.
130

131
    """
132
    view_func = view.as_view(endpoint)
1✔
133
    csrf.exempt(view_func)
1✔
134
    blueprint.add_url_rule(url,
1✔
135
                           endpoint=endpoint,
136
                           view_func=view_func,
137
                           defaults={pk: None},
138
                           methods=['GET', 'OPTIONS'])
139
    blueprint.add_url_rule(url,
1✔
140
                           endpoint=endpoint,
141
                           view_func=view_func,
142
                           methods=['POST', 'OPTIONS'])
143
    blueprint.add_url_rule('%s/<%s:%s>' % (url, pk_type, pk),
1✔
144
                            endpoint=endpoint + '_' + pk,
145
                            view_func=view_func,
146
                            methods=['GET', 'PUT', 'DELETE', 'OPTIONS'])
147

148
register_api(ProjectAPI, 'api_project', '/project', pk='oid', pk_type='int')
1✔
149
register_api(ProjectStatsAPI, 'api_projectstats', '/projectstats', pk='oid', pk_type='int')
1✔
150
register_api(CategoryAPI, 'api_category', '/category', pk='oid', pk_type='int')
1✔
151
register_api(TaskAPI, 'api_task', '/task', pk='oid', pk_type='int')
1✔
152
register_api(AuditlogAPI, 'api_auditlog', '/auditlog', pk='oid', pk_type='int')
1✔
153
register_api(TaskRunAPI, 'api_taskrun', '/taskrun', pk='oid', pk_type='int')
1✔
154
register_api(ResultAPI, 'api_result', '/result', pk='oid', pk_type='int')
1✔
155
register_api(UserAPI, 'api_user', '/user', pk='oid', pk_type='int')
1✔
156
register_api(AnnouncementAPI, 'api_announcement', '/announcement', pk='oid', pk_type='int')
1✔
157
register_api(BlogpostAPI, 'api_blogpost', '/blogpost', pk='oid', pk_type='int')
1✔
158
register_api(HelpingMaterialAPI, 'api_helpingmaterial',
1✔
159
             '/helpingmaterial', pk='oid', pk_type='int')
160
register_api(GlobalStatsAPI, 'api_globalstats', '/globalstats',
1✔
161
             pk='oid', pk_type='int')
162
register_api(FavoritesAPI, 'api_favorites', '/favorites',
1✔
163
             pk='oid', pk_type='int')
164
register_api(TokenAPI, 'api_token', '/token', pk='token', pk_type='string')
1✔
165
register_api(CompletedTaskAPI, 'api_completedtask', '/completedtask', pk='oid', pk_type='int')
1✔
166
register_api(CompletedTaskRunAPI, 'api_completedtaskrun', '/completedtaskrun', pk='oid', pk_type='int')
1✔
167
register_api(ProjectByNameAPI, 'api_projectbyname', '/projectbyname', pk='key', pk_type='string')
1✔
168
register_api(ProjectDetailsAPI, 'api_projectdetails', '/projectdetails', pk='oid', pk_type='int')
1✔
169
register_api(ProjectLocksAPI, 'api_projectlocks', '/locks', pk='oid', pk_type='int')
1✔
170
register_api(PerformanceStatsAPI, 'api_performancestats', '/performancestats', pk='oid', pk_type='int')
1✔
171
register_api(BulkTasksAPI, 'api_bulktasks', '/bulktasks', pk='oid', pk_type='int')
1✔
172

173

174
def add_task_signature(tasks):
1✔
175
    if current_app.config.get('ENABLE_ENCRYPTION'):
1✔
176
        for task in tasks:
1✔
177
            sign_task(task)
1✔
178

179
@jsonpify
1✔
180
@blueprint.route('/project/<project_id>/newtask')
1✔
181
@blueprint.route('/project/<project_id>/newtask/<int:task_id>')
1✔
182
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
183
def new_task(project_id, task_id=None):
1✔
184
    """Return a new task for a project."""
185
    # Check the value of saved_task_position from Redis:
186
    saved_task_position = None
1✔
187
    if not current_user.is_anonymous:
1✔
188
        position_key = PARTIAL_ANSWER_POSITION_KEY.format(project_id=project_id, user_id=current_user.id)
1✔
189
        saved_task_position = sentinel.master.get(position_key)
1✔
190
        if saved_task_position:
1✔
191
            try:
1✔
192
                saved_task_position = SavedTaskPositionEnum(saved_task_position.decode('utf-8'))
1✔
193
            except ValueError as e:
1✔
194
                pass
1✔
195

196
    # Check if the request has an arg:
197
    try:
1✔
198
        tasks, timeout, cookie_handler = _retrieve_new_task(project_id, task_id, saved_task_position)
1✔
199

200
        if type(tasks) is Response:
1✔
201
            return tasks
×
202

203
        user_id_or_ip = get_user_id_or_ip()
1✔
204
        # If there is a task for the user, return it
205
        if tasks is not None:
1✔
206
            guard = ContributionsGuard(sentinel.master, timeout=timeout)
1✔
207
            for task in tasks:
1✔
208
                guard.stamp(task, user_id_or_ip)
1✔
209
                if not guard.check_task_presented_timestamp(task, user_id_or_ip):
1✔
210
                    guard.stamp_presented_time(task, user_id_or_ip)
1✔
211
                else:
212
                    # user returning back for the same task
213
                    # original presented time has not expired yet
214
                    # reset presented time for projects configured to reset it
215
                    project = project_repo.get(project_id)
1✔
216
                    if not project:
1✔
217
                        return abort(400)
×
218
                    reset_presented_time = project.info.get("reset_presented_time", False)
1✔
219
                    if reset_presented_time:
1✔
220
                        cancelled_task = guard.retrieve_cancelled_timestamp(task, user_id_or_ip)
1✔
221
                        if cancelled_task:
1✔
222
                            # user is returning to same task upon cancelling the task
223
                            # reset presented time when configured under project settings
224
                            guard.stamp_presented_time(task, user_id_or_ip)
1✔
225
                            guard.remove_cancelled_timestamp(task, get_user_id_or_ip())
1✔
226
                    else:
227
                        # to continue original presented time, extend expiry
228
                        guard.extend_task_presented_timestamp_expiry(task, user_id_or_ip)
1✔
229

230
            data = [TaskAuth.dictize_with_access_control(task) for task in tasks]
1✔
231
            add_task_signature(data)
1✔
232
            if len(data) == 0:
1✔
233
                response = make_response(json.dumps({}))
1✔
234
            elif len(data) == 1:
1✔
235
                response = make_response(json.dumps(data[0]))
1✔
236
            else:
UNCOV
237
                response = make_response(json.dumps(data))
×
238
            response.mimetype = "application/json"
1✔
239
            cookie_handler(response)
1✔
240
            return response
1✔
241
        return Response(json.dumps({}), mimetype="application/json")
1✔
242
    except Exception as e:
1✔
243
        return error.format_exception(e, target='project', action='GET')
1✔
244

245

246
def _retrieve_new_task(project_id, task_id=None, saved_task_position=None):
1✔
247
    project = project_repo.get(project_id)
1✔
248
    if project is None or not(project.published or current_user.admin
1✔
249
        or current_user.id in project.owners_ids):
250
        raise NotFound
1✔
251

252
    if current_user.is_anonymous:
1✔
253
        info = dict(
1✔
254
            error="This project does not allow anonymous contributors")
255
        error = [model.task.Task(info=info)]
1✔
256
        return error, None, lambda x: x
1✔
257

258
    if current_user.get_quiz_failed(project):
1✔
259
        # User is blocked from project so don't return a task
260
        return None, None, None
1✔
261

262
    # check cookie
263
    pwd_manager = get_pwd_manager(project)
1✔
264
    user_id_or_ip = get_user_id_or_ip()
1✔
265
    if pwd_manager.password_needed(project, user_id_or_ip):
1✔
266
        raise Forbidden("No project password provided")
1✔
267

268
    if request.args.get('external_uid'):
1✔
269
        resp = jwt_authorize_project(project,
×
270
                                     request.headers.get('Authorization'))
271
        if resp != True:
×
272
            return resp, lambda x: x
×
273

274
    if request.args.get('limit'):
1✔
275
        limit = int(request.args.get('limit'))
1✔
276
    else:
277
        limit = 1
1✔
278

279
    if limit > 100:
1✔
280
        limit = 100
×
281

282
    if request.args.get('offset'):
1✔
283
        offset = int(request.args.get('offset'))
1✔
284
    else:
285
        offset = 0
1✔
286

287
    if request.args.get('orderby'):
1✔
288
        orderby = request.args.get('orderby')
1✔
289
    else:
290
        orderby = 'id'
1✔
291

292
    if request.args.get('desc'):
1✔
293
        desc = fuzzyboolean(request.args.get('desc'))
1✔
294
    else:
295
        desc = False
1✔
296

297
    user_id = None if current_user.is_anonymous else current_user.id
1✔
298
    user_ip = (anonymizer.ip(request.remote_addr or '127.0.0.1')
1✔
299
               if current_user.is_anonymous else None)
300
    external_uid = request.args.get('external_uid')
1✔
301
    sched_rand_within_priority = project.info.get('sched_rand_within_priority', False)
1✔
302

303
    user = user_repo.get(user_id)
1✔
304
    if (
1✔
305
        project.published
306
        and user_id != project.owner_id
307
        and user_id not in project.owners_ids
308
        and user.get_quiz_not_started(project)
309
        and user.get_quiz_enabled(project)
310
        and not task_repo.get_user_has_task_run_for_project(project_id, user_id)
311
    ):
312
        user.set_quiz_status(project, 'in_progress')
1✔
313

314
    # We always update the user even if we didn't change the quiz status.
315
    # The reason for that is the user.<?quiz?> methods take a snapshot of the project's quiz
316
    # config the first time it is accessed for a user and save that snapshot
317
    # with the user. So we want to commit that snapshot if this is the first access.
318
    user_repo.update(user)
1✔
319

320
    # Allow scheduling a gold-only task if quiz mode is enabled for the user and the project.
321
    quiz_mode_enabled = user.get_quiz_in_progress(project) and project.info["quiz"]["enabled"]
1✔
322

323
    task = sched.new_task(project.id,
1✔
324
                          project.info.get('sched'),
325
                          user_id,
326
                          user_ip,
327
                          external_uid,
328
                          offset,
329
                          limit,
330
                          orderby=orderby,
331
                          desc=desc,
332
                          rand_within_priority=sched_rand_within_priority,
333
                          gold_only=quiz_mode_enabled,
334
                          task_id=task_id,
335
                          saved_task_position=saved_task_position)
336

337
    handler = partial(pwd_manager.update_response, project=project,
1✔
338
                      user=user_id_or_ip)
339
    return task, project.info.get('timeout'), handler
1✔
340

341
def _guidelines_updated(project_id, user_id):
1✔
342
    """Function to determine if guidelines has been
343
        updated since last submission"""
344

345
    query_attrs_log = dict(project_id=project_id, attribute='task_guidelines', desc=True)
1✔
346
    query_attrs_task_run = dict(project_id=project_id, user_id=user_id)
1✔
347

348
    guidelines_log = auditlog_repo.filter_by(limit=1, **query_attrs_log)
1✔
349
    last_guidelines_update = dateutil.parser.parse(guidelines_log[0].created) if guidelines_log else None
1✔
350
    task_runs = task_repo.filter_task_runs_by(limit=1, desc=True, **query_attrs_task_run)
1✔
351
    last_task_run_time = dateutil.parser.parse(task_runs[0].created) if task_runs else None
1✔
352

353
    return last_task_run_time < last_guidelines_update if last_task_run_time and last_guidelines_update else False
1✔
354

355
@jsonpify
1✔
356
@blueprint.route('/app/<short_name>/userprogress')
1✔
357
@blueprint.route('/project/<short_name>/userprogress')
1✔
358
@blueprint.route('/app/<int:project_id>/userprogress')
1✔
359
@blueprint.route('/project/<int:project_id>/userprogress')
1✔
360
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
361
def user_progress(project_id=None, short_name=None):
1✔
362
    """API endpoint for user progress.
363

364
    Return a JSON object with four fields regarding the tasks for the user:
365
        { 'done': 10,
366
          'total: 100,
367
          'remaining': 90,
368
          'remaining_for_user': 45
369
        }
370
       This will mean that the user has done 10% of the available tasks for the
371
       project, 90 tasks are yet to be submitted and the user can access 45 of
372
       them based on user preferences.
373

374
    """
375
    if current_user.is_anonymous:
1✔
376
        return abort(401)
1✔
377
    if project_id or short_name:
1✔
378
        if short_name:
1✔
379
            project = project_repo.get_by_shortname(short_name)
1✔
380
        elif project_id:
1✔
381
            project = project_repo.get(project_id)
1✔
382

383
        if project:
1✔
384
            # For now, keep this version, but wait until redis cache is
385
            # used here for task_runs too
386
            query_attrs = dict(project_id=project.id, user_id=current_user.id)
1✔
387
            guidelines_updated = _guidelines_updated(project.id, current_user.id)
1✔
388
            taskrun_count = task_repo.count_task_runs_with(**query_attrs)
1✔
389
            num_available_tasks = n_available_tasks(project.id, include_gold_task=True)
1✔
390
            num_available_tasks_for_user = n_available_tasks_for_user(project, current_user.id)
1✔
391
            response = dict(
1✔
392
                done=taskrun_count,
393
                total=n_tasks(project.id),
394
                completed=n_completed_tasks(project.id),
395
                remaining=num_available_tasks,
396
                locked=len({task["task_id"] for task in get_locked_tasks(project)}),
397
                remaining_for_user=num_available_tasks_for_user,
398
                quiz=current_user.get_quiz_for_project(project),
399
                guidelines_updated=guidelines_updated
400
            )
401
            if current_user.admin or (current_user.subadmin and current_user.id in project.owners_ids):
1✔
402
                num_gold_tasks = n_unexpired_gold_tasks(project.id)
1✔
403
                response['available_gold_tasks'] = num_gold_tasks
1✔
404
            return Response(json.dumps(response), mimetype="application/json")
1✔
405
        else:
406
            return abort(404)
1✔
407
    else:  # pragma: no cover
408
        return abort(404)
409

410
@jsonpify
1✔
411
@blueprint.route('/app/<short_name>/taskprogress')
1✔
412
@blueprint.route('/project/<short_name>/taskprogress')
1✔
413
@blueprint.route('/app/<int:project_id>/taskprogress')
1✔
414
@blueprint.route('/project/<int:project_id>/taskprogress')
1✔
415
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
416
def task_progress(project_id=None, short_name=None):
1✔
417
    """API endpoint for task progress.
418

419
    Returns a JSON object continaing the number of tasks which meet the user defined filter constraints
420
    """
421
    if current_user.is_anonymous:
1✔
422
        return abort(401)
×
423
    if not (project_id or short_name):
1✔
424
        return abort(404)
×
425
    if short_name:
1✔
426
        project = project_repo.get_by_shortname(short_name)
×
427
    elif project_id:
1✔
428
        project = project_repo.get(project_id)
1✔
429
    filter_fields = request.args
1✔
430
    if not project:
1✔
431
        return abort(404)
×
432

433
    sql_text = "SELECT COUNT(*) FROM task WHERE project_id=" + str(project.id)
1✔
434
    task_info_fields = get_searchable_columns(project.id)
1✔
435

436
    # create sql query from filter fields received on request.args
437
    for key in filter_fields.keys():
1✔
438
        if key in task_fields:
1✔
439
            sql_text += " AND {0}=:{1}".format(key, key)
×
440
        elif key in task_info_fields:
1✔
441
            # include support for empty string and null in URL
442
            if filter_fields[key].lower() in ["null", ""]:
1✔
443
                sql_text +=  " AND info ->> '{0}' is Null".format(key)
1✔
444
            else:
445
                sql_text += " AND info ->> '{0}'=:{1}".format(key, key)
1✔
446
        else:
447
            raise Exception("invalid key: the field that you are filtering by does not exist")
×
448
    sql_text += ';'
1✔
449
    sql_query = text(sql_text)
1✔
450
    results = db.slave_session.execute(sql_query, filter_fields)
1✔
451
    timeout = current_app.config.get('TIMEOUT')
1✔
452

453
    # results are stored as a sqlalchemy resultProxy
454
    num_tasks = results.first()[0]
1✔
455
    task_count_dict = dict(task_count=num_tasks)
1✔
456
    return Response(json.dumps(task_count_dict), mimetype="application/json")
1✔
457

458

459
@jsonpify
1✔
460
@login_required
1✔
461
@blueprint.route('/preferences/<user_name>', methods=['GET'])
1✔
462
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
463
def get_user_preferences(user_name):
1✔
464
    """API endpoint for loading account user preferences.
465
    Returns a JSON object containing the user account preferences.
466
    """
467
    user = user_repo.get_by_name(user_name)
1✔
468
    if not user:
1✔
469
        return abort(404)
1✔
470

471
    try:
1✔
472
        (can_update, disabled_fields, hidden_fields) = can_update_user_info(current_user, user)
1✔
473
    except Exception:
1✔
474
        return abort(404)
1✔
475

476
    if not can_update or hidden_fields:
1✔
477
        return abort(403)
1✔
478

479
    user_preferences = get_user_pref_metadata(user_name)
1✔
480

481
    return Response(json.dumps(user_preferences), mimetype="application/json")
1✔
482

483

484
@jsonpify
1✔
485
@login_required
1✔
486
@csrf.exempt
1✔
487
@blueprint.route('/preferences/<user_name>', methods=['POST'])
1✔
488
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
489
def update_user_preferences(user_name):
1✔
490
    """API endpoint for updating account user preferences.
491
    Returns a JSON object containing the updated user account preferences.
492
    """
493
    user = user_repo.get_by_name(user_name)
1✔
494
    if not user:
1✔
495
        return abort(404)
1✔
496

497
    try:
1✔
498
        (can_update, disabled_fields, hidden_fields) = can_update_user_info(current_user, user)
1✔
499
    except Exception:
1✔
500
        return abort(404)
1✔
501

502
    if not can_update or disabled_fields:
1✔
503
        return abort(403)
1✔
504

505
    payload = json.loads(request.form['request_json']) if 'request_json' in request.form else request.json
1✔
506

507
    # User must post a payload or empty json object {}.
508
    if not payload and payload != {}:
1✔
509
        return abort(400)
1✔
510

511
    user_preferences = None
1✔
512
    if user:
1✔
513
        # Add a metadata section if not found.
514
        if 'metadata' not in user.info:
1✔
515
            user.info['metadata'] = {}
1✔
516

517
        # Update user preferences value.
518
        user.info.get('metadata', {})['profile'] = json.dumps(payload) if payload else ''
1✔
519

520
        # Set dirty flag on user.info['metadata']['profile']
521
        flag_modified(user, 'info')
1✔
522

523
        # Save user preferences.
524
        user_repo.update(user)
1✔
525

526
        # Clear user in cache.
527
        cached_users.delete_user_pref_metadata(user)
1✔
528

529
        # Return updated metadata and user preferences.
530
        user_preferences = user.info.get('metadata', {})
1✔
531

532
    return Response(json.dumps(user_preferences), mimetype="application/json")
1✔
533

534

535
@jsonpify
1✔
536
@blueprint.route('/auth/project/<short_name>/token')
1✔
537
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
538
def auth_jwt_project(short_name):
1✔
539
    """Create a JWT for a project via its secret KEY."""
540
    project_secret_key = None
1✔
541
    if 'Authorization' in request.headers:
1✔
542
        project_secret_key = request.headers.get('Authorization')
1✔
543
    if project_secret_key:
1✔
544
        project = project_repo.get_by_shortname(short_name)
1✔
545
        if project and project.secret_key == project_secret_key:
1✔
546
            token = jwt.encode({'short_name': short_name,
1✔
547
                                'project_id': project.id},
548
                               project.secret_key, algorithm='HS256')
549
            return token
1✔
550
        else:
551
            return abort(404)
1✔
552
    else:
553
        return abort(403)
1✔
554

555

556
@jsonpify
1✔
557
@blueprint.route('/disqus/sso')
1✔
558
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
559
def get_disqus_sso_api():
1✔
560
    """Return remote_auth_s3 and api_key for disqus SSO."""
561
    try:
1✔
562
        if current_user.is_authenticated:
1✔
563
            message, timestamp, sig, pub_key = get_disqus_sso_payload(current_user)
1✔
564
        else:
565
            message, timestamp, sig, pub_key = get_disqus_sso_payload(None)
1✔
566

567
        if message and timestamp and sig and pub_key:
1✔
568
            remote_auth_s3 = "%s %s %s" % (message, sig, timestamp)
1✔
569
            tmp = dict(remote_auth_s3=remote_auth_s3, api_key=pub_key)
1✔
570
            return Response(json.dumps(tmp), mimetype='application/json')
1✔
571
        else:
572
            raise MethodNotAllowed
1✔
573
    except MethodNotAllowed as e:
1✔
574
        e.message = "Disqus keys are missing"
1✔
575
        return error.format_exception(e, target='DISQUS_SSO', action='GET')
1✔
576

577

578
@jsonpify
1✔
579
@csrf.exempt
1✔
580
@blueprint.route('/task/<int:task_id>/canceltask', methods=['POST'])
1✔
581
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
582
def cancel_task(task_id=None):
1✔
583
    """Unlock task upon cancel so that same task can be presented again."""
584
    if not current_user.is_authenticated:
1✔
585
        return abort(401)
1✔
586

587
    data = request.json
1✔
588
    projectname = data.get('projectname', None)
1✔
589
    project = project_repo.get_by_shortname(projectname)
1✔
590
    if not project:
1✔
591
        return abort(400)
1✔
592

593
    user_id = current_user.id
1✔
594
    scheduler, timeout = get_scheduler_and_timeout(project)
1✔
595
    reset_presented_time = project.info.get("reset_presented_time", False)
1✔
596
    if scheduler in (Schedulers.locked, Schedulers.user_pref, Schedulers.task_queue):
1✔
597
        task_locked_by_user = has_lock(task_id, user_id, timeout)
1✔
598
        if task_locked_by_user:
1✔
599
            release_lock(task_id, user_id, timeout)
1✔
600
            current_app.logger.info(
1✔
601
                'Project {} - user {} cancelled task {}'
602
                .format(project.id, current_user.id, task_id))
603
            release_reserve_task_lock_by_id(project.id, task_id, current_user.id, timeout, expiry=EXPIRE_LOCK_DELAY, release_all_task=True)
1✔
604
            # presented time would reset only upon cancel task when reset is
605
            # configured under project settings. add an entry to the cache
606
            # to make note that task has been cancelled. with this, obtaining
607
            # the same task again would reset the presented time.
608
            if reset_presented_time:
1✔
609
                guard = ContributionsGuard(sentinel.master, timeout=timeout)
1✔
610
                user_id_or_ip = get_user_id_or_ip()
1✔
611
                task = task_repo.get_task(task_id)
1✔
612
                guard.stamp_cancelled_time(task, user_id_or_ip)
1✔
613

614
    return Response(json.dumps({'success': True}), 200, mimetype="application/json")
1✔
615

616

617
@jsonpify
1✔
618
@csrf.exempt
1✔
619
@login_required
1✔
620
@blueprint.route('/task/<int:task_id>/release_category_locks', methods=['POST'])
1✔
621
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
622
def release_category_locks(task_id=None):
1✔
623
    """Unlock all category (reservation) locks reserved by this user"""
624
    if not current_user.is_authenticated:
1✔
625
        return abort(401)
1✔
626

627
    project_name = request.json.get('projectname', None)
1✔
628
    project = project_repo.get_by_shortname(project_name)
1✔
629
    if not project:
1✔
630
        return abort(400)
1✔
631

632
    scheduler, timeout = get_scheduler_and_timeout(project)
1✔
633
    if scheduler == Schedulers.task_queue:
1✔
634
        release_reserve_task_lock_by_id(project.id, task_id, current_user.id, timeout, expiry=EXPIRE_LOCK_DELAY, release_all_task=True)
1✔
635

636
    return Response(json.dumps({'success': True}), 200, mimetype="application/json")
1✔
637

638

639
@jsonpify
1✔
640
@blueprint.route('/task/<int:task_id>/lock', methods=['GET'])
1✔
641
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
642
def fetch_lock(task_id):
1✔
643
    """Fetch the time (in seconds) until the current user's
644
    lock on a task expires.
645
    """
646
    if not current_user.is_authenticated:
1✔
647
        return abort(401)
×
648

649
    task = task_repo.get_task(task_id)
1✔
650
    if not task:
1✔
651
        return abort(400)
1✔
652

653
    project = project_repo.get(task.project_id)
1✔
654
    if not project:
1✔
655
        return abort(400)
1✔
656

657
    _, ttl = fetch_lock_for_user(task.project_id, task.id, current_user.id)
1✔
658
    if not ttl:
1✔
659
        return abort(404)
1✔
660

661
    timeout = project.info.get('timeout', ContributionsGuard.STAMP_TTL)
1✔
662

663
    seconds_to_expire = float(ttl) - time()
1✔
664
    lock_time = datetime.utcnow() - timedelta(seconds=timeout-seconds_to_expire)
1✔
665

666
    res = json.dumps({'success': True,
1✔
667
                      'expires': seconds_to_expire,
668
                      'lockTime': lock_time.isoformat()})
669

670
    return Response(res, 200, mimetype='application/json')
1✔
671

672

673
@jsonpify
1✔
674
@csrf.exempt
1✔
675
@blueprint.route('/project/<int:project_id>/taskgold', methods=['GET', 'POST'])
1✔
676
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
677
def task_gold(project_id=None):
1✔
678
    """Make task gold"""
679
    try:
1✔
680
        if not current_user.is_authenticated:
1✔
681
            return abort(401)
1✔
682

683
        project = project_repo.get(project_id)
1✔
684

685
        # Allow project owner, sub-admin co-owners, and admins to update Gold tasks.
686
        is_gold_access = (current_user.subadmin and current_user.id in project.owners_ids) or current_user.admin
1✔
687
        if project is None or not is_gold_access:
1✔
688
            raise Forbidden
1✔
689
        if request.method == 'POST':
1✔
690
            task_data = json.loads(request.form['request_json']) if 'request_json' in request.form else request.json
1✔
691
            task_id = task_data['task_id']
1✔
692
            task = task_repo.get_task(task_id)
1✔
693
            if task.project_id != project_id:
1✔
694
                raise Forbidden
×
695
            preprocess_task_run(project_id, task_id, task_data)
1✔
696
            info = task_data['info']
1✔
697
            set_gold_answers(task, info)
1✔
698
            task_repo.update(task)
1✔
699

700
            response_body = json.dumps({'success': True})
1✔
701
        else:
702
            task = sched.select_task_for_gold_mode(project, current_user.id)
1✔
703
            if task:
1✔
704
                task = task.dictize()
1✔
705
                sign_task(task)
1✔
706
            response_body = json.dumps(task)
1✔
707
        return Response(response_body, 200, mimetype="application/json")
1✔
708
    except Exception as e:
1✔
709
        return error.format_exception(e, target='taskgold', action=request.method)
1✔
710

711

712
@jsonpify
1✔
713
@login_required
1✔
714
@csrf.exempt
1✔
715
@blueprint.route('/task/<task_id>/services/<service_name>/<major_version>/<minor_version>', methods=['POST'])
1✔
716
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
717
def get_service_request(task_id, service_name, major_version, minor_version):
1✔
718
    """Proxy service call"""
719
    proxy_service_config = current_app.config.get('PROXY_SERVICE_CONFIG', None)
1✔
720
    task = task_repo.get_task(task_id)
1✔
721
    project = project_repo.get(task.project_id)
1✔
722

723
    authorized_services_key = current_app.config.get("AUTHORIZED_SERVICES_KEY", None)
1✔
724
    authorized_services = (
1✔
725
        project.info.get("ext_config", {})
726
        .get("authorized_services", {})
727
        .get(authorized_services_key, [])
728
    )
729
    if service_name not in authorized_services:
1✔
730
        return abort(403, "The project is not authorized to access this service")
1✔
731

732
    if not (task and proxy_service_config and service_name and major_version and minor_version):
1✔
733
        return abort(400)
1✔
734

735
    timeout = project.info.get('timeout', ContributionsGuard.STAMP_TTL)
1✔
736
    task_locked_by_user = has_lock(task.id, current_user.id, timeout)
1✔
737
    payload = request.json if isinstance(request.json, dict) else None
1✔
738
    can_create_gold_tasks = (current_user.subadmin and current_user.id in project.owners_ids) or current_user.admin
1✔
739

740
    if payload and (task_locked_by_user or can_create_gold_tasks):
1✔
741
        service = _get_valid_service(task_id, service_name, payload, proxy_service_config)
1✔
742
        if isinstance(service, dict):
1✔
743
            url = '{}/{}/{}/{}'.format(proxy_service_config['uri'], service_name, major_version, minor_version)
1✔
744
            headers = service.get('headers')
1✔
745
            ssl_cert = current_app.config.get('SSL_CERT_PATH', True)
1✔
746
            ret = requests.post(url, headers=headers, json=payload['data'], verify=ssl_cert)
1✔
747
            return Response(ret.content, 200, mimetype="application/json")
1✔
748

749
    current_app.logger.info(
×
750
        'Task id {} with lock-status {} by user {} with this payload {} failed.'
751
        .format(task_id, task_locked_by_user, current_user.id, payload))
752
    return abort(403)
×
753

754

755
def _get_valid_service(task_id, service_name, payload, proxy_service_config):
1✔
756
    service_data = payload.get('data', None)
1✔
757
    service_request = list(service_data.keys())[0] if isinstance(service_data, dict) and \
1✔
758
        len(list(service_data.keys())) == 1 else None
759
    service = proxy_service_config['services'].get(service_name, None)
1✔
760

761
    if service and service_request in service['requests']:
1✔
762
        service_validator = ServiceValidators(service)
1✔
763
        if service_validator.run_validators(service_request, payload):
1✔
764
            return service
1✔
765

766
    current_app.logger.info(
1✔
767
        'Task {} loaded for user {} failed calling {} service with payload {}'.format(task_id, current_user.id, service_name, payload))
768

769
    return abort(403, 'The request data failed validation')
1✔
770

771

772
@jsonpify
1✔
773
@login_required
1✔
774
@csrf.exempt
1✔
775
@blueprint.route('/task/<int:task_id>/assign', methods=['POST'])
1✔
776
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
777
def assign_task(task_id=None):
1✔
778
    """Assign/Un-assign task to users for locked, user_pref and task_queue schedulers."""
779
    projectname = request.json.get('projectname', None)
1✔
780
    unassgin = request.json.get('unassgin', False)
1✔
781
    action = "un-assign" if unassgin else "assign"
1✔
782

783
    a_project = project_repo.get_by_shortname(projectname)
1✔
784
    if not a_project:
1✔
785
        return abort(400, f"Invalid project name {projectname}")
1✔
786

787
    _, ttl = fetch_lock_for_user(a_project.id, task_id, current_user.id)
1✔
788
    if not ttl:
1✔
789
        current_app.logger.warn(
1✔
790
            "User %s requested for %sing task %s that has not been locked by the user.",
791
            current_user.fullname, action, task_id)
792
        return abort(403, f'A lock is required for {action} a task to a user.')
1✔
793

794
    # only assign/un-assign the user to the task for user_pref and task_queue scheduler
795
    scheduler, _ = get_scheduler_and_timeout(a_project)
1✔
796
    if scheduler not in (Schedulers.user_pref, Schedulers.task_queue):
1✔
797
        current_app.logger.warn(
1✔
798
            "Task scheduler for project is set to %s. Cannot %s users %s for task %s",
799
            scheduler, action, current_user.fullname, task_id)
800
        return abort(403, f'Project scheduler not configured for {action}ing a task to a user.')
1✔
801

802
    t = task_repo.get_task_by(project_id=a_project.id, id=int(task_id))
1✔
803
    user_pref = t.user_pref or {}
1✔
804
    assign_user = user_pref.get("assign_user", [])
1✔
805

806
    user_email = current_user.email_addr
1✔
807

808
    if unassgin:  # un-assign the user
1✔
809
        if user_email in assign_user:
1✔
810
            assign_user.remove(user_email)
1✔
811

812
            if assign_user:
1✔
813
                user_pref["assign_user"] = assign_user
1✔
814
            else:  # assign_user list is empty, delete the key "assign_user"
815
                del user_pref["assign_user"]
1✔
816

817
            t.user_pref = user_pref
1✔
818
            flag_modified(t, "user_pref")
1✔
819
            task_repo.update(t)
1✔
820
            current_app.logger.info("User %s un-assigned from task %s",
1✔
821
                                    current_user.fullname, task_id)
822
    else:  # assign the user
823
        if user_email not in assign_user:
1✔
824
            assign_user.append(user_email)
1✔
825
            user_pref["assign_user"] = assign_user
1✔
826
            t.user_pref = user_pref
1✔
827
            flag_modified(t, "user_pref")
1✔
828
            task_repo.update(t)
1✔
829
            current_app.logger.info("User %s assigned to task %s", current_user.fullname, task_id)
1✔
830

831
    return Response(json.dumps({'success': True}), 200, mimetype="application/json")
1✔
832

833

834
@jsonpify
1✔
835
@login_required
1✔
836
@csrf.exempt
1✔
837
@blueprint.route('/project/<short_name>/task/<int:task_id>/partial_answer', methods=['POST', 'GET', 'DELETE'])
1✔
838
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
839
def partial_answer(task_id=None, short_name=None):
1✔
840
    """Save/Get/Delete partial answer to Redis - this API might be called heavily.
841
    Try to avoid PostgreSQL DB calls as much as possible"""
842

843
    # A DB call but with cache
844
    project_id = project_name_to_oid(short_name)
1✔
845

846
    if not project_id:
1✔
847
        return abort(400, f"Invalid project name {short_name}")
1✔
848

849
    response = {'success': True}
1✔
850
    try:
1✔
851
        partial_answer_key = PARTIAL_ANSWER_KEY.format(project_id=project_id,
1✔
852
                                                       user_id=current_user.id,
853
                                                       task_id=task_id)
854
        if request.method == 'POST':
1✔
855
            task_id_map = get_user_saved_partial_tasks(sentinel, project_id, current_user.id, task_repo)
1✔
856
            max_saved_answers = current_app.config.get('MAX_SAVED_ANSWERS', 30)
1✔
857
            if len(task_id_map) >= max_saved_answers:
1✔
858
                return abort(400, f"Saved Tasks Limit Reached. Task Saved to Browser Only.")
1✔
859

860
            ttl = ONE_MONTH
1✔
861
            answer = json.dumps(request.json)
1✔
862
            sentinel.master.setex(partial_answer_key, ttl, answer)
1✔
863
        elif request.method == 'GET':
1✔
864
            data = sentinel.master.get(partial_answer_key)
1✔
865
            response['data'] = json.loads(data.decode('utf-8')) if data else ''
1✔
866
        elif request.method == 'DELETE':
1✔
867
            sentinel.master.delete(partial_answer_key)
1✔
868
    except Exception as e:
1✔
869
        return error.format_exception(e, target='partial_answer', action=request.method)
1✔
870
    return Response(json.dumps(response), status=200, mimetype="application/json")
1✔
871

872

873
@jsonpify
1✔
874
@login_required
1✔
875
@blueprint.route('/project/<short_name>/has_partial_answer')
1✔
876
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
877
def user_has_partial_answer(short_name=None):
1✔
878
    """Check whether the user has any saved partial answer for the project
879
    by checking the Redis sorted set if values are within the non-expiration range
880
    """
881
    if not current_user.is_authenticated:
1✔
882
        return abort(401)
1✔
883

884
    project_id = project_name_to_oid(short_name)
1✔
885
    task_id_map = get_user_saved_partial_tasks(sentinel, project_id, current_user.id, task_repo)
1✔
886
    response = {"has_answer": bool(task_id_map)}
1✔
887
    return Response(json.dumps(response), status=200, mimetype="application/json")
1✔
888

889
def get_prompt_data():
1✔
890
    '''
891
    Get data from request and validate it.
892
    '''
893
    try:
1✔
894
        data = request.get_json(force=True)
1✔
895
    except:
1✔
896
        return abort(400, "Invalid JSON data")
1✔
897

898
    if "prompts" in data:
1✔
899
        prompts = data.get("prompts")
1✔
900
    elif "instances" in data:
1✔
901
        prompts = data.get("instances")
1✔
902
    else:
903
        return abort(400, "The JSON should have either 'prompts' or 'instances'")
1✔
904

905
    if not prompts:
1✔
906
        return abort(400, 'prompts should not be empty')
1✔
907
    if isinstance(prompts, list):
1✔
908
        prompts = prompts[0]  # Batch request temporarily NOT supported
1✔
909
    if not (isinstance(prompts, str) or isinstance(prompts, dict)):
1✔
910
        return abort(400, f'prompts should be a string or a dict')
1✔
911

912
    return prompts
1✔
913

914

915
@jsonpify
1✔
916
@csrf.exempt
1✔
917
@blueprint.route('/llm', defaults={'model_name': None}, methods=['POST'])
1✔
918
@blueprint.route('/llm/<model_name>', methods=['POST'])
1✔
919
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
920
def large_language_model(model_name):
1✔
921
    """Large language model endpoint
922
    The JSON data in the POST request can be one of the following:
923
    {
924
        "model_name": "mistral-nemo-instruct"
925
        "payload":
926
            {
927
            "prompts": [ "Identify the company name: Microsoft will release Windows 20 next year." ]
928
            "params":
929
                {
930
                    "seed": 1234,
931
                    "temperature": 0.9,
932
                    "max_tokens": 128,
933

934
                }
935
            }
936
        "proxies": proxies,
937
        "user_uuid": 30812532,
938
        "project_name": "Inference_Proxy_test",
939
    }
940
    """
941
    available_models = current_app.config.get('LLM_MODEL_NAMES')
1✔
942
    endpoint = current_app.config.get('INFERENCE_ENDPOINT')
1✔
943
    proxies = current_app.config.get('LLM_PROXIES')
1✔
944

945
    if model_name is None:
1✔
946
        model_name = 'mistral-nemo-instruct'
1✔
947

948
    if model_name not in available_models:
1✔
949
        return abort(400, "LLM is unsupported")
1✔
950

951
    prompts = get_prompt_data()
1✔
952

953
    data = {
1✔
954
        "prompts": [prompts.get("context", '')],
955
        "params":
956
            {
957
                "max_tokens": prompts.get("max_new_tokens", 16),
958
                "temperature": prompts.get("temperature", 1.0),
959
                "seed": 12345
960
            }
961
        }
962

963
    headers = {
1✔
964
        "llm-access-token": current_app.config.get('AUTOLAB_ACCESS_TOKEN'),
965
    }
966

967
    body = {
1✔
968
        "model_name": model_name,
969
        "payload": data,
970
        "proxies": proxies,
971
        "user_uuid": current_user.id,
972
        "project_name": request.json.get('projectname', None)
973
    }
974

975
    r = requests.post(url=endpoint, json=body, headers=headers)
1✔
976
    out = json.loads(r.text)
1✔
977
    predictions = out["inference_response"]["predictions"][0]["choices"][0]["text"]
1✔
978
    response = {"Model: ": model_name, "predictions: ": predictions}
1✔
979

980
    return Response(json.dumps(response), status=r.status_code, mimetype="application/json")
1✔
981

982

983
@jsonpify
1✔
984
@blueprint.route('/project/<project_id>/gold_annotations')
1✔
985
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
986
def get_gold_annotations(project_id):
1✔
987
    """Obtain all gold tasks under a given project along with consensus built on their annotations
988
    """
989

990
    if not current_user.is_authenticated:
1✔
991
        return abort(401)
1✔
992

993
    project = project_repo.get(project_id)
1✔
994
    if not current_user.admin and not (current_user.subadmin and current_user.id in project.owners_ids):
1✔
995
        return abort(403)
1✔
996

997
    tasks = project_repo.get_gold_annotations(project_id)
1✔
998
    return Response(json.dumps(tasks), status=200, mimetype="application/json")
1✔
999

1000

1001
@jsonpify
1✔
1002
@blueprint.route('/project/<int:project_id>/projectprogress')
1✔
1003
@blueprint.route('/project/<short_name>/projectprogress')
1✔
1004
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
1✔
1005
def get_project_progress(project_id=None, short_name=None):
1✔
1006
    """View progress of a project. Returns count of total and completed tasks."""
1007

1008
    if current_user.is_anonymous:
1✔
1009
        return abort(401)
1✔
1010

1011
    if not (project_id or short_name):
1✔
1012
        return abort(404)
×
1013
    if short_name:
1✔
1014
        project = project_repo.get_by_shortname(short_name)
1✔
1015
    elif project_id:
1✔
1016
        project = project_repo.get(project_id)
1✔
1017
    if not project:
1✔
1018
        return abort(404)
1✔
1019

1020
    if current_user.admin or (current_user.subadmin and current_user.id in project.owners_ids):
1✔
1021
        response = project_repo.get_total_and_completed_task_count(project.id)
1✔
1022
        return Response(json.dumps(response), status=200, mimetype="application/json")
1✔
1023
    else:
1024
        return abort(403)
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