diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a90f10a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +**/__pycache__ +**/.idea +venv/ diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..1d2747b --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,36 @@ +version: "3.8" + +services: + rust_service: + build: rust-server/ + ports: + - 8080:8080 + depends_on: + - postgres_db + + python_service: + build: web-app/ + environment: + FLASK_ENV: development + FLASK_APP: app_start.py + ports: + - 8888:8888 + depends_on: + - postgres_db + + postgres_db: + image: postgres + container_name: database + restart: always + environment: + POSTGRES_DB: test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + ports: + - 5432:5432 + volumes: + - postgres_db:/var/lib/postgresql/data + + +volumes: + postgres_db: \ No newline at end of file diff --git a/rust-server/.dockerignore b/rust-server/.dockerignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/rust-server/.dockerignore @@ -0,0 +1 @@ +target diff --git a/rust-server/.gitignore b/rust-server/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/rust-server/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/rust-server/Cargo.toml b/rust-server/Cargo.toml new file mode 100644 index 0000000..20a6acc --- /dev/null +++ b/rust-server/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rust-server" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rand = "0.8.5" +rocket = { version = "=0.5.0-rc.2", features = ["json"] } +async-trait = "0.1.56" + +[dependencies.sqlx] +version = "0.5.13" +default-features = false +features = ["runtime-tokio-native-tls", "macros", "offline", "any", "postgres", "mysql", "sqlite", "mssql"] diff --git a/rust-server/Dockerfile b/rust-server/Dockerfile new file mode 100644 index 0000000..3cc22e2 --- /dev/null +++ b/rust-server/Dockerfile @@ -0,0 +1,7 @@ +FROM rust:1.61 + +COPY ./ ./ +COPY Cargo.toml Cargo.toml + +RUN cargo build --release +CMD ["./target/release/rust-server"] diff --git a/rust-server/README.md b/rust-server/README.md new file mode 100644 index 0000000..05a506c --- /dev/null +++ b/rust-server/README.md @@ -0,0 +1,11 @@ +# Building + +Install rust using rustup [here](https://www.rust-lang.org/tools/install) + +Build the project using + +```rust +cargo build --release +``` + +The executable file will be generated at `target/release/` diff --git a/rust-server/src/database/mod.rs b/rust-server/src/database/mod.rs new file mode 100644 index 0000000..95f4efb --- /dev/null +++ b/rust-server/src/database/mod.rs @@ -0,0 +1,38 @@ +use async_trait::async_trait; +pub use sqlx::any::AnyPool as DBPool; + +pub mod sql; + +pub type QuizId = u64; + +/// The type of error that may occur when interacting +/// with the database +#[derive(Debug)] +pub enum DBError { + /// A game could not be created because another + /// with confliting properties already exists + QuizNotFound(QuizId), + /// The database is corrupted and could not be + /// processed properly + DBCorrupt(String), + /// Some other error occured + Other(Box), +} + +pub type DBResult = Result; + +/// Trait representing a handler for the database +#[async_trait] +pub trait DBAccessor: Send + Sync {} + +impl std::fmt::Display for DBError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::QuizNotFound(id) => write!(f, "Failed to find a quiz with id: {id}"), + Self::DBCorrupt(msg) => write!(f, "Database is corrupted: {msg}"), + Self::Other(error) => error.fmt(f), + } + } +} + +impl std::error::Error for DBError {} diff --git a/rust-server/src/database/sql.rs b/rust-server/src/database/sql.rs new file mode 100644 index 0000000..2e413aa --- /dev/null +++ b/rust-server/src/database/sql.rs @@ -0,0 +1,31 @@ +use super::*; + +use sqlx::AnyPool; + +/// Handles access to the postgres games database +pub struct SqlAccess { + pool: AnyPool, +} + +impl SqlAccess { + /// Sets up access to the database and initializes all the neccessary tables + pub async fn new(db_uri: &str) -> DBResult { + let pool = DBPool::connect(db_uri).await?; + let sql = Self { pool }.init().await?; + Ok(sql) + } + + /// Initialize all the neccessary database stuff + async fn init(self) -> DBResult { + Ok(self) + } +} + +#[async_trait] +impl DBAccessor for SqlAccess {} + +impl From for DBError { + fn from(error: sqlx::Error) -> Self { + Self::Other(Box::new(error)) + } +} diff --git a/rust-server/src/main.rs b/rust-server/src/main.rs new file mode 100644 index 0000000..d8b4f82 --- /dev/null +++ b/rust-server/src/main.rs @@ -0,0 +1,16 @@ +mod database; +mod server; + +#[cfg(test)] +mod tests; + +#[rocket::launch] +async fn launch() -> _ { + let config = rocket::Config { + log_level: rocket::log::LogLevel::Debug, + address: std::net::Ipv4Addr::new(0, 0, 0, 0).into(), + port: 8080, + ..Default::default() + }; + server::rocket(config, "postgresql://test:test@postgres_db:5432/test").await +} diff --git a/rust-server/src/server/games.rs b/rust-server/src/server/games.rs new file mode 100644 index 0000000..d303117 --- /dev/null +++ b/rust-server/src/server/games.rs @@ -0,0 +1,89 @@ +use rocket::serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Games { + map: HashMap, +} + +/// Represents different states of a game +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub enum GameState { + /// Initial state of the game + Lobby, + InProgress, + Finished, +} + +pub type GameCode = String; +pub type PlayerName = String; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Player {} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Game { + players: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct GameAnswer {} + +impl Games { + pub fn new() -> Self { + Self { + map: HashMap::new(), + } + } + + pub fn create_game(&mut self) -> GameCode { + let code = loop { + let code = game_code_generator(); + if !self.map.contains_key(&code) { + break code; + } + }; + let game = Game::new(); + let res = self.map.insert(code.clone(), game); + assert!(res.is_none()); + code + } + + pub fn get_game(&self, code: &GameCode) -> Option<&Game> { + self.map.get(code) + } + + pub fn get_game_mut(&mut self, code: &GameCode) -> Option<&mut Game> { + self.map.get_mut(code) + } +} + +impl Game { + pub fn new() -> Self { + Self { + players: HashMap::new(), + } + } + + pub fn player_join(&mut self, player_name: PlayerName) { + self.players.entry(player_name).or_insert_with(|| Player {}); + } +} + +pub fn game_code_generator() -> GameCode { + use rand::Rng; + const LEN: usize = 4; + + let mut rng = rand::thread_rng(); + let mut code = String::new(); + for _ in 0..LEN { + let symbol = rng.gen_range('A'..='Z'); + code.push(symbol); + } + code +} diff --git a/rust-server/src/server/mod.rs b/rust-server/src/server/mod.rs new file mode 100644 index 0000000..7d8452b --- /dev/null +++ b/rust-server/src/server/mod.rs @@ -0,0 +1,140 @@ +use std::sync::Mutex; + +use rocket::{get, post, put, serde::json::Json, State}; + +use crate::database::{DBAccessor, DBError}; + +mod games; + +use games::*; + +type Database = Box; +type SResult = Result; +type GamesState = Mutex; + +#[derive(Debug)] +enum Error { + Database(DBError), + GameNotFound(GameCode), + BadRequest, +} + +pub async fn rocket(config: rocket::Config, db_uri: &str) -> rocket::Rocket { + let database = crate::database::sql::SqlAccess::new(db_uri) + .await + .expect("Failed to access the database"); + rocket::custom(config) + .manage(Box::new(database) as Database) + .manage(Mutex::new(Games::new())) + .mount("/", rocket::routes![index, create_or_join_game, get_game]) +} + +#[get("/")] +fn index() -> &'static str { + "This server handles games\n" +} + +/// Create a new game from the quiz id +/// or join an existing game with the specified code +#[post("/games/", data = "")] +async fn create_or_join_game( + id: GameCode, + name: Option, + games: &State, + database: &State, +) -> Result { + match id.parse::() { + Ok(quiz_id) => create_game(quiz_id, games, database).await, + Err(_) => match name { + Some(name) => join_game(id, name, games).await.map(|()| format!("")), + None => Err(Error::BadRequest), + }, + } +} + +#[get("/games/")] +async fn get_game(code: GameCode, games: &State) -> SResult { + match games.lock().unwrap().get_game(&code) { + Some(_game) => Ok(code), + None => Err(Error::GameNotFound(code)), + } +} + +#[put("/games/", data = "")] +async fn game_answer(code: GameCode, answer: Json) -> SResult<()> { + // TODO: register answer + Ok(()) +} + +async fn create_game( + _quiz_id: u64, + games: &State, + _database: &State, +) -> SResult { + // TODO: query quiz information from the database + let code = games.lock().unwrap().create_game(); + Ok(code) +} + +async fn join_game( + game_code: GameCode, + player_name: PlayerName, + games: &State, +) -> SResult<()> { + match games.lock().unwrap().get_game_mut(&game_code) { + None => Err(Error::GameNotFound(game_code)), + Some(game) => { + game.player_join(player_name); + Ok(()) + } + } +} + +impl Error { + fn status(&self) -> rocket::http::Status { + use rocket::http::Status; + match self { + Self::Database(error) => error.status(), + Self::GameNotFound(_) => Status::NotFound, + Self::BadRequest => Status::BadRequest, + } + } +} + +impl DBError { + fn status(&self) -> rocket::http::Status { + use rocket::http::Status; + match self { + Self::QuizNotFound(_) => Status::NotFound, + Self::DBCorrupt(_) => Status::InternalServerError, + Self::Other(_) => Status::InternalServerError, + } + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Database(error) => error.fmt(f), + Self::GameNotFound(msg) => { + write!(f, "A game with the code \'{msg}\' could not be found") + } + Self::BadRequest => write!(f, "Bad request"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(error: DBError) -> Self { + Self::Database(error) + } +} + +impl<'r> rocket::response::Responder<'r, 'static> for Error { + fn respond_to(self, _request: &'r rocket::Request<'_>) -> rocket::response::Result<'static> { + println!("Encountered an error: {self}"); + Err(self.status()) + } +} diff --git a/rust-server/src/tests/end_to_end.rs b/rust-server/src/tests/end_to_end.rs new file mode 100644 index 0000000..fb10855 --- /dev/null +++ b/rust-server/src/tests/end_to_end.rs @@ -0,0 +1,29 @@ +use rocket::{ + http::Status, + local::asynchronous::{Client, LocalRequest, LocalResponse}, +}; + +use super::*; + +#[tokio::test] +async fn test() { + let config = rocket::Config { + log_level: rocket::log::LogLevel::Debug, + address: std::net::Ipv4Addr::new(127, 0, 0, 1).into(), + port: 8000, + ..Default::default() + }; + let rocket = crate::server::rocket(config, "postgresql://test:test@localhost:5432").await; + let client = Client::tracked(rocket).await.unwrap(); + + let response = request(client.get("/")).await; + + assert_eq!(response.status(), Status::Ok); +} + +async fn request<'c>(request: LocalRequest<'c>) -> LocalResponse<'c> { + println!("---\nProcessing: {request:?}"); + let response = request.dispatch().await; + println!("Got response:\n{response:?}\n---"); + response +} diff --git a/rust-server/src/tests/mod.rs b/rust-server/src/tests/mod.rs new file mode 100644 index 0000000..31e4e73 --- /dev/null +++ b/rust-server/src/tests/mod.rs @@ -0,0 +1,3 @@ +use rocket::tokio; + +mod end_to_end; diff --git a/web-app/Dockerfile b/web-app/Dockerfile new file mode 100644 index 0000000..dcfcd3a --- /dev/null +++ b/web-app/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.9.7 + +COPY requirements.txt ./ +RUN pip install -r requirements.txt +COPY ./ ./ +EXPOSE 8080 +ENTRYPOINT ["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=8888"] + +ENV PYTHONPATH=/web-app:/web-app/adapters:/web-app/entrypoints:/web-app/domain:/web-app/service_layer:$PYTHONPATH diff --git a/web-app/adapters/__init__.py b/web-app/adapters/__init__.py new file mode 100644 index 0000000..e5034e2 --- /dev/null +++ b/web-app/adapters/__init__.py @@ -0,0 +1,2 @@ +# static analysis: ignore[import_failed] +from . import orm, repository \ No newline at end of file diff --git a/web-app/adapters/orm.py b/web-app/adapters/orm.py new file mode 100644 index 0000000..a340c2a --- /dev/null +++ b/web-app/adapters/orm.py @@ -0,0 +1,67 @@ +# static analysis: ignore[import_failed] +from sqlalchemy import Column, MetaData, Integer, String, ForeignKey, Text, Enum, Table, Boolean +from sqlalchemy.orm import registry +from sqlalchemy.orm import mapper, relationship + +from domain import model + + +# Base = declarative_base() +mapper_registry = registry() +metadata = mapper_registry.metadata + +# relations between tables: +# - To find quizzes: search quizzes table by user_id +# - To find questions: search question table by quiz_id +# - To find answers: search answers table by question_id + +# TODO: proper comments for tables + +# Table for users +user_table = Table("users", metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('name', Text), + Column('surname', Text), + Column('email', Text, unique=True), + Column('password', Text)) + +# Table for quizzes +quiz_table = Table("quizzes", metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('user_id', Integer, ForeignKey('users.id', ondelete="CASCADE")), + Column('name', Text)) + +# Table for questions +question_table = Table("questions", metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('quiz_id', Integer, ForeignKey('quizzes.id', ondelete="CASCADE")), + Column('question_type', Text), + Column('text', Text), + Column('time', Integer)) + +# Table for answers +answer_table = Table("answers", metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('question_id', Integer, ForeignKey('questions.id', ondelete="CASCADE")), + Column('text', Text), + Column('correct_answer', Boolean)) + + +def start_mappers(): + question_mapper = mapper_registry.map_imperatively(model.Question, question_table) + user_mapper = mapper_registry.map_imperatively(model.User, user_table) + answer_mapper = mapper_registry.map_imperatively(model.Answer, answer_table) + quiz_mapper = mapper_registry.map_imperatively(model.Quiz, quiz_table) + + +def create_all(engine): + metadata.bind = engine + metadata.create_all() + + +def delete_all(engine): + metadata.drop_all(engine) + + +if __name__ == '__main__': + start_mappers() diff --git a/web-app/adapters/repository.py b/web-app/adapters/repository.py new file mode 100644 index 0000000..2fc2bf6 --- /dev/null +++ b/web-app/adapters/repository.py @@ -0,0 +1,178 @@ +# static analysis: ignore[import_failed] + +from domain import model + +__repo = None + + +# drafts +# class AbstractRepository(ABC): +# @abstractmethod +# def add(self, thing): +# raise NotImplementedError +# +# @abstractmethod +# def get(self, ref): +# raise NotImplementedError + + +# repository pattern to structure code +# SqlAlchemy repository to access db +# TODO: comments +# TODO: command for put, delete request +class SqlAlchemyRepository: + def __init__(self, session): + self.session = session + + def add_user(self, user: model.User): + self.session.add(user) + self.session.commit() + + def get_user_by_id(self, user_id): + return self.session.query(model.User).filter_by(id=user_id).one() + + def get_user_by_email(self, email): + return self.session.query(model.User).filter_by(email=email).one() + + def delete_user(self, user: model.User): + self.session.delete(user) + self.session.commit() + + # Working with quizzes + def add_quiz(self, quiz: model.Quiz) -> int: + self.session.add(quiz) + self.session.commit() + return quiz.id + + def get_quiz(self, quiz_id): + return self.session.query(model.Quiz).filter_by(id=quiz_id).one() + + def put_quiz(self, quiz: model.Quiz): + # self.session.query(model.Quiz).f + raise NotImplemented + + def delete_quiz(self, quiz: model.Quiz): + self.session.delete(quiz) + self.session.commit() + + def list_quizzes(self, user_id): + return self.session.query(model.Quiz).filter_by(user_id=user_id).all() + + # Working with questions + def add_question(self, question: model.Question): + self.session.add(question) + self.session.commit() + return question.id + + def get_question(self, question_id): + return self.session.query(model.Question).filter_by(id=question_id).one() + + def delete_question(self, question: model.Question): + self.session.delete(question) + self.session.commit() + + def list_questions(self, quiz_id): + return self.session.query(model.Question).filter_by(quiz_id=quiz_id).all() + + # Working with answers + def add_answer(self, answer: model.Answer): + self.session.add(answer) + self.session.commit() + return answer.id + + def get_answer(self, answer_id): + return self.session.query(model.Answer).filter_by(id=answer_id).one() + + def delete_answer(self, answer: model.Answer): + self.session.delete(answer) + self.session.commit() + + def list_answers(self, question_id): + return self.session.query(model.Answer).filter_by(question_id=question_id).all() + + # -- for test + def get_users(self): + return self.session.query(model.User).all() + + def get_quizzes(self): + return self.session.query(model.Quiz).all() + + def get_answers(self): + return self.session.query(model.Answer).all() + + def get_questions(self): + return self.session.query(model.Question).all() + + +# Testing Repository +class FakeRepository: + def add_user(self, user: model.Quiz): + print(f"Adding user {user}") + + def get_user(self, user_id): + print(f"Getting user {user_id}") + + # Working with quizzes + def add_quiz(self, quiz: model.Quiz): + print(f"Adding quiz {quiz}") + + def get_quiz(self, quiz_id): + print(f"Getting quiz {quiz_id}") + + def list_quizzes(self, user_id): + print(f"Getting list of quizzes of user {user_id}") + + def put_quiz(self, quiz: model.Quiz): + print(f"Changing quiz using {quiz}") + + def delete_quiz(self, quiz_id): + print(f"deletion of quiz {quiz_id}") + + # Working with questions + def add_question(self, question: model.Question): + print(f"Adding question {question}") + + def get_question(self, question_id): + print(f"Getting question {question_id}") + + def list_questions(self, quiz_id): + print(f"Getting list of question of quiz {quiz_id}") + + def put_question(self, question: model.Question): + print(f"Changing quiz using {question}") + + def delete_question(self, question_id): + print(f"deletion of question {question_id}") + + # Working with answers + def add_answer(self, answer: model.Answer): + print(f"Adding answer {answer}") + + def get_answer(self, answer_id): + print(f"Getting answer {answer_id}") + + def list_answers(self, question_id): + print(f"Getting list of answers of question {question_id}") + + def put_answer(self, answer: model.Quiz): + print(f"Changing answer using {answer}") + + def delete_answer(self, answer_id): + print(f"deletion of answer {answer_id}") + + +# Testing +__repo = FakeRepository() + + +# To access repo from different modules +def initialize_repo(session): + global __repo + + if not __repo or __repo.__class__ == FakeRepository: + __repo = SqlAlchemyRepository(session) + + +def get_repo(): + global __repo + return __repo diff --git a/web-app/app_start.py b/web-app/app_start.py new file mode 100644 index 0000000..eb07319 --- /dev/null +++ b/web-app/app_start.py @@ -0,0 +1,5 @@ +# static analysis: ignore[import_failed] +from entrypoints.flask_app import app + +if __name__ == "__main__": + app.run() diff --git a/web-app/config.py b/web-app/config.py new file mode 100644 index 0000000..cf9f1d3 --- /dev/null +++ b/web-app/config.py @@ -0,0 +1,2 @@ +def get_postgres_uri(): + return "postgresql://test:test@postgres_db:5432/test" diff --git a/web-app/domain/__init__.py b/web-app/domain/__init__.py new file mode 100644 index 0000000..48d0fae --- /dev/null +++ b/web-app/domain/__init__.py @@ -0,0 +1,2 @@ +# static analysis: ignore[import_failed] +from . import model \ No newline at end of file diff --git a/web-app/domain/model.py b/web-app/domain/model.py new file mode 100644 index 0000000..b20ada2 --- /dev/null +++ b/web-app/domain/model.py @@ -0,0 +1,86 @@ +import enum +from typing import List, NewType + +# Types declaration + +# For user +Name = NewType('Name', str) +Surname = NewType('Surname', str) +Email = NewType('Email', str) +Password = NewType('Password', str) + +# For quiz +QuizId = NewType('QuizId', int) +QuizName = NewType('QuizName', str) + +# For question +QuestionAnswer = NewType('QuestionAnswers', str) +QuestionDescription = NewType('QuestionDescription', str) + + +class Answer: + def __init__(self, question_id: int, text: QuestionAnswer, correct_answer: bool): + self.text = text + self.correct_answer = correct_answer + self.question_id = question_id + + def to_dict(self): + return { + "text": self.text, + "correct_answer": self.correct_answer, + "question_id": self.question_id + } + + +# Possible types of question in a quiz +class QuestionTypes(enum.Enum): + poll = "poll" + quiz = "quiz" + + +# Question class, to store information about question: +# question type, description, possible answers, and correct answers +class Question: + def __init__(self, question_type: str, text: QuestionDescription, quiz_id: int, time_limit: int): + self.question_type = question_type + self.text = text + self.quiz_id = quiz_id + self.time = time_limit + + def to_dict(self): + return { + "quiz_id": self.quiz_id, + "text": self.text, + "question_type": self.question_type, + "time": self.time + } + + +# Quiz class, +class Quiz: + def __init__(self, quiz_name: QuizName, user_id: int = 0): + self.name = quiz_name + self.user_id = user_id + + def to_dict(self): + return { + "user_id": self.user_id, + "name": self.name + } + + +# User class, to manage stored in db information through nickname/email +class User: + def __init__(self, name: Name, surname: Surname, email: Email, password: Password): + self.name = name + self.surname = surname + self.email = email + self.password = password + + def to_dict(self): + return { + "name": self.name, + "surname": self.surname, + "email": self.email, + "password": self.password + } diff --git a/web-app/entrypoints/__init__.py b/web-app/entrypoints/__init__.py new file mode 100644 index 0000000..1a975b4 --- /dev/null +++ b/web-app/entrypoints/__init__.py @@ -0,0 +1 @@ +# static analysis: ignore[import_failed] diff --git a/web-app/entrypoints/api/__init__.py b/web-app/entrypoints/api/__init__.py new file mode 100644 index 0000000..1a975b4 --- /dev/null +++ b/web-app/entrypoints/api/__init__.py @@ -0,0 +1 @@ +# static analysis: ignore[import_failed] diff --git a/web-app/entrypoints/api/api_initialization.py b/web-app/entrypoints/api/api_initialization.py new file mode 100644 index 0000000..0660342 --- /dev/null +++ b/web-app/entrypoints/api/api_initialization.py @@ -0,0 +1,17 @@ +# static analysis: ignore[import_failed] +from entrypoints.api.resource import AnswerResource, QuizResource, QuestionResource, UserResource + +from flask_restful import Api + + +def api_initialization(app) -> Api: + api = Api(app) + api.add_resource(UserResource.UserLoginResource, '/api/user/login') + api.add_resource(UserResource.UserRegistrationResource, '/api/user/register') + + api.add_resource(QuizResource.QuizResource, '/api/user//quiz/') + api.add_resource(QuizResource.QuizListResource, '/api/user//quiz') + + api.add_resource(QuestionResource.QuestionResource, '/api/quiz//question/') + + return api diff --git a/web-app/entrypoints/api/resource/AnswerResource.py b/web-app/entrypoints/api/resource/AnswerResource.py new file mode 100644 index 0000000..474772f --- /dev/null +++ b/web-app/entrypoints/api/resource/AnswerResource.py @@ -0,0 +1,12 @@ +# static analysis: ignore[import_failed] +from flask import jsonify +from flask_restful import reqparse, abort, Resource +from adapters.repository import SqlAlchemyRepository, get_repo +from domain.model import Answer + + +class AnswerResource(Resource): + def get(self, answer_id): + repo = get_repo() + answer = repo.get_answer(answer_id) + return answer diff --git a/web-app/entrypoints/api/resource/QuestionResource.py b/web-app/entrypoints/api/resource/QuestionResource.py new file mode 100644 index 0000000..edbc3ea --- /dev/null +++ b/web-app/entrypoints/api/resource/QuestionResource.py @@ -0,0 +1,22 @@ +# static analysis: ignore[import_failed] +from flask import jsonify +from flask_restful import reqparse, abort, Resource +from adapters.repository import SqlAlchemyRepository, get_repo +from domain.model import Question, QuestionTypes + +parser = reqparse.RequestParser() +parser.add_argument('answer', required=True) + + +class QuestionResource(Resource): + def get(self, quiz_id, question_id): + repo = get_repo() + question = repo.get_question(question_id) + return question + + def post(self, quiz_id, question_id): + args = parser.parse_args() + repo = get_repo() + + chosen_answer = args['answer'] + # TODO: Find all answers to questions, and find the chosen one diff --git a/web-app/entrypoints/api/resource/QuizResource.py b/web-app/entrypoints/api/resource/QuizResource.py new file mode 100644 index 0000000..827cdee --- /dev/null +++ b/web-app/entrypoints/api/resource/QuizResource.py @@ -0,0 +1,138 @@ +# static analysis: ignore[import_failed] +from flask import jsonify, make_response +from flask_restful import reqparse, abort, Resource +from adapters.repository import SqlAlchemyRepository, get_repo + +from domain.model import Quiz + +from service_layer import services + +quiz_creation_parser = reqparse.RequestParser() +quiz_creation_parser.add_argument('name', required=True) +quiz_creation_parser.add_argument('questions', type=dict, action="append", required=True) + + +def check_for_credentials(args: dict): + email, password = services.email_and_pass_from(args) + + repo = get_repo() + user = repo.get_user_by_email(email) + + if not user: + abort(404, message="Unauthorized") + + if not user.password == password: + abort(404, message="Unauthorized") + + +def abort_if_user_not_found(user_id: int): + repo = get_repo() + user = repo.get_user_by_id(user_id) + if not user: + abort(404, message="Unauthorized") + + +def abort_if_quiz_not_found(quiz_id): + repo = get_repo() + quiz = repo.get_quiz(quiz_id) + + if not quiz: + abort(404, message="Quiz is not found") + + +def create_quiz(user_id, args): + abort_if_user_not_found(user_id) + + repo = get_repo() + + quiz = services.quiz_from(args, user_id) + quiz_id = repo.add_quiz(quiz) + + questions_and_answers = services.questions_of_quiz_creation_from(args, quiz_id) + + for (question, answers_args) in questions_and_answers: + question_id = repo.add_question(question) + for answers in services.answers_for_one_question_of_quiz_creation_from(answers_args, question_id): + repo.add_answer(answers) + + +class QuizResource(Resource): + def get(self, user_id, quiz_id): + abort_if_quiz_not_found(quiz_id) + + repo = get_repo() + quiz = repo.get_quiz(quiz_id) + + if quiz.user_id != user_id: + abort(403, message="You are not allowed to get this information") + + questions = repo.list_questions(quiz_id) + questions_list = [] + # assert len(questions) != 0 + for question in questions: + questions_list.append(question.to_dict()) + + answers_list = [] + answers = repo.list_answers(question.id) + for answer in answers: + answers_list.append(answer.to_dict()) + questions_list[-1]["answers"] = answers_list + + quiz_dict = quiz.to_dict() + quiz_dict["questions"] = questions_list + return make_response(quiz_dict, 200) + + def put(self, user_id, quiz_id): + abort_if_quiz_not_found(quiz_id) + + args = quiz_creation_parser.parse_args() + repo = get_repo() + quiz = repo.get_quiz(quiz_id) + + if quiz.user_id != user_id: + abort(403, message="You are not allowed to perform this action") + + repo.delete_quiz(quiz) + create_quiz(user_id, args) + + return make_response("Quiz recreated", 200) + + def delete(self, user_id, quiz_id): + abort_if_quiz_not_found(quiz_id) + + repo = get_repo() + + quiz = repo.get_quiz(quiz_id) + + if quiz.user_id != user_id: + abort(403, message="You are not allowed to perform this action") + + repo.delete_quiz(quiz) + + return make_response("Quiz deleted", 200) + + +class QuizListResource(Resource): + def get(self, user_id): + abort_if_user_not_found(user_id) + + repo = get_repo() + quizzes = repo.list_quizzes(user_id) + + user = repo.get_user_by_id(user_id) + + quizzes_as_dict = [] + + for quiz in quizzes: + quiz_as_dict = quiz.to_dict + quiz_as_dict["quiz_id"] = quiz.id + quizzes_as_dict.append(dicted_quiz) + + return make_response( + {"quizzes": dicted_quizzes, "quiz_number": len(quizzes), "user": user.to_dict()}, 200) + + def post(self, user_id): + args = quiz_creation_parser.parse_args() + + create_quiz(user_id, args) + return make_response("Quiz created", 201) diff --git a/web-app/entrypoints/api/resource/UserResource.py b/web-app/entrypoints/api/resource/UserResource.py new file mode 100644 index 0000000..e85f051 --- /dev/null +++ b/web-app/entrypoints/api/resource/UserResource.py @@ -0,0 +1,80 @@ +# static analysis: ignore[import_failed] +import flask +from flask import jsonify, make_response +from flask_restful import reqparse, abort, Resource +from adapters.repository import SqlAlchemyRepository, get_repo + +from domain.model import User + +from service_layer import services + +from sqlalchemy.exc import IntegrityError + +login_parser = reqparse.RequestParser() +login_parser.add_argument('email', required=True) +login_parser.add_argument('password', required=True) + +registration_parser = reqparse.RequestParser() +registration_parser.add_argument('name', required=True) +registration_parser.add_argument('surname', required=True) +registration_parser.add_argument('email', required=True) +registration_parser.add_argument('password', required=True) + + +def check_for_credentials(args: dict) -> int: + email, password = services.email_and_pass_from(args) + + repo = get_repo() + user = repo.get_user_by_email(email) + + if not user: + abort(404, message="Unauthorized") + + if not user.password == password: + abort(404, message="Unauthorized") + + return user.id + + +def abort_if_user_already_exists(email): + repo = get_repo() + + user = repo.get_user_by_email(email) + + if user: + abort(403, message="User already exists") + + +def abort_if_user_not_found(user_id: int, args: dict): + email, password = services.email_and_pass_from(args) + + repo = get_repo() + user = repo.get_user_by_id(user_id) + if not user or user.email != email or user.password != password: + abort(404, message="Unauthorized") + + +class UserLoginResource(Resource): + def post(self): + args = login_parser.parse_args() + + user_id = check_for_credentials(args) + + return {"user_id": user_id} + + +class UserRegistrationResource(Resource): + def post(self): + args = registration_parser.parse_args() + repo = get_repo() + + user = services.user_from(args) + + # abort_if_user_already_exists(user.email) + try: + repo.add_user(user) + return make_response("User created", 201) + + except IntegrityError: + repo.session.rollback() + return make_response("User already exists", 403) diff --git a/web-app/entrypoints/api/resource/__init__.py b/web-app/entrypoints/api/resource/__init__.py new file mode 100644 index 0000000..1a975b4 --- /dev/null +++ b/web-app/entrypoints/api/resource/__init__.py @@ -0,0 +1 @@ +# static analysis: ignore[import_failed] diff --git a/web-app/entrypoints/flask_app.py b/web-app/entrypoints/flask_app.py new file mode 100644 index 0000000..a701e53 --- /dev/null +++ b/web-app/entrypoints/flask_app.py @@ -0,0 +1,98 @@ +# static analysis: ignore[import_failed] +import json + +from flask import Flask, request, render_template +from flask_cors import CORS +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import config +from domain import model +from adapters import orm, repository +from service_layer import services +from entrypoints.api import api_initialization +from tests import test_orm + +engine = create_engine(config.get_postgres_uri()) +get_session = sessionmaker(bind=engine) +repository.initialize_repo(get_session()) +orm.start_mappers() + +# To delete all tables +# orm.delete_all(engine) + +orm.create_all(engine) +app = Flask(__name__, template_folder='./../static/templates') +api = api_initialization.api_initialization(app) +cors = CORS(app, resources={r"/api/*": {"origins": "*"}}) + + +@app.route('/') +def main_page(): + return render_template("main_page.html") + + +@app.route('/login') +def login_page(): + return render_template('authorization_page.html') + + +@app.route('/register') +def registration_page(): + return render_template("registration_page.html") + + +# [will be deleted] +@app.route('/lobby') +def lobby_page(): + return render_template("waiting_hall.html") + + +# For tests through docker +@app.route('/tests/users') +def test(): + repo = repository.get_repo() + + new_items = list() + for item in repo.get_users(): + new_items.append(item.to_dict()) + + return json.dumps({"items": new_items}) + + +@app.route('/tests/user_creation') +def test_user_creation(): + repo = repository.get_repo() + + test_orm.test_add_user(repo) + + return "

