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): ...@@ -107,10 +107,9 @@ class DataManagement(object):
course = self.rest.find.course(course) course = self.rest.find.course(course)
role = self.creator.scaffold_project(course, name) role = self.creator.scaffold_project(course, name)
self.db.session.commit() self.db.session.commit()
log.debug(f'[DATA] Created role: {role.log_name}') log.debug(f'[DATA] Created project: {role.log_name}')
return role return role
def create_role(self, course_name: str, role_type: str, name: str) -> Role: def create_role(self, course_name: str, role_type: str, name: str) -> Role:
"""Creates role in the course based on type """Creates role in the course based on type
Args: Args:
......
...@@ -4,12 +4,14 @@ Factory to create sample data ...@@ -4,12 +4,14 @@ Factory to create sample data
import random import random
import string import string
from datetime import timedelta
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from portal import logger from portal import logger
from portal.database.models import Course, Group, Project, ReviewItem, Role, Submission, User, \ from portal.database.models import Course, Group, Project, ReviewItem, Role, Submission, User, \
Worker Worker
from portal.tools import time
log = logger.get_logger(__name__) log = logger.get_logger(__name__)
...@@ -176,5 +178,18 @@ class DataFactory(object): ...@@ -176,5 +178,18 @@ class DataFactory(object):
permissions=permissions) permissions=permissions)
return role return role
def scaffold_project(self, course, name): def scaffold_project(self, course: Course, name: str):
return self.create_project(course=course, name=name, codename=name) 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. A collection of Mixins specifying common behaviour and attributes of database entities.
""" """
import datetime
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from portal import db from portal import db
......
...@@ -3,6 +3,7 @@ from flask_jwt_extended import jwt_required ...@@ -3,6 +3,7 @@ from flask_jwt_extended import jwt_required
from flask_restplus import Namespace from flask_restplus import Namespace
from portal import logger from portal import logger
from portal.database.models import ClientType
from portal.rest import rest_helpers from portal.rest import rest_helpers
from portal.rest.custom_resource import CustomResource from portal.rest.custom_resource import CustomResource
from portal.rest.schemas import SCHEMAS from portal.rest.schemas import SCHEMAS
...@@ -170,7 +171,10 @@ class ProjectSubmissions(CustomResource): ...@@ -170,7 +171,10 @@ class ProjectSubmissions(CustomResource):
# check if a new submission can be created by this user in this project # check if a new submission can be created by this user in this project
project = self.find.project(course, pid) 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( data = rest_helpers.parse_request_data(
schema=SCHEMAS.submission_create, action='create', resource='submission' schema=SCHEMAS.submission_create, action='create', resource='submission'
...@@ -179,11 +183,7 @@ class ProjectSubmissions(CustomResource): ...@@ -179,11 +183,7 @@ class ProjectSubmissions(CustomResource):
# data for Kontr processing # data for Kontr processing
service = self.rest.submissions() service = self.rest.submissions()
new_submission = service.create_submission( new_submission = service.create(user=user,project=project,submission_params=data)
user=user,
project=project,
submission_params=data
)
return SCHEMAS.dump('submission', new_submission), 201 return SCHEMAS.dump('submission', new_submission), 201
...@@ -201,7 +201,3 @@ class ProjectTestFiles(CustomResource): ...@@ -201,7 +201,3 @@ class ProjectTestFiles(CustomResource):
service = self.rest.storage(project=project) service = self.rest.storage(project=project)
storage_entity = service.get_test_files_entity_from_storage() storage_entity = service.get_test_files_entity_from_storage()
return service.send_file_or_zip(storage_entity) return service.send_file_or_zip(storage_entity)
...@@ -4,6 +4,8 @@ Errors in the service layer ...@@ -4,6 +4,8 @@ Errors in the service layer
import json import json
from typing import Union from typing import Union
from portal.database import Project, User
class PortalError(Exception): class PortalError(Exception):
"""Base exception class """Base exception class
...@@ -96,7 +98,7 @@ class ResourceNotFoundError(PortalAPIError): ...@@ -96,7 +98,7 @@ class ResourceNotFoundError(PortalAPIError):
class UnauthorizedError(PortalAPIError): class UnauthorizedError(PortalAPIError):
def __init__(self, note=None): def __init__(self, note=None):
message = dict(message=f"You are not authorized.",) message = dict(message=f"You are not authorized.", )
if note: if note:
message['note'] = note message['note'] = note
...@@ -144,3 +146,12 @@ class SubmissionRefusedError(PortalAPIError): ...@@ -144,3 +146,12 @@ class SubmissionRefusedError(PortalAPIError):
class WorkerNotAvailable(PortalError): class WorkerNotAvailable(PortalError):
def __init__(self, message: str = None): def __init__(self, message: str = None):
self.message = message or "Worker is not available" 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 Projects service
""" """
import datetime
import logging import logging
from datetime import timedelta
from typing import List from typing import List
from flask_sqlalchemy import BaseQuery from flask_sqlalchemy import BaseQuery
from portal import storage
from portal.async_celery import tasks from portal.async_celery import tasks
from portal.database.models import ClientType, Course, Project, ProjectConfig, ProjectState, \ from portal.database.models import ClientType, Course, Project, ProjectConfig, ProjectState, \
Submission, SubmissionState, User Submission, SubmissionState, User
...@@ -14,7 +16,6 @@ from portal.service import errors, filters ...@@ -14,7 +16,6 @@ from portal.service import errors, filters
from portal.service.errors import ForbiddenError from portal.service.errors import ForbiddenError
from portal.service.general import GeneralService, get_new_name from portal.service.general import GeneralService, get_new_name
from portal.tools import time from portal.tools import time
from portal import storage
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -130,16 +131,40 @@ class ProjectService(GeneralService): ...@@ -130,16 +131,40 @@ class ProjectService(GeneralService):
def check_submission_create(self, client=None): def check_submission_create(self, client=None):
client = client or self.client client = client or self.client
if client.type == ClientType.USER: if client.type != ClientType.USER:
user = self.find.user(client.id) raise errors.SubmissionRefusedError(f"Worker cannot create submission.")
# TODO: Might be good to add check whether it is in DEBUG Mode user = self.find.user(client.id)
if user.is_admin: # TODO: Might be good to add check whether it is in DEBUG Mode
return True if user.is_admin:
return True
if self.project.state(timestamp=time.current_time()) != ProjectState.ACTIVE: 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): if not ProjectService(self.project).can_create_submission(client):
raise errors.SubmissionRefusedError( 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: def find_all(self, course: Course) -> list:
"""List of all projects """List of all projects
...@@ -154,7 +179,8 @@ class ProjectService(GeneralService): ...@@ -154,7 +179,8 @@ class ProjectService(GeneralService):
return filters.filter_projects_from_course(course=course, user=perm_service.client) return filters.filter_projects_from_course(course=course, user=perm_service.client)
raise ForbiddenError(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. 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 tasks.update_project_test_files.delay(self.project.course.id, self.project.id)
...@@ -3,7 +3,6 @@ Submissions service ...@@ -3,7 +3,6 @@ Submissions service
""" """
import logging import logging
import time
from pathlib import Path from pathlib import Path
from typing import List, Union from typing import List, Union
...@@ -50,7 +49,7 @@ class SubmissionsService(GeneralService): ...@@ -50,7 +49,7 @@ class SubmissionsService(GeneralService):
allowed = [] allowed = []
return self.update_entity(entity, data, allowed=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. """Creates a new submission in the database and downloads its files.
Zip and git sources are processed differently. Zip and git sources are processed differently.
......
import json import json
import json as json_parser
from datetime import datetime from datetime import datetime
import flask
from flask import Response from flask import Response
from flask.testing import FlaskClient from flask.testing import FlaskClient
...@@ -8,11 +10,24 @@ from portal import logger ...@@ -8,11 +10,24 @@ from portal import logger
from portal.database.models import Course, Group, Project, ProjectConfig, Review, ReviewItem, Role, \ from portal.database.models import Course, Group, Project, ProjectConfig, Review, ReviewItem, Role, \
Submission, User, Worker Submission, User, Worker
DEFAULT_USER_CREDENTIALS = json.dumps({
"type": "username_password", def get_user_credentials(username, password='123456'):
"identifier": "admin", return json.dumps({
"secret": "789789" "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__) log = logger.get_logger(__name__)
...@@ -26,7 +41,8 @@ def extract_data(response: Response) -> dict: ...@@ -26,7 +41,8 @@ def extract_data(response: Response) -> dict:
def make_request(client: FlaskClient, url: str, method: str = 'get', 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. """ Creates an authenticated request to an endpoint.
Args: Args:
...@@ -47,6 +63,8 @@ def make_request(client: FlaskClient, url: str, method: str = 'get', ...@@ -47,6 +63,8 @@ def make_request(client: FlaskClient, url: str, method: str = 'get',
full_url = API_PREFIX + url full_url = API_PREFIX + url
log.debug(f"[REQ] ({method}) - \"{full_url}\"") log.debug(f"[REQ] ({method}) - \"{full_url}\"")
request_method = getattr(client, method) 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, return request_method(full_url, headers=headers,
data=data, follow_redirects=True) data=data, follow_redirects=True)
...@@ -55,6 +73,7 @@ def __get_access_token(client, credentials): ...@@ -55,6 +73,7 @@ def __get_access_token(client, credentials):
log.debug(f"[REQ] Login credentials: {credentials}") log.debug(f"[REQ] Login credentials: {credentials}")
login_resp = client.post(f"{API_PREFIX}/auth/login", data=credentials, login_resp = client.post(f"{API_PREFIX}/auth/login", data=credentials,
headers={"content-type": "application/json"}) headers={"content-type": "application/json"})
log.debug(f"[RESP] {login_resp}")
resp_data = json.loads(str(login_resp.get_data().decode("utf-8"))) resp_data = json.loads(str(login_resp.get_data().decode("utf-8")))
return resp_data['access_token'] return resp_data['access_token']
...@@ -259,3 +278,8 @@ class DateTimeEncoder(json.JSONEncoder): ...@@ -259,3 +278,8 @@ class DateTimeEncoder(json.JSONEncoder):
return o.isoformat() return o.isoformat()
return json.JSONEncoder.default(self, o) 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 ...@@ -3,7 +3,7 @@ import json
import pytest import pytest
from portal.database.models import Client, Secret from portal.database.models import Client, Secret
from tests.rest import utils from tests.rest import rest_tools
@pytest.fixture @pytest.fixture
...@@ -26,7 +26,7 @@ def worker_credentials(): ...@@ -26,7 +26,7 @@ def worker_credentials():
def test_client_detail_using_student_cred(client, student_credentials): def test_client_detail_using_student_cred(client, student_credentials):
path = f'/client' path = f'/client'
response = utils.make_request( response = rest_tools.make_request(
client, path, 'get', credentials=student_credentials) client, path, 'get', credentials=student_credentials)
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'application/json' assert response.mimetype == 'application/json'
...@@ -37,7 +37,7 @@ def test_client_detail_using_student_cred(client, student_credentials): ...@@ -37,7 +37,7 @@ def test_client_detail_using_student_cred(client, student_credentials):
def test_client_detail_using_executor_cred(client, worker_credentials): def test_client_detail_using_executor_cred(client, worker_credentials):
path = f'/client' path = f'/client'
response = utils.make_request( response = rest_tools.make_request(
client, path, 'get', credentials=worker_credentials) client, path, 'get', credentials=worker_credentials)
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'application/json' assert response.mimetype == 'application/json'
...@@ -49,10 +49,10 @@ def test_client_detail_using_executor_cred(client, worker_credentials): ...@@ -49,10 +49,10 @@ def test_client_detail_using_executor_cred(client, worker_credentials):
def test_create_secret(client): def test_create_secret(client):
instance = Client.query.filter_by(codename="executor").first() instance = Client.query.filter_by(codename="executor").first()
request_dict = dict(name="new_secret") request_dict = dict(name="new_secret")
response = utils.make_request(client, f'/clients/{instance.id}/secrets', response = rest_tools.make_request(client, f'/clients/{instance.id}/secrets',
headers={"content-type": "application/json"}, headers={"content-type": "application/json"},
data=json.dumps(request_dict), data=json.dumps(request_dict),
method='post') method='post')
assert response.status_code == 201 assert response.status_code == 201
assert response.mimetype == 'application/json' assert response.mimetype == 'application/json'
...@@ -67,22 +67,22 @@ def test_create_secret(client): ...@@ -67,22 +67,22 @@ def test_create_secret(client):
def test_list_secret(client): def test_list_secret(client):
instance = Client.query.filter_by(codename="executor").first() instance = Client.query.filter_by(codename="executor").first()
response = utils.make_request(client, f'/clients/{instance.id}/secrets', response = rest_tools.make_request(client, f'/clients/{instance.id}/secrets',
headers={"content-type": "application/json"}, headers={"content-type": "application/json"},
method='get') method='get')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'application/json' assert response.mimetype == 'application/json'
secrets = utils.extract_data(response) secrets = rest_tools.extract_data(response)
assert len(secrets) == 1 assert len(secrets) == 1
def test_delete_secret(client): def test_delete_secret(client):
worker = Client.query.filter_by(codename="executor").first() worker = Client.query.filter_by(codename="executor").first()
secret = Secret.query.filter_by(client=worker).first() secret = Secret.query.filter_by(client=worker).first()
response = utils.make_request(client, f'/clients/{worker.id}/secrets/{secret.id}', response = rest_tools.make_request(client, f'/clients/{worker.id}/secrets/{secret.id}',
headers={"content-type": "application/json"}, headers={"content-type": "application/json"},
method='delete') method='delete')
assert response.status_code == 204 assert response.status_code == 204
...@@ -92,13 +92,13 @@ def test_delete_secret(client): ...@@ -92,13 +92,13 @@ def test_delete_secret(client):
def test_read_secret(client): def test_read_secret(client):
instance = Client.query.filter_by(codename="executor").first() instance = Client.query.filter_by(codename="executor").first()
secret = Secret.query.filter_by(client=instance).first() secret = Secret.query.filter_by(client=instance).first()
response = utils.make_request(client, f'/clients/{instance.id}/secrets/{secret.id}', response = rest_tools.make_request(client, f'/clients/{instance.id}/secrets/{secret.id}',
headers={"content-type": "application/json"}, headers={"content-type": "application/json"},
method='get') method='get')
assert response.status_code == 200 assert response.status_code == 200
assert response.mimetype == 'application/json' 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['id'] == secret.id
assert response_secret['name'] == secret.name assert response_secret['name'] == secret.name
...@@ -109,10 +109,10 @@ def test_update_secret(client): ...@@ -109,10 +109,10 @@ def test_update_secret(client):
instance = Client.query.filter_by(codename="executor").first() instance = Client.query.filter_by(codename="executor").first()
request_dict = dict(name="new_name") request_dict = dict(name="new_name")
secret = Secret.query.filter_by(client=instance).first() secret = Secret.query.filter_by(client=instance).first()
response = utils.make_request(client, f'/clients/{instance.id}/secrets/{secret.id}', response = rest_tools.make_request(client, f'/clients/{instance.id}/secrets/{secret.id}',
headers={"content-type": "application/json"}, headers={"content-type": "application/json"},
data=json.dumps(request_dict), data=json.dumps(request_dict),
method='put') method='put')
assert response.status_code == 204 assert response.status_code == 204
updated_secret = Secret.query.filter_by(client=instance).first() updated_secret = Secret.query.filter_by(client=instance).first()
......
...@@ -3,19 +3,19 @@ import json ...@@ -3,19 +3,19 @@ import json
from portal.database.models import Course from portal.database.models import Course
from portal.service import courses, general from portal.service import courses, general
from portal.service.courses import CourseService from portal.service.courses import CourseService
from . import utils from . import rest_tools
def test_list(client): 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.status_code == 200
assert response.mimetype == 'application/json' assert response.mimetype == 'application/json'
courses_response = utils.extract_data(response) courses_response = rest_tools.extract_data(response)
assert len(courses_response) == 2 assert len(courses_response) == 2
db_courses = Course.query.all() db_courses = Course.query.all()
utils.assert_course_in(db_courses, courses_response[0]) rest_tools.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[1])
def test_create(client): def test_create(client):
...@@ -26,16 +26,16 @@ def test_create(client): ...@@ -26,16 +26,16 @@ def test_create(client):
db_courses_number = len(Course.query.all()) db_courses_number = len(Course.query.all())
request_json = json.dumps(request_dict) request_json = json.dumps(request_dict)
response = utils.make_request(client, '/courses', data=request_json,