diff --git a/server/app/__init__.py b/server/app/__init__.py index 5bdffe8261feeb26ec16288ae38e3f791640948a..5edf5d5d7567caa88537716e7702b77a4e48ccfc 100644 --- a/server/app/__init__.py +++ b/server/app/__init__.py @@ -12,11 +12,14 @@ def create_app(config_name="configmodule.DevelopmentConfig"): SocketIO instance and pass in the Flask app to start the server. """ + # Init flask app = Flask(__name__, static_url_path="/static", static_folder="static") app.config.from_object(config_name) app.url_map.strict_slashes = False + with app.app_context(): + # Init flask apps bcrypt.init_app(app) jwt.init_app(app) db.init_app(app) @@ -24,14 +27,18 @@ def create_app(config_name="configmodule.DevelopmentConfig"): ma.init_app(app) configure_uploads(app, (MediaDTO.image_set,)) + # Init socket from app.core.sockets import sio sio.init_app(app) + # Init api from app.apis import flask_api flask_api.init_app(app) + # Flask helpers methods + @app.before_request def clear_trailing(): rp = request.path diff --git a/server/app/apis/media.py b/server/app/apis/media.py index c59589a60afb5944fdd45fea0b230187d8d8da8b..c177ae16e57e06c691c0b628e9fb4d170c76870c 100644 --- a/server/app/apis/media.py +++ b/server/app/apis/media.py @@ -54,21 +54,21 @@ class ImageList(Resource): api.abort(codes.INTERNAL_SERVER_ERROR, "Something went wrong when trying to save image") -@api.route("/images/<ID>") -@api.param("ID") +@api.route("/images/<media_id>") +@api.param("media_id") class ImageList(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) - def get(self, ID): + def get(self, media_id): """ Gets the specified image. """ - item = dbc.get.one(Media, ID) + item = dbc.get.one(Media, media_id) return item_response(schema.dump(item)) @protect_route(allowed_roles=["*"]) - def delete(self, ID): + def delete(self, media_id): """ Deletes the specified image. """ - item = dbc.get.one(Media, ID) + item = dbc.get.one(Media, media_id) try: files.delete_image_and_thumbnail(item.filename) dbc.delete.default(item) diff --git a/server/app/core/__init__.py b/server/app/core/__init__.py index c80bb5c0ade8ae3ffbafdbd50e193677a5692832..091a76c349930355887a34a72915b14ad2950ed4 100644 --- a/server/app/core/__init__.py +++ b/server/app/core/__init__.py @@ -9,6 +9,7 @@ from flask_jwt_extended.jwt_manager import JWTManager from flask_marshmallow import Marshmallow from flask_sqlalchemy import SQLAlchemy +# Flask apps db = SQLAlchemy(model_class=Base, query_class=ExtendedQuery) bcrypt = Bcrypt() jwt = JWTManager() @@ -17,5 +18,13 @@ ma = Marshmallow() @jwt.token_in_blacklist_loader def check_if_token_in_blacklist(decrypted_token): + """ + An extension method with flask_jwt_extended that will execute when jwt verifies + Check if the token is blacklisted in the database + :param decrypted_token: jti or string of the jwt + :type decrypted_token: str + :return: True if token is blacklisted + :rtype: bool + """ jti = decrypted_token["jti"] return models.Blacklist.query.filter_by(jti=jti).first() is not None diff --git a/server/app/core/codes.py b/server/app/core/codes.py index c52ddf8d82b0b3121249ccf74229ae81c897b696..f66bf1d6274520dcdfea296c3fd38212a4365d5a 100644 --- a/server/app/core/codes.py +++ b/server/app/core/codes.py @@ -11,13 +11,14 @@ CODE_RE = re.compile(f"^[{ALLOWED_CHARS}]{{{CODE_LENGTH}}}$") def generate_code_string() -> str: - """Generates a 6 character long random sequence containg uppercase letters - and numbers. + """ + Return a 6 character long random sequence containing uppercase letters and numbers. """ return "".join(random.choices(ALLOWED_CHARS, k=CODE_LENGTH)) def verify_code(code: str) -> bool: - """Returns True if code only contains letters and/or numbers - and is exactly 6 characters long.""" + """ + Returns True if code only contains letters and/or numbers and is exactly 6 characters long. + """ return CODE_RE.search(code.upper()) is not None diff --git a/server/app/core/files.py b/server/app/core/files.py index f45b9bb58f0cea1757498b344a8221e910708805..4f5256fb795fdc2ebf599464e5b99ee7c30a22d5 100644 --- a/server/app/core/files.py +++ b/server/app/core/files.py @@ -2,10 +2,11 @@ Contains functions related to file handling, mainly saving and deleting images. """ -from PIL import Image -from flask import current_app, has_app_context import os + +from flask import current_app, has_app_context from flask_uploads import IMAGES, UploadSet +from PIL import Image if has_app_context(): PHOTO_PATH = current_app.config["UPLOADED_PHOTOS_DEST"] @@ -32,6 +33,13 @@ if has_app_context(): def _delete_image(filename): + """ + Private function + Delete an image with the given filename + :param filename: Of the image that will be deleted + :type filename: str + :rtype: None + """ path = os.path.join(PHOTO_PATH, filename) os.remove(path) @@ -39,6 +47,10 @@ def _delete_image(filename): def save_image_with_thumbnail(image_file): """ Saves the given image and also creates a small thumbnail for it. + :param image_file: Image object that will be saved on server + :type image_file: object + :return: Filename of the saved image, if filename already exist the filename will be changed + :rtype: str """ saved_filename = image_set.save(image_file) @@ -55,6 +67,8 @@ def save_image_with_thumbnail(image_file): def delete_image_and_thumbnail(filename): """ Delete the given image together with its thumbnail. + :param filename: + :type filename: """ _delete_image(filename) _delete_image(f"thumbnail_{filename}") diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index ae32121d7a42557ab9757433816506f88bc17eb0..64d9ca189293602fd70f8f05e70d96c0f9556721 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -1,5 +1,5 @@ """ -This module contains schemas used to convert database objects into +This module contains marshmallow schemas used to convert database objects into dictionaries. """ diff --git a/server/app/database/__init__.py b/server/app/database/__init__.py index 2840c94e7c6fa8ff38e53ddcf0fa8429d7847c72..47fd3079c10314adc7391f4282fbec3b2ebe36e8 100644 --- a/server/app/database/__init__.py +++ b/server/app/database/__init__.py @@ -11,6 +11,10 @@ from sqlalchemy.sql import func class Base(Model): + """ + Abstract table/model that all tables inherit + """ + __abstract__ = True _created = Column(DateTime(timezone=True), server_default=func.now()) _updated = Column(DateTime(timezone=True), onupdate=func.now()) @@ -21,16 +25,25 @@ class ExtendedQuery(BaseQuery): Extensions to a regular query which makes using the database more convenient. """ - def first_extended(self, required=True, error_message=None, error_code=404): + def first_api(self, required=True, error_message=None, error_code=404): """ Extensions of the first() functions otherwise used on queries. Abort if no item was found and it was required. + + :param required: Raise an exception if the query results in None + :type required: bool + :param error_message: The message that will be sent to the client with the exception + :type error_message:str + :param error_code: The status code that will be sent to the client with the exception + :type error_code: int + :return: + :rtype: """ + item = self.first() if required and not item: - if not error_message: - error_message = "Object not found" + error_message = error_message or "Object not found" abort(error_code, error_message) return item @@ -39,7 +52,18 @@ class ExtendedQuery(BaseQuery): """ When looking for lists of items this is used to only return a few of them to allow for pagination. + :param page: Offset of the result + :type page: int + :param page_size: Amount of rows that will be retrieved from the query + :type page_size: int + :param order_column: Field of a DbModel in which the query shall order by + :type order_column: sqlalchemy.sql.schema.Column + :param order: If equals 1 then order by ascending otherwise order by descending + :type order: int + :return: A page/list of items with offset page*page_size and the total count of all rows ignoring page and page_size + :rtype: list, int """ + query = self if order_column: if order == 1: diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index 3d2ec4e6bbd6ebf9aa2e8ded7ea3b03ee2ac421a..799fe4cc95821dd0c9858e701bf10082db63fb32 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -30,7 +30,7 @@ from app.database.models import ( ViewType, Whitelist, ) -from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT +from app.database.types import IMAGE_COMPONENT_ID, QUESTION_COMPONENT_ID, TEXT_COMPONENT_ID from flask import current_app from flask.globals import current_app from flask_restx import abort @@ -40,8 +40,8 @@ from sqlalchemy import exc def db_add(item): """ - Internal function. Adds item to the database - and handles comitting and refreshing. + Internal function that add item to the database. + Handle different types of errors that occur when inserting an item into the database by calling abort. """ try: db.session.add(item) @@ -73,12 +73,12 @@ def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, copy=False, * coordinates with the provided size and data. """ - if type_id == ID_TEXT_COMPONENT: + if type_id == TEXT_COMPONENT_ID: item = db_add( TextComponent(slide_id, type_id, view_type_id, x, y, w, h), ) item.text = data.get("text") - elif type_id == ID_IMAGE_COMPONENT: + elif type_id == IMAGE_COMPONENT_ID: if not copy: # Scale image if adding a new one, a copied image should keep it's size item_image = get.one(Media, data["media_id"]) filename = item_image.filename @@ -101,7 +101,7 @@ def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, copy=False, * ImageComponent(slide_id, type_id, view_type_id, x, y, w, h), ) item.media_id = data.get("media_id") - elif type_id == ID_QUESTION_COMPONENT: + elif type_id == QUESTION_COMPONENT_ID: item = db_add( QuestionComponent(slide_id, type_id, view_type_id, x, y, w, h), ) diff --git a/server/app/database/controller/copy.py b/server/app/database/controller/copy.py index 07816e3e46424fc8d6b698730ce34d75341cc994..d19f6bd362ffc00066c12bc196e24a7d7d5ee5b8 100644 --- a/server/app/database/controller/copy.py +++ b/server/app/database/controller/copy.py @@ -4,7 +4,7 @@ This file contains functionality to copy and duplicate data to the database. from app.database.controller import add, get, search, utils from app.database.models import Question -from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT +from app.database.types import IMAGE_COMPONENT_ID, QUESTION_COMPONENT_ID, TEXT_COMPONENT_ID def _alternative(item_old, question_id): @@ -53,11 +53,11 @@ def component(item_component, slide_id_new, view_type_id): """ data = {} - if item_component.type_id == ID_TEXT_COMPONENT: + if item_component.type_id == TEXT_COMPONENT_ID: data["text"] = item_component.text - elif item_component.type_id == ID_IMAGE_COMPONENT: + elif item_component.type_id == IMAGE_COMPONENT_ID: data["media_id"] = item_component.media_id - elif item_component.type_id == ID_QUESTION_COMPONENT: + elif item_component.type_id == QUESTION_COMPONENT_ID: data["question_id"] = item_component.question_id return add.component( diff --git a/server/app/database/controller/get.py b/server/app/database/controller/get.py index 2b85abed30e143c28e7ec7fe319eb08384236296..c888fc6dc86318321ecb8b9bd31d0495db062569 100644 --- a/server/app/database/controller/get.py +++ b/server/app/database/controller/get.py @@ -32,16 +32,14 @@ def all(db_type): def one(db_type, id, required=True): """ Get lazy db-item in the table that has the same id. """ - return db_type.query.filter(db_type.id == id).first_extended(required=required) + return db_type.query.filter(db_type.id == id).first_api(required=required) ### Codes ### def code_by_code(code): """ Gets the code object associated with the provided code. """ - return Code.query.filter(Code.code == code.upper()).first_extended( - True, "A presentation with that code does not exist" - ) + return Code.query.filter(Code.code == code.upper()).first_api(True, "A presentation with that code does not exist") def code_list(competition_id): @@ -65,7 +63,7 @@ def user_exists(email): def user_by_email(email): """ Gets the user object associated with the provided email. """ - return User.query.filter(User.email == email).first_extended(error_code=codes.UNAUTHORIZED) + return User.query.filter(User.email == email).first_api(error_code=codes.UNAUTHORIZED) ### Slides ### @@ -77,7 +75,7 @@ def slide(competition_id, slide_id): join_competition = Competition.id == Slide.competition_id filters = (Competition.id == competition_id) & (Slide.id == slide_id) - return Slide.query.join(Competition, join_competition).filter(filters).first_extended() + return Slide.query.join(Competition, join_competition).filter(filters).first_api() def slide_list(competition_id): @@ -104,7 +102,7 @@ def team(competition_id, team_id): join_competition = Competition.id == Team.competition_id filters = (Competition.id == competition_id) & (Team.id == team_id) - return Team.query.join(Competition, join_competition).filter(filters).first_extended() + return Team.query.join(Competition, join_competition).filter(filters).first_api() def team_list(competition_id): @@ -129,7 +127,7 @@ def question(competition_id, slide_id, question_id): join_slide = Slide.id == Question.slide_id filters = (Competition.id == competition_id) & (Slide.id == slide_id) & (Question.id == question_id) - return Question.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).first_extended() + return Question.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).first_api() def question_list(competition_id, slide_id): @@ -185,7 +183,7 @@ def question_alternative( .join(Slide, join_slide) .join(Question, join_question) .filter(filters) - .first_extended() + .first_api() ) @@ -255,7 +253,7 @@ def question_alternative_answer(competition_id, team_id, question_alternative_id QuestionAlternativeAnswer.query.join(Competition, join_competition) .join(Team, join_team) .filter(filters) - .first_extended(required) + .first_api() ) @@ -287,11 +285,7 @@ def component(competition_id, slide_id, component_id): poly = with_polymorphic(Component, [TextComponent, ImageComponent]) return ( - db.session.query(poly) - .join(Competition, join_competition) - .join(Slide, join_slide) - .filter(filters) - .first_extended() + db.session.query(poly).join(Competition, join_competition).join(Slide, join_slide).filter(filters).first_api() ) diff --git a/server/app/database/models.py b/server/app/database/models.py index 45ae3797154b0cb706dd2716fe9bcac7928a46f3..1d925a4db4f7f1679d3f60c6011c65d710f10985 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -5,13 +5,19 @@ each other. """ from app.core import bcrypt, db -from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT +from app.database.types import IMAGE_COMPONENT_ID, QUESTION_COMPONENT_ID, TEXT_COMPONENT_ID from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property -STRING_SIZE = 254 +STRING_SIZE = 254 # Default size of string Columns (varchar) class Whitelist(db.Model): + """ + Table with allowed jwt. + + Depend on table: User, Competition. + """ + id = db.Column(db.Integer, primary_key=True) jti = db.Column(db.String, unique=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) @@ -24,6 +30,10 @@ class Whitelist(db.Model): class Blacklist(db.Model): + """ + Table with banned jwt. + """ + id = db.Column(db.Integer, primary_key=True) jti = db.Column(db.String, unique=True) @@ -32,6 +42,10 @@ class Blacklist(db.Model): class Role(db.Model): + """ + Table with roles: Admin and Editor. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) @@ -41,8 +55,11 @@ class Role(db.Model): self.name = name -# TODO Region? class City(db.Model): + """ + Table with cities. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) @@ -54,6 +71,12 @@ class City(db.Model): class User(db.Model): + """ + Table with users. + + Depend on table: Role, City. + """ + id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(STRING_SIZE), unique=True) name = db.Column(db.String(STRING_SIZE), nullable=True) @@ -92,6 +115,12 @@ class User(db.Model): class Media(db.Model): + """ + Table with media objects that can be image or video depending on type_id. + + Depend on table: MediaType, User. + """ + id = db.Column(db.Integer, primary_key=True) filename = db.Column(db.String(STRING_SIZE), unique=True) type_id = db.Column(db.Integer, db.ForeignKey("media_type.id"), nullable=False) @@ -104,6 +133,12 @@ class Media(db.Model): class Competition(db.Model): + """ + Table with competitions. + + Depend on table: Media, City. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) year = db.Column(db.Integer, nullable=False, default=2020) @@ -127,6 +162,12 @@ class Competition(db.Model): class Team(db.Model): + """ + Table with teams. + + Depend on table: Competition. + """ + __table_args__ = (db.UniqueConstraint("competition_id", "name"),) id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), nullable=False) @@ -143,6 +184,12 @@ class Team(db.Model): class Slide(db.Model): + """ + Table with slides. + + Depend on table: Competition, Media. + """ + __table_args__ = (db.UniqueConstraint("order", "competition_id"),) id = db.Column(db.Integer, primary_key=True) order = db.Column(db.Integer, nullable=False) @@ -164,6 +211,12 @@ class Slide(db.Model): class Question(db.Model): + """ + Table with questions of different types depending on type_id + + Depend on table: QuestionType, Slide. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), nullable=False) total_score = db.Column(db.Integer, nullable=True, default=None) @@ -182,6 +235,12 @@ class Question(db.Model): class QuestionAlternative(db.Model): + """ + Table with question alternatives. + + Depend on table: Question. + """ + id = db.Column(db.Integer, primary_key=True) text = db.Column(db.String(STRING_SIZE), nullable=False) value = db.Column(db.Integer, nullable=False) @@ -194,6 +253,14 @@ class QuestionAlternative(db.Model): class QuestionScore(db.Model): + """ + Table with question answers. + + Depend on table: Question, Team. + + Unique Constraint: Question.id, Team.id. + """ + __table_args__ = (db.UniqueConstraint("question_id", "team_id"),) id = db.Column(db.Integer, primary_key=True) score = db.Column(db.Integer, nullable=True, default=0) @@ -222,6 +289,13 @@ class QuestionAlternativeAnswer(db.Model): class Component(db.Model): + """ + Table implemented with single table inheritance where each subclass is linked to a specific type_id + This class/table contains all fields from every subclass. + + Depend on table: :ViewType, Slide, ComponentType, Media, Question. + """ + id = db.Column(db.Integer, primary_key=True) x = db.Column(db.Integer, nullable=False, default=0) y = db.Column(db.Integer, nullable=False, default=0) @@ -244,28 +318,50 @@ class Component(db.Model): class TextComponent(Component): + """ + Subclass of Component that contains text. + """ + text = db.Column(db.Text, default="", nullable=False) # __tablename__ = None - __mapper_args__ = {"polymorphic_identity": ID_TEXT_COMPONENT} + __mapper_args__ = {"polymorphic_identity": TEXT_COMPONENT_ID} class ImageComponent(Component): + """ + Subclass of Component that contains an image. + + Depend on table: Media. + """ + media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=True) media = db.relationship("Media", uselist=False) # __tablename__ = None - __mapper_args__ = {"polymorphic_identity": ID_IMAGE_COMPONENT} + __mapper_args__ = {"polymorphic_identity": IMAGE_COMPONENT_ID} class QuestionComponent(Component): + """ + Subclass of Component that contains a question. + + Depend on table: Question. + """ + question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=True) # __tablename__ = None - __mapper_args__ = {"polymorphic_identity": ID_QUESTION_COMPONENT} + __mapper_args__ = {"polymorphic_identity": QUESTION_COMPONENT_ID} class Code(db.Model): + """ + Table with codes. + + Depend on table: ViewType, Competition, Team. + """ + id = db.Column(db.Integer, primary_key=True) code = db.Column(db.Text, unique=True) view_type_id = db.Column(db.Integer, db.ForeignKey("view_type.id"), nullable=False) @@ -282,6 +378,10 @@ class Code(db.Model): class ViewType(db.Model): + """ + Table with view types: Team, Judge, Audience and Operator. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) @@ -290,6 +390,10 @@ class ViewType(db.Model): class ComponentType(db.Model): + """ + Table with component types: Text, Image and Question. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) components = db.relationship("Component", backref="component_type") @@ -299,6 +403,10 @@ class ComponentType(db.Model): class MediaType(db.Model): + """ + Table with media types: Image and Video. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) media = db.relationship("Media", backref="type") @@ -308,6 +416,10 @@ class MediaType(db.Model): class QuestionType(db.Model): + """ + Table with question types: Text, Practical, Multiple and Single. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) questions = db.relationship("Question", backref="type") diff --git a/server/app/database/types.py b/server/app/database/types.py index 46db168cdbf9e1dce2727bacefac7fd823a9de39..e60dcacc77a6cf2d49abbb652b7dc0a98c7b5f44 100644 --- a/server/app/database/types.py +++ b/server/app/database/types.py @@ -2,6 +2,24 @@ This module defines the different component types. """ -ID_TEXT_COMPONENT = 1 -ID_IMAGE_COMPONENT = 2 -ID_QUESTION_COMPONENT = 3 \ No newline at end of file +# Store all type ID in cache instead of here. + +IMAGE_MEDIA_ID = 1 +VIDEO_MEDIA_ID = 2 + +TEXT_QUESTION_ID = 1 +PRACTICAL_QUESTION_ID = 2 +MULTIPLE_QUESTION_ID = 3 +SINGLE_QUESTION_ID = 4 + +TEAM_VIEW_ID = 1 +JUDGE_VIEW_ID = 2 +AUDIENCE_VIEW_ID = 3 +OPERATOR_VIEW_ID = 4 + +ADMIN_ROLE_ID = 1 +EDITOR_ROLE_ID = 2 + +TEXT_COMPONENT_ID = 1 +IMAGE_COMPONENT_ID = 2 +QUESTION_COMPONENT_ID = 3 diff --git a/server/populate.py b/server/populate.py index 935acce66c2d2c8a083e90f040ba2d778bb61c29..7a629ade28ee78455d0e21882fd8e92165025d26 100644 --- a/server/populate.py +++ b/server/populate.py @@ -9,7 +9,7 @@ from app import create_app, db from app.database.models import City, QuestionType, Role -def _add_items(): +def create_default_items(): media_types = ["Image", "Video"] question_types = ["Text", "Practical", "Multiple", "Single"] component_types = ["Text", "Image", "Question"] @@ -125,4 +125,4 @@ if __name__ == "__main__": db.drop_all() db.create_all() - _add_items() + create_default_items()