OK

" + + +@app.route('/tests/quizzes') +def test_quizzes_list(): + repo = repository.get_repo() + + new_items = list() + for item in repo.get_quizzes(): + new_items.append(item.to_dict()) + + return json.dumps({"items": new_items}) + + +@app.route('/tests/get_user_by_email/') +def test_user_by_email(email): + repo = repository.get_repo() + + user = repo.get_user_by_email(email) + return json.dumps({"user": user.to_dict()}) + + +@app.route('/tests/answers') +def test_answers_list(): + repo = repository.get_repo() + + dict_items = [] + for item in repo.get_answers(): + dict_items.append(item.to_dict()) + return json.dumps({"answers": dict_items}) diff --git a/web-app/main.py b/web-app/main.py new file mode 100644 index 0000000..18dac90 --- /dev/null +++ b/web-app/main.py @@ -0,0 +1,4 @@ +# static analysis: ignore[import_failed] +from entrypoints import flask_app + +flask_app.main() diff --git a/web-app/requirements.txt b/web-app/requirements.txt new file mode 100644 index 0000000..60e6639 --- /dev/null +++ b/web-app/requirements.txt @@ -0,0 +1,17 @@ +aniso8601==9.0.1 +click==8.1.3 +Flask==2.1.2 +Flask-RESTful==0.3.9 +greenlet==1.1.2 +importlib-metadata==4.11.4 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.1 +pytz==2022.1 +six==1.16.0 +SQLAlchemy==1.4.37 +waitress==2.1.2 +Werkzeug==2.1.2 +zipp==3.8.0 +psycopg2==2.9.3 +flask-cors==3.0.10 diff --git a/web-app/service_layer/__init__.py b/web-app/service_layer/__init__.py new file mode 100644 index 0000000..1a975b4 --- /dev/null +++ b/web-app/service_layer/__init__.py @@ -0,0 +1 @@ +# static analysis: ignore[import_failed] diff --git a/web-app/service_layer/services.py b/web-app/service_layer/services.py new file mode 100644 index 0000000..aeef318 --- /dev/null +++ b/web-app/service_layer/services.py @@ -0,0 +1,77 @@ +# static analysis: ignore[import_failed] +from domain import model + + +# user_from gets args and creates User class +# user_args should contain following: +# name +# surname +# email +# password +def user_from(user_args: dict) -> model.User: + user_name = user_args['name'] + user_surname = user_args['surname'] + user_email = user_args['email'] + user_password = user_args['password'] + + return model.User( + name=user_name, + surname=user_surname, + email=user_email, + password=user_password + ) + + +def quiz_from(quiz_args: dict, user_id: int) -> model.Quiz: + quiz = model.Quiz( + quiz_name=quiz_args["name"], + user_id=user_id + ) + return quiz + + +def questions_of_quiz_creation_from(quiz_args: dict, quiz_id: int) -> [(model.Question, dict)]: + questions = [ + (model.Question( + question_type=question_args["question_type"], + text=question_args["text"], + time_limit=question_args["time"], + quiz_id=quiz_id + ), question_args["answers"]) + for question_args in quiz_args["questions"] + ] + # return quiz_args["questions"] + # for question_args in quiz_args["questions"]: + # questions.append((model.Question( + # question_type=question_args["question_type"], + # text=question_args["text"], + # time_limit=question_args["time"], + # quiz_id=quiz_id), + # question_args["answers"])) + + return questions + + +def answers_for_one_question_of_quiz_creation_from(answers_args: dict, question_id) -> [model.Answer]: + answers = [ + model.Answer( + text=answer_args["text"], + correct_answer=answer_args["correct_answer"], + question_id=question_id + ) + for answer_args in answers_args + ] + return answers + + +def email_and_pass_from(args: dict) -> (str, str): + return args['email'], args['password'] + + +def user_from(user_args) -> model.User: + return model.User( + name=user_args["name"], + surname=user_args["surname"], + email=user_args["email"], + password=user_args["password"] + ) diff --git a/web-app/static/images/Google_icon.png b/web-app/static/images/Google_icon.png new file mode 100644 index 0000000..7a05766 Binary files /dev/null and b/web-app/static/images/Google_icon.png differ diff --git a/web-app/static/images/inno_icon.png b/web-app/static/images/inno_icon.png new file mode 100644 index 0000000..f23c2b3 Binary files /dev/null and b/web-app/static/images/inno_icon.png differ diff --git a/web-app/static/images/logo.png b/web-app/static/images/logo.png new file mode 100644 index 0000000..223f13f Binary files /dev/null and b/web-app/static/images/logo.png differ diff --git a/web-app/static/scripts/jquery-1.10.1.min.js b/web-app/static/scripts/jquery-1.10.1.min.js new file mode 100644 index 0000000..e69de29 diff --git a/web-app/static/scripts/jquery-3.6.0.min.js b/web-app/static/scripts/jquery-3.6.0.min.js new file mode 100644 index 0000000..c4c6022 --- /dev/null +++ b/web-app/static/scripts/jquery-3.6.0.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="
",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 + + + + + + + + Document + + +
+
+

