Verified Commit bf102b33 authored by Peter Stanko's avatar Peter Stanko
Browse files

Submission create check - test + fixes

parent a6bd7ff8
Pipeline #14175 failed with stage
in 7 minutes and 58 seconds
......@@ -107,10 +107,9 @@ class DataManagement(object):
course = self.rest.find.course(course)
role = self.creator.scaffold_project(course, name)
self.db.session.commit()
log.debug(f'[DATA] Created role: {role.log_name}')
log.debug(f'[DATA] Created project: {role.log_name}')
return role
def create_role(self, course_name: str, role_type: str, name: str) -> Role:
"""Creates role in the course based on type
Args:
......
......@@ -4,12 +4,14 @@ Factory to create sample data
import random
import string
from datetime import timedelta
from flask_sqlalchemy import SQLAlchemy
from portal import logger
from portal.database.models import Course, Group, Project, ReviewItem, Role, Submission, User, \
Worker
from portal.tools import time
log = logger.get_logger(__name__)
......@@ -176,5 +178,18 @@ class DataFactory(object):
permissions=permissions)
return role
def scaffold_project(self, course, name):
return self.create_project(course=course, name=name, codename=name)
def scaffold_project(self, course: Course, name: str):
project_config = dict(
file_whitelist="*.*",
test_files_source='https://gitlab.fi.muni.cz/grp-kontr2/testing/hello-test-files',
test_files_subdir='',
pre_submit_script="python for kontr pre",
post_submit_script="python for kontr post",
submission_scheduler_config="python for sub Q",
submission_parameters="{\"type\":\"text\"}",
submissions_allowed_from=time.current_time() - timedelta(days=1),
submissions_allowed_to=time.current_time() + timedelta(days=10),
archive_from=time.current_time() + timedelta(days=30)
)
return self.create_project(
course=course, name=name, config=project_config)
"""
A collection of Mixins specifying common behaviour and attributes of database entities.
"""
import datetime
from sqlalchemy.ext.hybrid import hybrid_property
from portal import db
......
......@@ -3,6 +3,7 @@ from flask_jwt_extended import jwt_required
from flask_restplus import Namespace
from portal import logger
from portal.database.models import ClientType
from portal.rest import rest_helpers
from portal.rest.custom_resource import CustomResource
from portal.rest.schemas import SCHEMAS
......@@ -170,7 +171,10 @@ class ProjectSubmissions(CustomResource):
# check if a new submission can be created by this user in this project
project = self.find.project(course, pid)
self.rest.projects(project).check_submission_create(client=user)
if client.type == ClientType.USER:
client_instance = self.find.user(client.id)
if not client_instance.is_admin:
self.rest.projects(project).check_submission_create(client=user)
data = rest_helpers.parse_request_data(
schema=SCHEMAS.submission_create, action='create', resource='submission'
......@@ -179,11 +183,7 @@ class ProjectSubmissions(CustomResource):
# data for Kontr processing
service = self.rest.submissions()
new_submission = service.create_submission(
user=user,
project=project,
submission_params=data
)
new_submission = service.create(user=user,project=project,submission_params=data)
return SCHEMAS.dump('submission', new_submission), 201
......@@ -201,7 +201,3 @@ class ProjectTestFiles(CustomResource):
service = self.rest.storage(project=project)
storage_entity = service.get_test_files_entity_from_storage()
return service.send_file_or_zip(storage_entity)
......@@ -4,6 +4,8 @@ Errors in the service layer
import json
from typing import Union
from portal.database import Project, User
class PortalError(Exception):
"""Base exception class
......@@ -96,7 +98,7 @@ class ResourceNotFoundError(PortalAPIError):
class UnauthorizedError(PortalAPIError):
def __init__(self, note=None):
message = dict(message=f"You are not authorized.",)
message = dict(message=f"You are not authorized.", )
if note:
message['note'] = note
......@@ -144,3 +146,12 @@ class SubmissionRefusedError(PortalAPIError):
class WorkerNotAvailable(PortalError):
def __init__(self, message: str = None):
self.message = message or "Worker is not available"
class SubmissionDiffTimeError(PortalAPIError):
def __init__(self, project: Project, diff_time, user: User = None):
log_user = f'by {user.log_name} ' if user else ''
message = f"Submission cannot be created for {project.log_name}, {log_user}" \
f"you can create submission in {diff_time}."
super().__init__(code=429, message=message)
"""
Projects service
"""
import datetime
import logging
from datetime import timedelta
from typing import List
from flask_sqlalchemy import BaseQuery
from portal import storage
from portal.async_celery import tasks
from portal.database.models import ClientType, Course, Project, ProjectConfig, ProjectState, \
Submission, SubmissionState, User
......@@ -14,7 +16,6 @@ from portal.service import errors, filters
from portal.service.errors import ForbiddenError
from portal.service.general import GeneralService, get_new_name
from portal.tools import time
from portal import storage
log = logging.getLogger(__name__)
......@@ -130,16 +131,40 @@ class ProjectService(GeneralService):
def check_submission_create(self, client=None):
client = client or self.client
if client.type == ClientType.USER:
user = self.find.user(client.id)
# TODO: Might be good to add check whether it is in DEBUG Mode
if user.is_admin:
return True
if client.type != ClientType.USER:
raise errors.SubmissionRefusedError(f"Worker cannot create submission.")
user = self.find.user(client.id)
# TODO: Might be good to add check whether it is in DEBUG Mode
if user.is_admin:
return True
if self.project.state(timestamp=time.current_time()) != ProjectState.ACTIVE:
raise errors.SubmissionRefusedError(f"Project {self.project.name} not active.")
raise errors.SubmissionRefusedError(f"Project {self.project.log_name} not active.")
if not ProjectService(self.project).can_create_submission(client):
raise errors.SubmissionRefusedError(
f"Submission in project {self.project.name} already active.")
f"Submission in project {self.project.log_name} already active.")
self._check_latest_submission(user)
return True
def _check_latest_submission(self, user: User):
latest_submission = self.get_latest_submission(user)
if not latest_submission:
return True
latest_time = latest_submission.created_at
now_time = datetime.datetime.utcnow()
diff_time = now_time - latest_time
delta = timedelta(minutes=30) # TODO this should be configurable @mdujava
log.info(f"Submission for project {self.project.log_name} by user {user.log_name},"
f" time delta: {diff_time} < {delta} = {diff_time < delta}")
if diff_time < delta:
log.debug(f"Submission for {self.project.log_name} by {user.log_name} "
f"rejected based on the time delta: {diff_time}")
raise errors.SubmissionDiffTimeError(self.project, diff_time, user=user)
return True
def get_latest_submission(self, user) -> Submission:
return Submission.query.filter((Submission.user == user) &
(Submission.project == self.project)) \
.order_by(Submission.created_at.desc()).first()
def find_all(self, course: Course) -> list:
"""List of all projects
......@@ -154,7 +179,8 @@ class ProjectService(GeneralService):
return filters.filter_projects_from_course(course=course, user=perm_service.client)
raise ForbiddenError(perm_service.client)
def update_project_test_files(self):
""" Sends a request to Storage to update the project's test_files to the newest version.
"""
tasks.update_project_test_files.delay(self.project.course.id, self.project.id)
\ No newline at end of file
def update_project_test_files(self):
""" Sends a request to Storage to update the project's test_files to the newest version.
"""
tasks.update_project_test_files.delay(self.project.course.id, self.project.id)
......@@ -3,7 +3,6 @@ Submissions service
"""
import logging
import time
from pathlib import Path
from typing import List, Union
......@@ -50,7 +49,7 @@ class SubmissionsService(GeneralService):
allowed = []
return self.update_entity(entity, data, allowed=allowed)
def create_submission(self, user: User, project: Project, submission_params: dict):
def create(self, user: User, project: Project, submission_params: dict):
"""Creates a new submission in the database and downloads its files.
Zip and git sources are processed differently.
......
import json
import json as json_parser
from datetime import datetime
import flask
from flask import Response
from flask.testing import FlaskClient
......@@ -8,11 +10,24 @@ from portal import logger
from portal.database.models import Course, Group, Project, ProjectConfig, Review, ReviewItem, Role, \
Submission, User, Worker
DEFAULT_USER_CREDENTIALS = json.dumps({
"type": "username_password",
"identifier": "admin",
"secret": "789789"
})
def get_user_credentials(username, password='123456'):
return json.dumps({
"type": "username_password",
"identifier": username,
"secret": password
})
def get_user_secret(username, secret):
return json.dumps({
"type": "secret",
"identifier": username,
"secret": secret
})
DEFAULT_USER_CREDENTIALS = get_user_credentials('admin', '789789')
log = logger.get_logger(__name__)
......@@ -26,7 +41,8 @@ def extract_data(response: Response) -> dict:
def make_request(client: FlaskClient, url: str, method: str = 'get',
credentials: str = None, headers: dict = None, data: str = None) -> Response:
credentials: str = None, headers: dict = None,
data: str = None, json: dict = None) -> Response:
""" Creates an authenticated request to an endpoint.
Args:
......@@ -47,6 +63,8 @@ def make_request(client: FlaskClient, url: str, method: str = 'get',
full_url = API_PREFIX + url
log.debug(f"[REQ] ({method}) - \"{full_url}\"")
request_method = getattr(client, method)
if json is not None and data is None:
data = json_parser.dumps(json)
return request_method(full_url, headers=headers,
data=data, follow_redirects=True)
......@@ -55,6 +73,7 @@ def __get_access_token(client, credentials):
log.debug(f"[REQ] Login credentials: {credentials}")
login_resp = client.post(f"{API_PREFIX}/auth/login", data=credentials,
headers={"content-type": "application/json"})
log.debug(f"[RESP] {login_resp}")
resp_data = json.loads(str(login_resp.get_data().decode("utf-8")))
return resp_data['access_token']
......@@ -259,3 +278,8 @@ class DateTimeEncoder(json.JSONEncoder):
return o.isoformat()
return json.JSONEncoder.default(self, o)
def assert_response(response: flask.Response, code=200, content_type='application/json'):
assert response.status_code == code
assert response.mimetype == content_type
......@@ -3,7 +3,7 @@ import json
import pytest
from portal.database.models import Client, Secret
from tests.rest import utils
from tests.rest import rest_tools
@pytest.fixture
......@@ -26,7 +26,7 @@ def worker_credentials():
def test_client_detail_using_student_cred(client, student_credentials):
path = f'/client'
response = utils.make_request(
response = rest_tools.make_request(
client, path, 'get', credentials=student_credentials)
assert response.status_code == 200
assert response.mimetype == 'application/json'
......@@ -37,7 +37,7 @@ def test_client_detail_using_student_cred(client, student_credentials):
def test_client_detail_using_executor_cred(client, worker_credentials):
path = f'/client'
response = utils.make_request(
response = rest_tools.make_request(
client, path, 'get', credentials=worker_credentials)
assert response.status_code == 200
assert response.mimetype == 'application/json'
......@@ -49,10 +49,10 @@ def test_client_detail_using_executor_cred(client, worker_credentials):
def test_create_secret(client):
instance = Client.query.filter_by(codename="executor").first()
request_dict = dict(name="new_secret")
response = utils.make_request(client, f'/clients/{instance.id}/secrets',
headers={"content-type": "application/json"},
data=json.dumps(request_dict),
method='post')
response = rest_tools.make_request(client, f'/clients/{instance.id}/secrets',
headers={"content-type": "application/json"},
data=json.dumps(request_dict),
method='post')
assert response.status_code == 201
assert response.mimetype == 'application/json'
......@@ -67,22 +67,22 @@ def test_create_secret(client):
def test_list_secret(client):
instance = Client.query.filter_by(codename="executor").first()
response = utils.make_request(client, f'/clients/{instance.id}/secrets',
headers={"content-type": "application/json"},
method='get')
response = rest_tools.make_request(client, f'/clients/{instance.id}/secrets',
headers={"content-type": "application/json"},
method='get')
assert response.status_code == 200
assert response.mimetype == 'application/json'
secrets = utils.extract_data(response)
secrets = rest_tools.extract_data(response)
assert len(secrets) == 1
def test_delete_secret(client):
worker = Client.query.filter_by(codename="executor").first()
secret = Secret.query.filter_by(client=worker).first()
response = utils.make_request(client, f'/clients/{worker.id}/secrets/{secret.id}',
headers={"content-type": "application/json"},
method='delete')
response = rest_tools.make_request(client, f'/clients/{worker.id}/secrets/{secret.id}',
headers={"content-type": "application/json"},
method='delete')
assert response.status_code == 204
......@@ -92,13 +92,13 @@ def test_delete_secret(client):
def test_read_secret(client):
instance = Client.query.filter_by(codename="executor").first()
secret = Secret.query.filter_by(client=instance).first()
response = utils.make_request(client, f'/clients/{instance.id}/secrets/{secret.id}',
headers={"content-type": "application/json"},
method='get')
response = rest_tools.make_request(client, f'/clients/{instance.id}/secrets/{secret.id}',
headers={"content-type": "application/json"},
method='get')
assert response.status_code == 200
assert response.mimetype == 'application/json'
response_secret = utils.extract_data(response)
response_secret = rest_tools.extract_data(response)
assert response_secret['id'] == secret.id
assert response_secret['name'] == secret.name
......@@ -109,10 +109,10 @@ def test_update_secret(client):
instance = Client.query.filter_by(codename="executor").first()
request_dict = dict(name="new_name")
secret = Secret.query.filter_by(client=instance).first()
response = utils.make_request(client, f'/clients/{instance.id}/secrets/{secret.id}',
headers={"content-type": "application/json"},
data=json.dumps(request_dict),
method='put')
response = rest_tools.make_request(client, f'/clients/{instance.id}/secrets/{secret.id}',
headers={"content-type": "application/json"},
data=json.dumps(request_dict),
method='put')
assert response.status_code == 204
updated_secret = Secret.query.filter_by(client=instance).first()
......
......@@ -3,19 +3,19 @@ import json
from portal.database.models import Course
from portal.service import courses, general
from portal.service.courses import CourseService
from . import utils
from . import rest_tools
def test_list(client):
response = utils.make_request(client, '/courses', method='get')
response = rest_tools.make_request(client, '/courses', method='get')
assert response.status_code == 200
assert response.mimetype == 'application/json'
courses_response = utils.extract_data(response)
courses_response = rest_tools.extract_data(response)
assert len(courses_response) == 2
db_courses = Course.query.all()
utils.assert_course_in(db_courses, courses_response[0])
utils.assert_course_in(db_courses, courses_response[1])
rest_tools.assert_course_in(db_courses, courses_response[0])
rest_tools.assert_course_in(db_courses, courses_response[1])
def test_create(client):
......@@ -26,16 +26,16 @@ def test_create(client):
db_courses_number = len(Course.query.all())
request_json = json.dumps(request_dict)
response = utils.make_request(client, '/courses', data=request_json,
headers={"content-type": "application/json"},
method='post')
response = rest_tools.make_request(client, '/courses', data=request_json,
headers={"content-type": "application/json"},
method='post')
assert response.status_code == 201
assert response.mimetype == 'application/json'
resp_new_course = utils.extract_data(response)
resp_new_course = rest_tools.extract_data(response)
db_courses = Course.query.all()
assert len(db_courses) == db_courses_number + 1
utils.assert_course_in(db_courses, resp_new_course)
rest_tools.assert_course_in(db_courses, resp_new_course)
def test_create_extra_key(client):
......@@ -47,16 +47,16 @@ def test_create_extra_key(client):
db_courses_number = len(Course.query.all())
request_json = json.dumps(request_dict)
response = utils.make_request(client, '/courses', data=request_json,
headers={"content-type": "application/json"},
method='post')
response = rest_tools.make_request(client, '/courses', data=request_json,
headers={"content-type": "application/json"},
method='post')
assert response.status_code == 201
assert response.mimetype == 'application/json'
resp_new_course = utils.extract_data(response)
resp_new_course = rest_tools.extract_data(response)
db_courses = Course.query.all()
assert len(db_courses) == db_courses_number + 1
utils.assert_course_in(db_courses, resp_new_course)
rest_tools.assert_course_in(db_courses, resp_new_course)
def test_create_missing_name(client):
......@@ -64,9 +64,9 @@ def test_create_missing_name(client):
db_courses_number = len(Course.query.all())
request_json = json.dumps(request_dict)
response = utils.make_request(client, '/courses', data=request_json,
headers={"content-type": "application/json"},
method='post')
response = rest_tools.make_request(client, '/courses', data=request_json,
headers={"content-type": "application/json"},
method='post')
assert response.status_code == 400
assert response.mimetype == 'application/json'
assert len(Course.query.all()) == db_courses_number
......@@ -80,9 +80,9 @@ def test_create_invalid_value(client):
db_courses_number = len(Course.query.all())
request_json = json.dumps(request_dict)
response = utils.make_request(client, '/courses', data=request_json,
headers={"content-type": "application/json"},
method='post')
response = rest_tools.make_request(client, '/courses', data=request_json,
headers={"content-type": "application/json"},
method='post')
assert response.status_code == 400
assert response.mimetype == 'application/json'
assert len(Course.query.all()) == db_courses_number
......@@ -90,13 +90,13 @@ def test_create_invalid_value(client):
def test_read(client):
c = Course.query.filter_by(codename="testcourse1").first()
response = utils.make_request(client, f"/courses/{c.id}", method='get')
response = rest_tools.make_request(client, f"/courses/{c.id}", method='get')
assert response.status_code == 200
assert response.mimetype == 'application/json'
course = utils.extract_data(response)
course = rest_tools.extract_data(response)
utils.assert_course(c, course)
rest_tools.assert_course(c, course)
def test_update(client):
......@@ -106,8 +106,8 @@ def test_update(client):
codename="testcourse1_new",
)
request_json = json.dumps(request_dict)
response = utils.make_request(client, f'/courses/{c.id}', data=request_json,
headers={"content-type": "application/json"}, method='put')
response = rest_tools.make_request(client, f'/courses/{c.id}', data=request_json,
headers={"content-type": "application/json"}, method='put')
assert response.status_code == 204
assert response.mimetype == 'application/json'
......@@ -119,9 +119,9 @@ def test_update(client):
def test_delete(client):
c = Course.query.filter_by(codename="testcourse2").first()
response = utils.make_request(client, f'/courses/{c.id}',
headers={"content-type": "application/json"},
method='delete')
response = rest_tools.make_request(client, f'/courses/{c.id}',
headers={"content-type": "application/json"},
method='delete')
assert response.status_code == 204
assert response.mimetype == 'application/json'
......@@ -131,20 +131,20 @@ def test_delete(client):
def test_notes_token_read(client):
c = Course.query.filter_by(codename="testcourse2").first()
response = utils.make_request(
response = rest_tools.make_request(
client, f"/courses/{c.id}/notes_access_token", method='get')
assert response.status_code == 200
assert response.mimetype == 'application/json'
data = utils.extract_data(response)
data = rest_tools.extract_data(response)
assert data == "testcourse2_token"
def test_notes_token_create(client):
c = Course.query.filter_by(codename="testcourse2").first()
response = utils.make_request(client, f"/courses/{c.id}/notes_access_token",
data=json.dumps({"token": "new_token"}),
headers={"content-type": "application/json"}, method='put')
response = rest_tools.make_request(client, f"/courses/{c.id}/notes_access_token",
data=json.dumps({"token": "new_token"}),
headers={"content-type": "application/json"}, method='put')
assert response.status_code == 204
assert response.mimetype == 'application/json'
......@@ -154,9 +154,9 @@ def test_notes_token_create(client):
def test_notes_token_update(client):
c = Course.query.filter_by(codename="testcourse1").first()
response = utils.make_request(client, f"/courses/{c.id}/notes_access_token",
data=json.dumps({"token": "new_token"}),
headers={"content-type": "application/json"}, method='put')
response = rest_tools.make_request(client, f"/courses/{c.id}/notes_access_token",
data=json.dumps({"token": "new_token"}),
headers={"content-type": "application/json"}, method='put')
assert response.status_code == 204
assert response.mimetype == 'application/json'
......@@ -177,10 +177,10 @@ def test_copy_from_course(rest_service, client):
}
request_json = json.dumps(request_dict)
response = utils.make_request(client, f'/courses/{target.codename}/import',
data=request_json,
headers={"content-type": "application/json"},
method='put')
response = rest_tools.make_request(client, f'/courses/{target.codename}/import',
data=request_json,
headers={"content-type": "application/json"},
method='put')
assert response.status_code == 200
assert response.mimetype == "application/json"
......
import json
from portal.database.models import Course, Group, Role, User
from . import utils