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

Migrated to flask-restplus

parent b9372505
Pipeline #12778 passed with stage
in 7 minutes and 39 seconds
......@@ -7,7 +7,6 @@ name = "pypi"
flask = "*"
flask-sqlalchemy = "*"
pytest = "*"
flask-restful = "*"
marshmallow = "*"
flask-jwt-extended = "*"
marshmallow-enum = "*"
......@@ -22,6 +21,7 @@ pytz = "*"
flask-migrate = "*"
python-gitlab = "*"
psycopg2-binary = "*"
flask-restplus = "*"
[dev-packages]
pytest-cov = "*"
......
......@@ -94,5 +94,5 @@ def create_app(environment: str = None):
configure_app(app, env=environment)
configure_storage(app)
configure_extensions(app)
rest.register_blueprints(app)
rest.register_namespaces(app)
return app
from sqlalchemy.exc import SQLAlchemyError
"""
Database specific exceptions
"""
from sqlalchemy.exc import SQLAlchemyError
class PortalDbError(SQLAlchemyError):
"""Raised when a forbidden operation on portal's database is attempted.
"""
\ No newline at end of file
"""
from portal import db
"""
A collection of mixins specifying common behaviour and attributes of database entities.
A collection of Mixins specifying common behaviour and attributes of database entities.
"""
from portal import db
# maybe use server_default, server_onupdate instead of default, onupdate; crashes tests
# (https://stackoverflow.com/questions/13370317/sqlalchemy-default-datetime)
......
"""
Models module where all of the models are specified
"""
import enum
import uuid
from flask_sqlalchemy import BaseQuery
......@@ -15,10 +19,6 @@ from portal.tools import time
# https://stackoverflow.com/questions/36806403/cant-render-element-of-type-class-sqlalchemy-dialects-postgresql-base-uuid
from portal.tools.time import normalize_time
"""
Models module where all of the models are specified
"""
def _repr(instance) -> str:
"""Repr helper function
......@@ -467,21 +467,40 @@ class ProjectConfig(db.Model, EntityBase):
@hybrid_property
def submissions_allowed_from(self):
"""Gets from which date all the submissions are allowed from
Returns(time): Time from submissions are allowed from
"""
seconds = time.strip_seconds(self._submissions_allowed_from)
return seconds
@hybrid_property
def submissions_allowed_to(self):
"""Gets from which date all the submissions are allowed to
Returns(time): Time from submissions are allowed to
"""
seconds = time.strip_seconds(self._submissions_allowed_to)
return seconds
@hybrid_property
def archive_from(self):
"""Gets from which date all the submissions are archived from
Returns(time): Time from submissions should be archived from
"""
seconds = time.strip_seconds(self._archive_from)
return seconds
@submissions_allowed_from.setter
def submissions_allowed_from(self, submissions_allowed_from):
"""Set time from which submissions are allowed to submit
Args:
submissions_allowed_from(time): Time from which all the submissions are allowed to submit
"""
submissions_allowed_from = normalize_time(submissions_allowed_from)
if self.submissions_allowed_to is not None and \
submissions_allowed_from > self.submissions_allowed_to:
......@@ -493,6 +512,10 @@ class ProjectConfig(db.Model, EntityBase):
@submissions_allowed_to.setter
def submissions_allowed_to(self, submissions_allowed_to):
"""Set time from which submissions are allowed to submit
Args:
submissions_allowed_to(time): Time to which all the submissions are allowed to submit
"""
submissions_allowed_to = normalize_time(submissions_allowed_to)
if self.submissions_allowed_from is not None and \
self.submissions_allowed_from > submissions_allowed_to:
......@@ -504,6 +527,10 @@ class ProjectConfig(db.Model, EntityBase):
@archive_from.setter
def archive_from(self, archive_from):
"""Set time from which submissions are allowed to archive
Args:
submissions_allowed_from(time): Time from which all the submissions are allowed to archive
"""
archive_from = normalize_time(archive_from)
if self.submissions_allowed_from is not None and \
self.submissions_allowed_from > archive_from:
......
"""
Utils to help with database
"""
from flask_sqlalchemy import BaseQuery
from sqlalchemy import func
......
"""
Logging configuration module
"""
from logging.config import dictConfig
# look into: https://www.packetmischief.ca/2017/10/25/3-ways-to-fail-at-logging-with-flask/
# source: https://docs.python.org/3/howto/logging-cookbook.html (An example dictionary-based configuration)
# discussion: https://github.com/pallets/flask/issues/2023
FORMATTERS = {
'verbose': {
'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
......
from flask import Flask
"""
Rest layer module
"""
from flask import Flask
from flask_restplus import Api
API_PREFIX = "/api/v1.0"
def register_blueprints(app: Flask):
def create_api():
return Api(
title='Kontr Portal API',
version='1.0',
description='Kontr Portal API',
doc=f"{API_PREFIX}/docs",
prefix=API_PREFIX
)
rest_api = create_api()
def register_namespaces(app: Flask):
"""Registers blue prints for the application
Args:
app(Flask): Flask application
Returns(Flask): Flask application
"""
from portal.rest.auth.login import auth_blueprint
from portal.rest.courses.courses import courses_blueprint
from portal.rest.roles.roles import roles_blueprint
from portal.rest.groups.groups import groups_blueprint
from portal.rest.submissions.submissions import submissions_blueprint
from portal.rest.projects.projects import projects_blueprint
from portal.rest.components.components import components_blueprint
from portal.rest.notifications.notifications import notifications_blueprint
from portal.rest.users.users import users_blueprint
from portal.rest.management.management import management_blueprint
app.register_blueprint(auth_blueprint, url_prefix=f"{API_PREFIX}/auth")
app.register_blueprint(users_blueprint, url_prefix=f"{API_PREFIX}/users")
app.register_blueprint(courses_blueprint, url_prefix=f"{API_PREFIX}/courses")
app.register_blueprint(roles_blueprint, url_prefix=f"{API_PREFIX}/courses/<string:cid>/roles")
app.register_blueprint(groups_blueprint, url_prefix=f"{API_PREFIX}/courses/<string:cid>/groups")
app.register_blueprint(projects_blueprint,
url_prefix=f"{API_PREFIX}/courses/<string:cid>/projects")
app.register_blueprint(submissions_blueprint, url_prefix=f"{API_PREFIX}/submissions")
app.register_blueprint(components_blueprint, url_prefix=f"{API_PREFIX}/components")
app.register_blueprint(notifications_blueprint, url_prefix=f"{API_PREFIX}/notifications")
app.register_blueprint(management_blueprint, url_prefix=f"{API_PREFIX}/management")
__register_gitlab_endpoint(app)
return app
from portal.rest.auth.login import auth_namespace
from portal.rest.courses.courses import courses_namespace
from portal.rest.roles.roles import roles_namespace
from portal.rest.groups.groups import groups_namespace
from portal.rest.submissions.submissions import submissions_namespace
from portal.rest.projects.projects import projects_namespace
from portal.rest.components.components import components_namespace
from portal.rest.notifications.notifications import notifications_namespace
from portal.rest.users.users import users_namespace
from portal.rest.management.management import management_namespace
rest_api.add_namespace(auth_namespace)
rest_api.add_namespace(courses_namespace)
rest_api.add_namespace(roles_namespace)
rest_api.add_namespace(groups_namespace)
rest_api.add_namespace(submissions_namespace)
rest_api.add_namespace(projects_namespace)
rest_api.add_namespace(components_namespace)
rest_api.add_namespace(notifications_namespace)
rest_api.add_namespace(users_namespace)
rest_api.add_namespace(management_namespace)
def __register_gitlab_endpoint(app: Flask):
"""Registers the gitlab endpoint only if gitlab url has been specified
Args:
app(Flask): Flask application
"""
if app.config.get('GITLAB_URL'):
from portal.rest.auth.gitlab import oauth_blueprint
app.register_blueprint(oauth_blueprint, url_prefix=f"{API_PREFIX}/oauth")
from portal.rest.auth.gitlab import oauth_namespace
rest_api.add_namespace(management_namespace)
rest_api.init_app(app)
import portal.rest.errors
return app
import logging
from flask import Blueprint, url_for, request, redirect, session, make_response, Flask, Response
from flask import Flask, Response, make_response, redirect, request, session, url_for
from flask_oauthlib.client import OAuth, OAuthRemoteApp
from flask_restplus import Namespace, Resource
from typing import Union
from portal import oauth
......@@ -11,6 +11,8 @@ from portal.tools.decorators import error_handler
log = logging.getLogger(__name__)
oauth_namespace = Namespace('oauth')
def extract_user_info(me: dict) -> dict:
log.debug(f"[GITLAB] Received info: {me}")
......@@ -49,18 +51,18 @@ def create_gitlab_app(oauth: OAuth) -> Union[OAuthRemoteApp, None]:
gitlab = create_gitlab_app(oauth=oauth)
oauth_blueprint = Blueprint('oauth', __name__, url_prefix='/oauth')
@error_handler
@oauth_blueprint.route('/login', methods=['GET'])
def oauth_login():
if not gitlab:
return {'message': 'Gitlab OAuth is not enabled'}, 404
@oauth_namespace.route('/login')
class OAuthLogin(Resource):
def get(self):
if not gitlab:
return {'message': 'Gitlab OAuth is not enabled'}, 404
callback = url_for('oauth.oauth_authorized', _external=True, _scheme='https')
log.debug(f"Callback set: {callback}")
return gitlab.authorize(callback=callback)
callback = url_for('oauth.oauth_authorized', _external=True, _scheme='https')
log.debug(f"Callback set: {callback}")
return gitlab.authorize(callback=callback)
def user_oauth_register(user_info):
......@@ -84,27 +86,28 @@ def user_login(user_info) -> Response:
return resp
@error_handler
@oauth_blueprint.route('/login/authorized', methods=['GET'])
def oauth_authorized():
if not gitlab:
return {'message': 'Gitlab OAuth is not enabled'}, 404
resp = gitlab.authorized_response()
if resp is None:
return 'Access denied: reason=%s error=%s' % (
request.args['error'],
request.args['error_description']
)
token = resp['access_token']
log.debug(f"[GITLAB] Received token: {token}")
session['gitlab_token'] = (token, '')
me = gitlab.get('/api/v4/user')
user_info = extract_user_info(me.data)
login = user_login(user_info)
login.set_cookie('gitlab_token', token)
log.debug(f"[GITLAB] Gitlab Login response: {login}")
return login
@oauth_namespace.route('/login/authorized')
class OAuthLoginAuthorized(Resource):
def get(self):
if not gitlab:
return {'message': 'Gitlab OAuth is not enabled'}, 404
resp = gitlab.authorized_response()
if resp is None:
return 'Access denied: reason=%s error=%s' % (
request.args['error'],
request.args['error_description']
)
token = resp['access_token']
log.debug(f"[GITLAB] Received token: {token}")
session['gitlab_token'] = (token, '')
me = gitlab.get('/api/v4/user')
user_info = extract_user_info(me.data)
login = user_login(user_info)
login.set_cookie('gitlab_token', token)
log.debug(f"[GITLAB] Gitlab Login response: {login}")
return login
@gitlab.tokengetter
......
import logging
from flask_jwt_extended import create_access_token, create_refresh_token, \
jwt_refresh_token_required, get_jwt_identity, jwt_required
from flask_restful import Resource, Api
from flask import Blueprint, request
from flask import request
from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, \
jwt_refresh_token_required, jwt_required
from flask_restplus import Resource, Namespace
from portal import jwt
from portal.service import general
from portal.service.auth import login_user, login_component
from portal.service.errors import UnauthorizedError, PortalAPIError
from portal.service.auth import login_component, login_user
from portal.service.errors import PortalAPIError, UnauthorizedError
from portal.tools.decorators import error_handler
log = logging.getLogger(__name__)
auth_blueprint = Blueprint('auth', __name__, url_prefix='/auth')
auth_api = Api(auth_blueprint)
auth_namespace = Namespace('auth')
@jwt.user_claims_loader
......@@ -24,12 +22,13 @@ def add_claims_to_access_token(identity):
data = 'component'
return {
'type': data
}
}
# basic login - username + password (user) / name + secret (component)
@auth_namespace.route('/login')
class Login(Resource):
@error_handler
def post(self):
data = request.get_json()
if not data.get('type'):
......@@ -59,12 +58,13 @@ class Login(Resource):
id=client.id,
access_token=create_access_token(identity=client.id),
refresh_token=create_refresh_token(identity=client.id)
)
)
return response, 200
@auth_namespace.route('/refresh')
class Refresh(Resource):
@error_handler
@jwt_refresh_token_required
def post(self):
client = get_jwt_identity()
......@@ -73,13 +73,14 @@ class Refresh(Resource):
ret = dict(
access_token=create_access_token(identity=client)
)
)
return ret, 200
@auth_namespace.route('/logout')
class Logout(Resource):
@error_handler
@jwt_required
def post(self):
# might use redis later
......@@ -91,11 +92,6 @@ class Logout(Resource):
ret = {
'access_token': None,
'refresh_token': None,
}
}
return ret, 200
auth_api.add_resource(Login, '/login')
auth_api.add_resource(Refresh, '/refresh')
auth_api.add_resource(Logout, '/logout')
import logging
from flask import Blueprint
from flask_jwt_extended import jwt_required
from flask_restful import Api, Resource
from flask_restplus import Resource, Namespace
from portal.rest import rest_helpers
from portal.rest.schemas import component_schema, components_schema
from portal.service import permissions, auth
from portal.service import auth, permissions
from portal.service.auth import find_client
from portal.service.components import create_component, delete_component, update_component, \
find_all_components
from portal.service.components import create_component, delete_component, find_all_components, \
update_component
from portal.service.errors import ForbiddenError
from portal.service.general import find_component
from portal.tools.decorators import error_handler
components_blueprint = Blueprint('components', __name__)
components_api = Api(components_blueprint)
components_namespace = Namespace('components')
log = logging.getLogger(__name__)
@components_namespace.route('')
class ComponentList(Resource):
@error_handler
@jwt_required
def get(self):
client = auth.find_client()
......@@ -30,7 +28,7 @@ class ComponentList(Resource):
components_list = find_all_components()
return components_schema.dump(components_list)[0], 200
@error_handler
@jwt_required
def post(self):
client = auth.find_client()
......@@ -40,15 +38,17 @@ class ComponentList(Resource):
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
@components_namespace.route('/<string:cid>')
@components_namespace.doc(params={'cid': 'An Component ID'})
class ComponentResource(Resource):
@error_handler
@jwt_required
def get(self, cid):
def get(self, cid: str):
client = find_client()
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
......@@ -56,9 +56,9 @@ class ComponentResource(Resource):
component = find_component(cid)
return component_schema.dump(component)[0], 200
@error_handler
@jwt_required
def delete(self, cid):
def delete(self, cid: str):
client = find_client()
# authorization
if not permissions.check_sysadmin(client):
......@@ -68,9 +68,9 @@ class ComponentResource(Resource):
delete_component(component)
return '', 204
@error_handler
@jwt_required
def put(self, cid):
def put(self, cid: str):
client = find_client()
# authorization
if not permissions.check_sysadmin(client):
......@@ -78,12 +78,8 @@ class ComponentResource(Resource):
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 request
from flask_jwt_extended import jwt_required
from flask_restful import Api, Resource
from flask_restplus import Namespace, Resource
from portal.rest import rest_helpers
from portal.rest.schemas import course_schema, courses_schema, course_import_schema, users_schema
from portal.service.courses import delete_course, update_course, create_course, \
update_notes_token, copy_course, find_all_courses, get_users_filtered
from portal.rest.schemas import course_import_schema, course_schema, courses_schema, users_schema
from portal.service import permissions
from portal.service.auth import find_client
from portal.service.courses import copy_course, create_course, delete_course, find_all_courses, \
get_users_filtered, update_course, update_notes_token
from portal.service.errors import ForbiddenError, PortalAPIError
from portal.service.filters import filter_course_dump
from portal.service.general import find_course
from portal.service.errors import ForbiddenError, PortalAPIError
from portal.service.permissions import check_client
from portal.service.auth import find_client
from portal.tools.decorators import error_handler
from portal.service import permissions
courses_blueprint = Blueprint('courses', __name__)
courses_api = Api(courses_blueprint)
courses_namespace = Namespace('courses')
log = logging.getLogger(__name__)
@courses_namespace.route('')
class CourseList(Resource):
@jwt_required
def get(self):
client = find_client()
# authorization
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
courses_list = find_all_courses()
return courses_schema.dump(courses_list)
@jwt_required
def post(self):
client = find_client()
# authorization
if not permissions.check_sysadmin(client):
raise ForbiddenError(uid=client.id)
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
@courses_namespace.route('/<string:cid>')
@courses_namespace.doc({'cid': 'Course id'})
class CourseResource(Resource):
@error_handler
@jwt_required
def get(self, cid):
def get(self, cid: str):
client = find_client()
course = find_course(cid)
# authorization
......@@ -43,9 +68,9 @@ class CourseResource(Resource):
raise ForbiddenError(uid=client.id)
@error_handler
@jwt_required
def delete(self, cid):
def delete(self, cid: str):
client = find_client()
# authorization
if not permissions.check_sysadmin(client):
......@@ -55,9 +80,9 @@ class CourseResource(Resource):
delete_course(course)