Autorithation

+ + +

OR

+
+ + +
+
+ +
+ + + + \ No newline at end of file diff --git a/web-app/static/templates/main_page.html b/web-app/static/templates/main_page.html new file mode 100644 index 0000000..5c8c199 --- /dev/null +++ b/web-app/static/templates/main_page.html @@ -0,0 +1,56 @@ + + + + + + + + + + Document + + + + + +
+ +
+
+ +
+
+ + + +
+ + +
+ +
+ + +
+
+
+ Join Quiz! + +
+ + +

OR

+ +
+
+ + +
+ + +
+ + + + + \ No newline at end of file diff --git a/web-app/static/templates/page_of_answer.html b/web-app/static/templates/page_of_answer.html new file mode 100644 index 0000000..a83b5e4 --- /dev/null +++ b/web-app/static/templates/page_of_answer.html @@ -0,0 +1,24 @@ + + + +page_of_answer + + + + +
+TimeScale +
+
Task
+
+ + + + +
+ + + + \ No newline at end of file diff --git a/web-app/static/templates/page_of_create.html b/web-app/static/templates/page_of_create.html new file mode 100644 index 0000000..28225e8 --- /dev/null +++ b/web-app/static/templates/page_of_create.html @@ -0,0 +1,34 @@ + + + +page_of_answer + + + + + +
+

