Commit a431272d authored by Barbora Kompišová's avatar Barbora Kompišová
Browse files

documentation, refactor

parent 55464f6a
import uuid
import enum
import datetime
import pytz
from flask_sqlalchemy import BaseQuery
from sqlalchemy import event
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import aliased
from typing import List
from werkzeug.security import generate_password_hash, check_password_hash
from portal.database import SubmissionState
......@@ -15,17 +14,17 @@ from portal.database.mixins import EntityBase
from portal.database.exceptions import PortalDbError
from portal.tools import time
# uuid as primary key source:
# https://stackoverflow.com/questions/36806403/cant-render-element-of-type-class-sqlalchemy-dialects-postgresql-base-uuid
from portal.tools.time import normalize_time
def _repr(instance):
no_include = {'password_hash', 'review', 'course', 'user', 'project', 'group', 'role',
'review_items'}
result = f"{instance.__class__.__name__}: "
for key, value in vars(instance).items():
if not key.startswith("_") and key not in (
'password_hash', 'review', 'course', 'user', 'project', 'group', 'role', 'review_items'):
if not key.startswith("_") and key not in no_include:
result += f"{key}={value} "
return result
......@@ -46,14 +45,13 @@ class User(db.Model, EntityBase):
def set_password(self, password: str):
"""Sets password for the user
Args:
password(str): Unhashed password
"""
self.password_hash = generate_password_hash(password)
def verify_password(self, password: str) -> bool:
"""
"""Verifies user's password
Args:
password(str): Unhashed password
......@@ -64,7 +62,7 @@ class User(db.Model, EntityBase):
@property
def courses(self) -> list:
"""Gets courses the users is signed in
"""Gets courses the users is a part of
Returns(list): List of courses
"""
......@@ -99,11 +97,11 @@ class User(db.Model, EntityBase):
.join(Role.users).filter(User.id == self.id)
def query_groups_in_course(self, course: 'Course', project: 'Project' = None) -> BaseQuery:
"""Queries user's groups for course and project(optional)
"""Queries user's groups for course and project
Args:
course(Course): Course instance
project(Project): Project Instance
project(Project): Project Instance (optional)
Returns(BaseQuery): BaseQuery that can be executed
"""
......@@ -114,11 +112,11 @@ class User(db.Model, EntityBase):
return base_query.join(Group.users).filter(User.id == self.id)
def get_groups_in_course(self, course: 'Course', project: 'Project' = None) -> list:
"""Gers user's groups for course and project(optional)
"""Gets user's groups for course and project (optional)
Args:
course(Course): Course instance
project(Project): Project Instance
project(Project): Project Instance (optional)
Returns(list): List of groups
"""
......@@ -155,15 +153,33 @@ class User(db.Model, EntityBase):
role=role).all()
def query_permissions_for_course(self, course: 'Course'):
permissions = RolePermissions.query.join(RolePermissions.role)\
"""Returns the query for users permissions in the course
Args:
course(Course): Course instance
Returns: Query for the permissions
"""
permissions = RolePermissions.query.join(RolePermissions.role) \
.filter_by(course=course) \
.join(Role.users).filter(User.id == self.id)
return permissions
def get_permissions_for_course(self, course: 'Course'):
def get_permissions_for_course(self, course: 'Course') -> List['RolePermissions']:
"""Gets list of permissions for the course
Args:
course(Course): Course instance
Returns(list[Permissions]): List of the Permissions
"""
return self.query_permissions_for_course(course=course).all()
def __init__(self, uco, email, username, is_admin=False) -> None:
def __init__(self, uco: int = None, email: str = None, username: str = None,
is_admin: bool = False) -> None:
"""Creates user instance
Args:
uco(int): User's UCO (school identifier)
email(str): User's Email
username(str): Username
is_admin(bool): Whether user is admin
"""
self.uco = uco
self.email = email
self.username = username
......@@ -187,7 +203,8 @@ class Course(db.Model, EntityBase):
notes_access_token = db.Column(db.String(100)) # TODO: check length
# Info on cascades: http://docs.sqlalchemy.org/en/latest/orm/cascades.html
# Cascades here simulate a composition - roles, groups and projects exist only when associated to a course
# Cascades here simulate a composition - roles, groups and projects
# exist only when associated with a course
roles = db.relationship("Role", back_populates="course", cascade="all, delete-orphan",
passive_deletes=True)
groups = db.relationship("Group", back_populates="course", cascade="all, delete-orphan",
......@@ -195,7 +212,12 @@ class Course(db.Model, EntityBase):
projects = db.relationship("Project", back_populates="course", cascade="all, delete-orphan",
passive_deletes=True)
def __init__(self, name, codename) -> None:
def __init__(self, name: str = None, codename: str = None) -> None:
"""Creates course instance
Args:
name(str): Course name
codename(str): Course codename
"""
self.name = name
self.codename = codename
......@@ -205,13 +227,23 @@ class Course(db.Model, EntityBase):
def __eq__(self, other):
return self.id == other.id
def get_users_by_role(self, role: 'Role'):
def get_users_by_role(self, role: 'Role') -> List['User']:
"""Gets all users in the course based on their role
Args:
role(Role): The role to filter by
Returns(list[User]): List of users that have the role
"""
return User.query.join(User.roles) \
.filter((Role.id == role.id) & (Role.course == self))
.filter((Role.id == role.id) & (Role.course == self)).all()
def get_users_by_group(self, group: 'Group'):
def get_users_by_group(self, group: 'Group') -> List['User']:
"""Gets all users in the group
Args:
group(Group): The group to find users in
Returns(list[User]): List of users that are in the group
"""
return User.query.join(User.groups) \
.filter((Group.id == group.id) & (Role.course == self))
.filter((Group.id == group.id) & (Role.course == self)).all()
class ProjectState(enum.Enum):
......@@ -239,7 +271,14 @@ class Project(db.Model, EntityBase):
)
def state(self, timestamp=time.NOW()) -> ProjectState:
if not (self.config.submissions_allowed_from or self.config.submissions_allowed_to or self.config.archive_from):
"""Gets project state based on the timestamp
Args:
timestamp: Time for which the state should be calculated
Returns:
"""
if not (self.config.submissions_allowed_from or
self.config.submissions_allowed_to or
self.config.archive_from):
return ProjectState.INACTIVE
if self.config.submissions_allowed_from <= timestamp < self.config.submissions_allowed_to:
return ProjectState.ACTIVE
......@@ -249,14 +288,39 @@ class Project(db.Model, EntityBase):
return ProjectState.INACTIVE
def set_config(self, **kwargs):
"""Sets project configuration
Args:
test_files_source(str): Repository URL
file_whitelist(str): Filter string for whitelist
pre_submit_script(str): Pre submit script used in the submissions processing
post_submit_script(str): Post submit script used in the submissions processing
submission_parameters(str):
Schema that defines all the parameters that should be passed to submission
submission_scheduler_config(str):
Configuration for the submissions scheduler
submissions_allowed_from(time):
Time from all the submissions are allowed from
submissions_allowed_to(time):
Time to which all submissions are allowed to
archive_from(time):
Archive all submissions from
"""
for k, w in kwargs.items():
if k in (
'test_files_source', 'file_whitelist', 'pre_submit_script', 'post_submit_script',
'submission_parameters', 'submission_scheduler_config', 'submissions_allowed_from',
'test_files_source', 'file_whitelist', 'pre_submit_script',
'post_submit_script',
'submission_parameters', 'submission_scheduler_config',
'submissions_allowed_from',
'submissions_allowed_to', 'archive_from'):
setattr(self.config, k, w)
def __init__(self, course, name, test_files_source) -> None:
def __init__(self, course: Course, name: str = None, test_files_source: str = None):
"""Creates instance of the project
Args:
course(Course): Course instance
name(str): Project name
test_files_source(str): Url to test files
"""
self.course = course
self.name = name
self.config = ProjectConfig(project=self, test_files_source=test_files_source)
......@@ -333,7 +397,12 @@ class ProjectConfig(db.Model, EntityBase):
raise PortalDbError()
self._archive_from = time.strip_seconds(archive_from)
def __init__(self, project, test_files_source) -> None:
def __init__(self, project: Project, test_files_source: str = None):
"""Creates instance of the project's config
Args:
project(Project): Project instance
test_files_source: Source files
"""
self.project = project
self.test_files_source = test_files_source
......@@ -358,6 +427,10 @@ class Role(db.Model, EntityBase):
passive_deletes=True, uselist=False)
def set_permissions(self, **kwargs):
"""Sets permissions for the role
Args:
**kwargs(dict): Permissions
"""
for k, w in kwargs.items():
if hasattr(self.permissions, k) and k not in ('id', 'role_id', 'role'):
setattr(self.permissions, k, w)
......@@ -366,7 +439,12 @@ class Role(db.Model, EntityBase):
db.UniqueConstraint('course_id', 'name', name='course_unique_name'),
)
def __init__(self, course, name):
def __init__(self, course: Course, name: str = None):
"""Creates instance of the role in a course
Args:
course(Course): Course instance
name(str): Name of the role
"""
self.course = course
self.name = name
self.permissions = RolePermissions(self)
......@@ -418,7 +496,11 @@ class RolePermissions(db.Model, EntityBase):
write_reviews_group = db.Column(db.Boolean, default=False, nullable=False)
write_reviews_own = db.Column(db.Boolean, default=False, nullable=False)
def __init__(self, role):
def __init__(self, role: Role):
"""Creates instance of the permissions for the role
Args:
role:
"""
self.role = role
def __repr__(self):
......@@ -441,7 +523,12 @@ class Group(db.Model, EntityBase):
db.UniqueConstraint('course_id', 'name', name='course_unique_name'),
)
def __init__(self, course, name) -> None:
def __init__(self, course: Course, name: str = None):
"""Creates instance of the group
Args:
course(Course): Course instance
name(str): Name of the role
"""
self.course = course
self.name = name
......@@ -471,7 +558,15 @@ class Submission(db.Model, EntityBase):
# open to extension (state transition validation, ...)
self.state = new_state
def __init__(self, user, project, parameters, review=None):
def __init__(self, user: User, project: Project, parameters: str = None,
review: 'Review' = None):
"""Creates a new submission instance
Args:
user(User): User instance
project(Project): Project instance
parameters(str): Parameters for the submission
review(Review): Review instance
"""
self.user = user
self.project = project
self.review = review
......@@ -493,7 +588,11 @@ class Review(db.Model, EntityBase):
review_items = db.relationship("ReviewItem", back_populates="review",
cascade="all, delete-orphan", passive_deletes=True)
def __init__(self, submission):
def __init__(self, submission: Submission):
"""Creates a review for the submission
Args:
submission(Submission):
"""
self.submission = submission
def __repr__(self):
......@@ -512,10 +611,18 @@ class ReviewItem(db.Model, EntityBase):
user = db.relationship("User", uselist=False,
back_populates="review_items") # the review item's author
content = db.Column(db.Text)
file = db.Column(db.String(100), nullable=False)
file = db.Column(db.String(256), nullable=False)
line = db.Column(db.Integer, nullable=False)
def __init__(self, user, review, file, line, content):
def __init__(self, user: User, review: Review, file: str, line: int, content: str):
"""Creates a review item in the review
Args:
user(User): The author of the review item
review(Review): Review the item belongs to
file(str): File the review item binds to
line(int): Line of the file
content: Review item content (text)
"""
self.review = review
self.user = user
self.file = file
......@@ -535,6 +642,24 @@ class Component(db.Model, EntityBase):
name = db.Column(db.String(30), nullable=False, unique=True)
secret = db.Column(db.String(250))
ip_address = db.Column(db.String(50))
type = db.Column(db.String(25))
notes = db.Column(db.Text)
def __init__(self, name: str = None, secret: str = None, ip_address: str = None,
type: str = None, notes: str = None):
"""Creates component
Args:
name(str): Name of the component
secret(str): Component's secret key
ip_address(str): Component's IP address
type(str): Type of the component
notes(str): Additional notes for the component
"""
self.name = name
self.secret = secret
self.ip_address = ip_address
self.notes = notes
self.type = type
# source: http://docs.sqlalchemy.org/en/latest/orm/events.html
......
......@@ -173,6 +173,8 @@ class ComponentSchema(Schema):
name = fields.Str()
access_token = fields.Str()
ip_address = fields.Str()
type = fields.Str()
notes = fields.Str()
class CourseImportConfigSchema(Schema):
......
import logging
from flask import Blueprint
from flask_jwt_extended import jwt_required
from flask_restful import Api, Resource
from portal.rest import component_schema, rest_helpers
from portal.service import permissions, auth
from portal.service.auth import find_client
from portal.service.components import create_component, delete_component, update_component, \
find_all_components
from portal.service.errors import ForbiddenError
from portal.service.general import find_component
from portal.tools.decorators import error_handler
components = Blueprint('components', __name__)
components_api = Api(components)
log = logging.getLogger(__name__)
class ComponentList(Resource):
@error_handler
@jwt_required
def get(self):
client = auth.find_client()
# authorization
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
components_list = find_all_components()
return component_schema.dump(components_list), 200
@error_handler
@jwt_required
def post(self):
client = auth.find_client()
# authorization
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
data = rest_helpers.parse_request_data(
component_schema, action='create', resource='component'
)
new_component = create_component(data)
return component_schema.dump(new_component)[0], 201
class ComponentResource(Resource):
@error_handler
@jwt_required
def get(self, cid):
client = find_client()
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
component = find_component(cid)
return component_schema.dump(component)[0], 200
@error_handler
@jwt_required
def delete(self, cid):
client = find_client()
# authorization
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
component = find_component(cid)
delete_component(component)
return '', 204
@error_handler
@jwt_required
def put(self, cid):
client = find_client()
# authorization
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
data = rest_helpers.parse_request_data(
component_schema, action='update', resource='component'
)
component = find_component(cid)
update_component(component, data)
return '', 204
components_api.add_resource(ComponentList, '')
components_api.add_resource(ComponentResource, '/<string:cid>')
import logging
from flask import Blueprint, request
from flask import Blueprint
from flask_jwt_extended import jwt_required
from flask_restful import Api, Resource
from portal.database.models import Course
from portal.rest import course_schema, courses_schema, course_import_schema
from portal.rest import course_schema, courses_schema, course_import_schema, rest_helpers
from portal.service.courses import delete_course, update_course, create_course, \
update_notes_token, copy_course, filter_course_dump
update_notes_token, copy_course, filter_course_dump, find_all_courses
from portal.service.general import find_course
from portal.service.errors import PortalAPIError, ForbiddenError
from portal.service.errors import ForbiddenError
from portal.service.permissions import check_client
from portal.service.auth import find_client
from portal.tools.decorators import error_handler
......@@ -46,11 +45,11 @@ class CourseResource(Resource):
@jwt_required
def delete(self, cid):
client = find_client()
course = find_course(cid)
# authorization
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
course = find_course(cid)
delete_course(course)
return '', 204
......@@ -63,11 +62,9 @@ class CourseResource(Resource):
if not (check_client(client=client, course=course, permissions=['update_course'])):
raise ForbiddenError(uid=client.id)
json_data = request.get_json()
if not json_data:
raise PortalAPIError(400, message='No data provided for course update.')
data = course_schema.load(json_data)[0]
data = rest_helpers.parse_request_data(
schema=course_schema, action='update', resource='course'
)
update_course(course, data)
return '', 204
......@@ -81,7 +78,7 @@ class CourseList(Resource):
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
courses_list = Course.query.all()
courses_list = find_all_courses()
return courses_schema.dump(courses_list)
@error_handler
......@@ -92,11 +89,7 @@ class CourseList(Resource):
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
json_data = request.get_json()
if not json_data:
raise PortalAPIError(400, message='No data provided for course creation.')
data = course_schema.load(json_data)[0]
data = rest_helpers.parse_request_data(course_schema, resource='course', action='create')
new_course = create_course(data)
return course_schema.dump(new_course)[0], 201
......@@ -124,9 +117,7 @@ class CourseNotesToken(Resource):
permissions=['handle_notes_access_token'])):
raise ForbiddenError(uid=client.id)
json_data = request.get_json()
if not json_data:
raise PortalAPIError(400, message='No data provided for notes token update.')
json_data = rest_helpers.require_data(action='update_notes_token', resource='course')
update_notes_token(course, json_data)
return '', 204
......@@ -142,10 +133,9 @@ class CourseImport(Resource):
if not check_client(client=client, course=course, permissions=['update_course']):
raise ForbiddenError(uid=client.id)
json_data = request.get_json()
if not json_data:
raise PortalAPIError(400, message='No data provided for notes token update.')
data = course_import_schema.load(json_data)[0]
data = rest_helpers.parse_request_data(
course_import_schema, action='import', resource='course'
)
source_course = find_course(data['source_course'])
config = data['config']
......
......@@ -4,7 +4,6 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from flask_restful import Api, Resource
from portal.service import general, auth
from portal.service.groups import delete_group, update_group, create_group, \
update_user_group_membership, find_users_in_group_by_role, add_single_user_to_group, \
......@@ -12,7 +11,7 @@ from portal.service.groups import delete_group, update_group, create_group, \
from portal.service.permissions import check_client
from portal.tools.decorators import error_handler
from portal.rest import group_schema, groups_schema, users_schema, user_list_update_schema, \
group_import_schema
group_import_schema, rest_helpers
from portal.service.errors import PortalAPIError, ForbiddenError
groups = Blueprint('groups', __name__)
......@@ -27,11 +26,13 @@ class GroupResource(Resource):
def get(self, cid, gid):
client = auth.find_client()
course = general.find_course(cid)
group = general.find_group(course, gid)
# authorization
if not check_client(client=client, course=course, permissions=['view_course_full']) \
or not check_client(client=client, course=course, permissions=['view_course_limited']):
or not check_client(client=client, course=course,
permissions=['view_course_limited']):
raise ForbiddenError(uid=client.id)
group = general.find_group(course, gid)
return group_schema.dump(group)
@error_handler
......@@ -39,11 +40,11 @@ class GroupResource(Resource):
def delete(self, cid, gid):
client = auth.find_client()
course = general.find_course(cid)
group = general.find_group(course, gid)
# authorization
if not check_client(client=client, course=course, permissions=['write_groups']):
raise ForbiddenError(uid=client.id)
group = general.find_group(course, gid)
delete_group(group)
return '', 204
......@@ -52,17 +53,15 @@ class GroupResource(Resource):
def put(self, cid, gid):
client = auth.find_client()
course = general.find_course(cid)
group = general.find_group(course, gid)
# authorization
if