Header

+
+

Name_of_quiz

+ +
+
Block
+
Block
+
+ +
+ + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/web-app/static/templates/page_of_user.html b/web-app/static/templates/page_of_user.html new file mode 100644 index 0000000..e69de29 diff --git a/web-app/static/templates/registration_page.html b/web-app/static/templates/registration_page.html new file mode 100644 index 0000000..9d5a08e --- /dev/null +++ b/web-app/static/templates/registration_page.html @@ -0,0 +1,59 @@ + + + + + + + + + + Document + + + +
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ Registration + +
+ + +
+ + +
+ + +
+

OR

+
+ + +
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/web-app/static/templates/waiting_hall.html b/web-app/static/templates/waiting_hall.html new file mode 100644 index 0000000..a80ccab --- /dev/null +++ b/web-app/static/templates/waiting_hall.html @@ -0,0 +1,47 @@ + + + + + + Bootstrap demo + + + + + + +
+
+

Intaby

+ +
+ + + +
+
+
+
+ Name of quiz +
+
+ Stars in _ +
+
+
+
+
+ Count of people +
+
+ +
+ +
+ +
+ + + \ No newline at end of file diff --git a/web-app/tests/test_api.py b/web-app/tests/test_api.py new file mode 100644 index 0000000..8410451 --- /dev/null +++ b/web-app/tests/test_api.py @@ -0,0 +1,144 @@ +# static analysis: ignore[import_failed] +import json + +from requests import get, post, delete, put + + +def test_quizzes_post_request(): + answers1 = [ + {"text": "Hello", "correct_answer": True}, + {"text": "World", "correct_answer": False} + ] + question1 = {"question_type": "poll", + "text": "questions1", + "time": 60, + "answers": answers1} + + answers2 = [ + {"text": "Hello", "correct_answer": True}, + {"text": "World", "correct_answer": False} + ] + + question2 = { + "question_type": "quiz", + "text": "questions2", + "time": 15, + "answers": answers2 + } + + quiz_post_request_body = { + "questions": [question1, question2], + "name": "Quiz name" + } + + # print(type(quiz_post_request_body["questions"])) + + url = "http://127.0.0.1:8888/api/user/1/quiz" + assert post(url, json=quiz_post_request_body, + headers={"Content-Type": "application/json"}).status_code == 201 + + +def test_quiz_get_request(): + url = "http://127.0.0.1:8888/api/user/1/quiz/1" + assert get(url).status_code == 200 + + +# def test_quiz_delete_request(): +# quiz_post_request_body = { +# "questions": {"question1": {"Hello": True, "World": True}, "question2": {"Hello": True, "World": True}}, +# "name": "Quiz name" +# } +# url = "http://127.0.0.1:8888/api/user/1/quiz" +# +# post(url, data=json.dumps(quiz_post_request_body), headers={"Content-Type": "application/json"}) +# +# assert delete(url + "/1").status_code == 200 + + +def test_quizzes_get_request(): + url = "http://127.0.0.1:8888/api/user/1/quiz" + + assert get(url).status_code == 200 + + +def test_quiz_put_request(): + url = "http://127.0.0.1:8888/api/user/1/quiz/1" + + answers1 = [ + {"text": "Hello", "correct_answer": False}, + {"text": "Worldzzz", "correct_answer": False} + ] + question1 = {"question_type": "poll", + "text": "questions1", + "time": 60, + "answers": answers1} + + answers2 = [ + {"text": "Hello", "correct_answer": True}, + {"text": "World", "correct_answer": False} + ] + + question2 = { + "question_type": "quiz", + "text": "questions2zzz", + "time": 15, + "answers": answers2 + } + + quiz_put_request_body = { + "questions": [question1, question2], + "name": "Quiz recreated name" + } + + assert put(url, json=quiz_put_request_body, headers={"Content-Type": "application/json"}).status_code == 200 + + +def test_user_login_request(): + url = "http://127.0.0.1:8888/api/user/login" + + body = {"email": "vikochka_kruk@mail.ru", + "password": "123"} + + assert post(url, json=body).status_code == 200 + + +def test_user_post_request(): + url = "http://127.0.0.1:8888/api/user/register" + + user_post_request_body = { + 'name': 'Il', + 'surname': 'hello', + 'email': 'il@k.r', + 'password': 'strong_pass' + } + assert post(url, json=user_post_request_body, headers={"Content-Type": "application/json"}).status_code == 201 + + +def test_question_get_request(): + url = "http://127.0.0.1:8888/api/quiz/1/question/2" + assert get(url).status_code == 200 + + +def test_question_post_request(): + url = "http://127.0.0.1:8888/api/quiz/1/question/2" + + question_post_request_body = {"answers": ["Incorrect answer", "Correct answer"]} + assert post(url, json=question_post_request_body, headers={"Content-Type": "application/json"}).status_code == 201 + + +def test_quiz_delete_request(): + url = "http://127.0.0.1:8888/api/user/1/quiz/1" + + assert delete(url).status_code == 200 + + +# print(test_user_post_request()) +# print(test_quiz_delete_request()) +# print(test_quiz_put_request()) +# print(test_quizzes_post_request()) +# print(test_quiz_get_request()) +# print(test_user_login_request()) +# print(test_question_get_request()) +# print(test_user_get_request()) + +# print(str([{"Hello": True, "World": False}])) diff --git a/web-app/tests/test_orm.py b/web-app/tests/test_orm.py new file mode 100644 index 0000000..40e5d27 --- /dev/null +++ b/web-app/tests/test_orm.py @@ -0,0 +1,68 @@ +# static analysis: ignore[import_failed] +from adapters import repository +from domain import model + + +# Unit tests can be written only for user or quiz (With user) +# Because we use foreign key to link all info in db + +def test_add_user(repo): + new_user = model.User("Fedor", "Surname", "fedya@gg.ru", "CoolPass") + + repo.add_user(new_user) + db_user = repo.session.query(model.User).filter_by(email=new_user.email).one() + + assert db_user == new_user + + repo.delete_user(new_user) + + +def test_add_and_quiz(repo): + new_user = model.User("Fedor", "Surname", "fedya@gg.ru", "CoolPass") + user_id = repo.add_user(new_user) + + new_quiz = model.Quiz(quiz_name="Quiz name", user_id=user_id) + quiz_id = repo.add_quiz(new_quiz) + + db_quiz = repo.get_quiz(quiz_id) + + assert db_quiz == new_quiz + + repo.delete_user(new_user) + + +def test_add_and_question(repo): + new_user = model.User("Fedor", "Surname", "fedya@gg.ru", "CoolPass") + user_id = repo.add_user(new_user) + + new_quiz = model.Quiz(quiz_name="Quiz name", user_id=user_id) + quiz_id = repo.add_quiz(new_quiz) + + question = model.Question(question_type="poll", text="questions1", time_limit=60, quiz_id=quiz_id) + question_id = repo.add_questions(question1) + + db_question = repo.get_question(question_id) + + assert db_question == question + + repo.delete_user(new_user) + + +def test_add_and_answer(repo): + new_user = model.User("Fedor", "Surname", "fedya@gg.ru", "CoolPass") + user_id = repo.add_user(new_user) + + new_quiz = model.Quiz(quiz_name="Quiz name", user_id=user_id) + quiz_id = repo.add_quiz(new_quiz) + + question = model.Question(question_type="poll", text="questions1", time_limit=60, quiz_id=quiz_id) + question_id = repo.add_questions(question) + + answer = model.Answer(text="Hello", correct_answer=True, question_id=question_id) + answer_id = repo.add_answer(answer) + + db_answer = repo.get_answer(answer_id) + + assert db_answer == answer + + repo.delete_user(new_user)