From 55e1a1baefefd0d141f0cec3008f30bc8148ff1d Mon Sep 17 00:00:00 2001 From: Kilton937342 Date: Fri, 16 Sep 2022 21:50:55 +0200 Subject: [PATCH] first commit --- .gitignore | 6 + backend/api/config.py | 20 + backend/api/conftest.py | 43 + backend/api/database/auth/crud.py | 74 + backend/api/database/auth/models.py | 76 + backend/api/database/db.py | 17 + backend/api/database/exercices/FileField.py | 46 + backend/api/database/exercices/crud.py | 172 +++ backend/api/database/exercices/models.py | 184 +++ backend/api/database/room/crud.py | 6 + backend/api/database/room/models.py | 49 + backend/api/dbo/auth/crud.py | 30 + backend/api/dbo/auth/models.py | 22 + backend/api/dbo/db.sqlite3 | Bin 0 -> 45056 bytes backend/api/dbo/exercices/crud.py | 106 ++ backend/api/dbo/exercices/models.py | 105 ++ backend/api/dbo/rooms/crud.py | 106 ++ backend/api/dbo/rooms/models.py | 74 + backend/api/dbo/utils/ExoSourceValidator.py | 19 + backend/api/dbo/utils/FileField.py | 61 + backend/api/generateur/generateur_csv.py | 72 + backend/api/generateur/generateur_main.py | 49 + backend/api/main.py | 138 ++ backend/api/routes/auth/routes.py | 87 ++ backend/api/routes/base.py | 10 + backend/api/routes/exercices/routes.py | 183 +++ backend/api/routes/exercices/routes_old.py | 247 +++ backend/api/routes/room/routes.py | 7 + backend/api/routes/rooms/routes.py | 24 + backend/api/schemas/base.py | 7 + backend/api/schemas/exercices.py | 33 + backend/api/schemas/rooms.py | 17 + backend/api/schemas/users.py | 58 + backend/api/services/auth.py | 63 + backend/api/services/database.py | 15 + backend/api/services/exoValidation.py | 101 ++ backend/api/services/io.py | 62 + backend/api/services/jwt.py | 15 + backend/api/services/password.py | 23 + backend/api/services/schema.py | 32 + backend/api/services/timeout.py | 21 + backend/api/testing.py | 108 ++ backend/api/tests/test_auth.py | 249 +++ backend/api/tests/test_exos.py | 481 ++++++ .../tests/testing_exo_source/exo_source.py | 11 + .../testing_exo_source/exo_source_invalid.py | 11 + .../exo_source_missing_main.py | 1 + backend/api_old/apis/__init__.py | 0 backend/api_old/apis/auth/route_auth.py | 140 ++ backend/api_old/apis/base.py | 13 + backend/api_old/apis/exercices/__init__.py | 0 .../api_old/apis/exercices/route_exercices.py | 165 ++ backend/api_old/apis/room/route_room.py | 64 + backend/api_old/apis/room/websocket.py | 315 ++++ backend/api_old/config.py | 58 + backend/api_old/database/__init__.py | 0 backend/api_old/database/auth/crud.py | 40 + backend/api_old/database/auth/models.py | 22 + backend/api_old/database/db.sqlite3 | Bin 0 -> 110592 bytes backend/api_old/database/db.sqlite3-shm | Bin 0 -> 32768 bytes backend/api_old/database/db.sqlite3-wal | Bin 0 -> 12392 bytes backend/api_old/database/decorators.py | 32 + backend/api_old/database/exercices/crud.py | 102 ++ .../api_old/database/exercices/customField.py | 62 + backend/api_old/database/exercices/models.py | 106 ++ .../api_old/database/exercices/validators.py | 93 ++ backend/api_old/database/main.py | 79 + backend/api_old/database/room/crud.py | 90 ++ backend/api_old/database/room/models.py | 72 + backend/api_old/db.sqlite3 | Bin 0 -> 28672 bytes backend/api_old/generateur/generateur_csv.py | 72 + backend/api_old/generateur/generateur_main.py | 49 + backend/api_old/index.html | 135 ++ backend/api_old/main.py | 127 ++ backend/api_old/pyproject.toml | 4 + backend/api_old/schema/user.py | 47 + backend/api_old/services/auth.py | 138 ++ backend/api_old/services/io.py | 56 + backend/api_old/services/jwt.py | 14 + backend/api_old/services/password.py | 24 + backend/api_old/services/timeout.py | 21 + backend/api_old/tests/__init__.py | 0 backend/api_old/tests/conftest.py | 49 + backend/api_old/tests/test_exercices.py | 105 ++ backend/api_old/uploads/ERSIWQ/1test_model.py | 10 + backend/api_old/uploads/JZJGJR/1test_model.py | 10 + backend/api_old/uploads/NAZABB/1test_model.py | 10 + backend/api_old/uploads/SSJCKT/1test_model.py | 10 + backend/api_old/uploads/TVLLES/1test_model.py | 10 + backend/api_old/uploads/UAPHYF/1test_model.py | 10 + frontend/README.md | 34 + frontend/index.html | 15 + frontend/package.json | 33 + frontend/pnpm-lock.yaml | 1336 +++++++++++++++++ frontend/src/App.module.css | 33 + frontend/src/App.tsx | 65 + frontend/src/apis/auth.instance.js | 12 + frontend/src/apis/exoInstance.instance.js | 27 + frontend/src/assets/favicon.ico | Bin 0 -> 15086 bytes frontend/src/components/DelayedShow.tsx | 18 + frontend/src/components/Home.tsx | 60 + frontend/src/components/Layout.tsx | 21 + frontend/src/components/LoginPopup.tsx | 17 + frontend/src/components/Modal.tsx | 27 + frontend/src/components/NavBar.tsx | 33 + frontend/src/components/Notification.tsx | 51 + frontend/src/components/Routing.tsx | 21 + .../components/auth/ChangePasswordForm.tsx | 73 + .../src/components/auth/DeleteConfirm.tsx | 53 + frontend/src/components/auth/EditUserForm.tsx | 100 ++ frontend/src/components/auth/LoginForm.tsx | 50 + frontend/src/components/auth/registerForm.tsx | 57 + frontend/src/components/auth/section.tsx | 11 + frontend/src/components/exercices/Card.tsx | 217 +++ .../exercices/ExerciceCreateForm.tsx | 132 ++ .../components/exercices/ExerciceEditForm.tsx | 142 ++ .../src/components/exercices/ModalCard.tsx | 345 +++++ .../src/components/exercices/Pagination.tsx | 121 ++ frontend/src/components/forms/TextField.tsx | 70 + frontend/src/components/forms/TextField2.tsx | 50 + frontend/src/components/forms/select.tsx | 262 ++++ .../src/components/icons/ClearIndicator.tsx | 15 + .../components/icons/dropdown_indicator.tsx | 15 + frontend/src/components/test.tsx | 12 + frontend/src/context/auth.context.jsx | 147 ++ frontend/src/context/loginPopUp.context.jsx | 14 + frontend/src/context/navigate.context.jsx | 15 + frontend/src/context/notification.context.jsx | 91 ++ frontend/src/hooks/useForm.js | 57 + frontend/src/index.css | 13 + frontend/src/index.tsx | 72 + frontend/src/logo.svg | 1 + frontend/src/pages/Dashboard.tsx | 55 + frontend/src/pages/ExercicePage.tsx | 357 +++++ frontend/src/pages/Login.tsx | 30 + frontend/src/pages/Register.tsx | 35 + frontend/src/requests/auth.requests.js | 126 ++ frontend/src/requests/exo.requests.js | 214 +++ frontend/src/styles/NavBar.module.scss | 219 +++ .../styles/auth/changePassword.module.scss | 20 + .../src/styles/auth/dashboard.module.scss | 12 + .../src/styles/auth/deleteConfirm.module.scss | 34 + frontend/src/styles/auth/editUser.module.scss | 27 + frontend/src/styles/auth/login.module.scss | 10 + .../src/styles/auth/loginPage.module.scss | 13 + .../src/styles/auth/loginPopup.module.scss | 14 + frontend/src/styles/auth/section.module.scss | 22 + .../src/styles/exercices/Card.module.scss | 265 ++++ .../exercices/ExercicesPage.module.scss | 174 +++ .../styles/exercices/ModalCard.module.scss | 146 ++ .../styles/exercices/Pagination.module.scss | 17 + .../src/styles/exercices/exoForm.module.scss | 101 ++ frontend/src/styles/form/input.module.scss | 114 ++ frontend/src/styles/form/input2.module.scss | 40 + frontend/src/styles/form/select.module.scss | 177 +++ frontend/src/styles/functions.scss | 6 + frontend/src/styles/global.scss | 295 ++++ frontend/src/styles/index.scss | 5 + frontend/src/styles/mixins.scss | 54 + frontend/src/styles/modal.module.scss | 54 + frontend/src/styles/notification.module.scss | 102 ++ frontend/src/styles/variables.scss | 22 + frontend/src/types/auth.type.ts | 12 + frontend/src/types/exo.type.ts | 37 + frontend/src/utils/utils.js | 130 ++ frontend/tsconfig.json | 17 + frontend/vite.config.ts | 12 + 167 files changed, 12601 insertions(+) create mode 100644 .gitignore create mode 100644 backend/api/config.py create mode 100644 backend/api/conftest.py create mode 100644 backend/api/database/auth/crud.py create mode 100644 backend/api/database/auth/models.py create mode 100644 backend/api/database/db.py create mode 100644 backend/api/database/exercices/FileField.py create mode 100644 backend/api/database/exercices/crud.py create mode 100644 backend/api/database/exercices/models.py create mode 100644 backend/api/database/room/crud.py create mode 100644 backend/api/database/room/models.py create mode 100644 backend/api/dbo/auth/crud.py create mode 100644 backend/api/dbo/auth/models.py create mode 100644 backend/api/dbo/db.sqlite3 create mode 100644 backend/api/dbo/exercices/crud.py create mode 100644 backend/api/dbo/exercices/models.py create mode 100644 backend/api/dbo/rooms/crud.py create mode 100644 backend/api/dbo/rooms/models.py create mode 100644 backend/api/dbo/utils/ExoSourceValidator.py create mode 100644 backend/api/dbo/utils/FileField.py create mode 100644 backend/api/generateur/generateur_csv.py create mode 100644 backend/api/generateur/generateur_main.py create mode 100644 backend/api/main.py create mode 100644 backend/api/routes/auth/routes.py create mode 100644 backend/api/routes/base.py create mode 100644 backend/api/routes/exercices/routes.py create mode 100644 backend/api/routes/exercices/routes_old.py create mode 100644 backend/api/routes/room/routes.py create mode 100644 backend/api/routes/rooms/routes.py create mode 100644 backend/api/schemas/base.py create mode 100644 backend/api/schemas/exercices.py create mode 100644 backend/api/schemas/rooms.py create mode 100644 backend/api/schemas/users.py create mode 100644 backend/api/services/auth.py create mode 100644 backend/api/services/database.py create mode 100644 backend/api/services/exoValidation.py create mode 100644 backend/api/services/io.py create mode 100644 backend/api/services/jwt.py create mode 100644 backend/api/services/password.py create mode 100644 backend/api/services/schema.py create mode 100644 backend/api/services/timeout.py create mode 100644 backend/api/testing.py create mode 100644 backend/api/tests/test_auth.py create mode 100644 backend/api/tests/test_exos.py create mode 100644 backend/api/tests/testing_exo_source/exo_source.py create mode 100644 backend/api/tests/testing_exo_source/exo_source_invalid.py create mode 100644 backend/api/tests/testing_exo_source/exo_source_missing_main.py create mode 100644 backend/api_old/apis/__init__.py create mode 100644 backend/api_old/apis/auth/route_auth.py create mode 100644 backend/api_old/apis/base.py create mode 100644 backend/api_old/apis/exercices/__init__.py create mode 100644 backend/api_old/apis/exercices/route_exercices.py create mode 100644 backend/api_old/apis/room/route_room.py create mode 100644 backend/api_old/apis/room/websocket.py create mode 100644 backend/api_old/config.py create mode 100644 backend/api_old/database/__init__.py create mode 100644 backend/api_old/database/auth/crud.py create mode 100644 backend/api_old/database/auth/models.py create mode 100644 backend/api_old/database/db.sqlite3 create mode 100644 backend/api_old/database/db.sqlite3-shm create mode 100644 backend/api_old/database/db.sqlite3-wal create mode 100644 backend/api_old/database/decorators.py create mode 100644 backend/api_old/database/exercices/crud.py create mode 100644 backend/api_old/database/exercices/customField.py create mode 100644 backend/api_old/database/exercices/models.py create mode 100644 backend/api_old/database/exercices/validators.py create mode 100644 backend/api_old/database/main.py create mode 100644 backend/api_old/database/room/crud.py create mode 100644 backend/api_old/database/room/models.py create mode 100644 backend/api_old/db.sqlite3 create mode 100644 backend/api_old/generateur/generateur_csv.py create mode 100644 backend/api_old/generateur/generateur_main.py create mode 100644 backend/api_old/index.html create mode 100644 backend/api_old/main.py create mode 100644 backend/api_old/pyproject.toml create mode 100644 backend/api_old/schema/user.py create mode 100644 backend/api_old/services/auth.py create mode 100644 backend/api_old/services/io.py create mode 100644 backend/api_old/services/jwt.py create mode 100644 backend/api_old/services/password.py create mode 100644 backend/api_old/services/timeout.py create mode 100644 backend/api_old/tests/__init__.py create mode 100644 backend/api_old/tests/conftest.py create mode 100644 backend/api_old/tests/test_exercices.py create mode 100644 backend/api_old/uploads/ERSIWQ/1test_model.py create mode 100644 backend/api_old/uploads/JZJGJR/1test_model.py create mode 100644 backend/api_old/uploads/NAZABB/1test_model.py create mode 100644 backend/api_old/uploads/SSJCKT/1test_model.py create mode 100644 backend/api_old/uploads/TVLLES/1test_model.py create mode 100644 backend/api_old/uploads/UAPHYF/1test_model.py create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/src/App.module.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/apis/auth.instance.js create mode 100644 frontend/src/apis/exoInstance.instance.js create mode 100644 frontend/src/assets/favicon.ico create mode 100644 frontend/src/components/DelayedShow.tsx create mode 100644 frontend/src/components/Home.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/LoginPopup.tsx create mode 100644 frontend/src/components/Modal.tsx create mode 100644 frontend/src/components/NavBar.tsx create mode 100644 frontend/src/components/Notification.tsx create mode 100644 frontend/src/components/Routing.tsx create mode 100644 frontend/src/components/auth/ChangePasswordForm.tsx create mode 100644 frontend/src/components/auth/DeleteConfirm.tsx create mode 100644 frontend/src/components/auth/EditUserForm.tsx create mode 100644 frontend/src/components/auth/LoginForm.tsx create mode 100644 frontend/src/components/auth/registerForm.tsx create mode 100644 frontend/src/components/auth/section.tsx create mode 100644 frontend/src/components/exercices/Card.tsx create mode 100644 frontend/src/components/exercices/ExerciceCreateForm.tsx create mode 100644 frontend/src/components/exercices/ExerciceEditForm.tsx create mode 100644 frontend/src/components/exercices/ModalCard.tsx create mode 100644 frontend/src/components/exercices/Pagination.tsx create mode 100644 frontend/src/components/forms/TextField.tsx create mode 100644 frontend/src/components/forms/TextField2.tsx create mode 100644 frontend/src/components/forms/select.tsx create mode 100644 frontend/src/components/icons/ClearIndicator.tsx create mode 100644 frontend/src/components/icons/dropdown_indicator.tsx create mode 100644 frontend/src/components/test.tsx create mode 100644 frontend/src/context/auth.context.jsx create mode 100644 frontend/src/context/loginPopUp.context.jsx create mode 100644 frontend/src/context/navigate.context.jsx create mode 100644 frontend/src/context/notification.context.jsx create mode 100644 frontend/src/hooks/useForm.js create mode 100644 frontend/src/index.css create mode 100644 frontend/src/index.tsx create mode 100644 frontend/src/logo.svg create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/ExercicePage.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Register.tsx create mode 100644 frontend/src/requests/auth.requests.js create mode 100644 frontend/src/requests/exo.requests.js create mode 100644 frontend/src/styles/NavBar.module.scss create mode 100644 frontend/src/styles/auth/changePassword.module.scss create mode 100644 frontend/src/styles/auth/dashboard.module.scss create mode 100644 frontend/src/styles/auth/deleteConfirm.module.scss create mode 100644 frontend/src/styles/auth/editUser.module.scss create mode 100644 frontend/src/styles/auth/login.module.scss create mode 100644 frontend/src/styles/auth/loginPage.module.scss create mode 100644 frontend/src/styles/auth/loginPopup.module.scss create mode 100644 frontend/src/styles/auth/section.module.scss create mode 100644 frontend/src/styles/exercices/Card.module.scss create mode 100644 frontend/src/styles/exercices/ExercicesPage.module.scss create mode 100644 frontend/src/styles/exercices/ModalCard.module.scss create mode 100644 frontend/src/styles/exercices/Pagination.module.scss create mode 100644 frontend/src/styles/exercices/exoForm.module.scss create mode 100644 frontend/src/styles/form/input.module.scss create mode 100644 frontend/src/styles/form/input2.module.scss create mode 100644 frontend/src/styles/form/select.module.scss create mode 100644 frontend/src/styles/functions.scss create mode 100644 frontend/src/styles/global.scss create mode 100644 frontend/src/styles/index.scss create mode 100644 frontend/src/styles/mixins.scss create mode 100644 frontend/src/styles/modal.module.scss create mode 100644 frontend/src/styles/notification.module.scss create mode 100644 frontend/src/styles/variables.scss create mode 100644 frontend/src/types/auth.type.ts create mode 100644 frontend/src/types/exo.type.ts create mode 100644 frontend/src/utils/utils.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ece6cdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +frontend/node_modules +frontend/dist +**/__pycache__/** +backend/env +backend/api/uploads +backend/api/database.db \ No newline at end of file diff --git a/backend/api/config.py b/backend/api/config.py new file mode 100644 index 0000000..31b0686 --- /dev/null +++ b/backend/api/config.py @@ -0,0 +1,20 @@ +from datetime import timedelta +from redis import Redis +from pydantic import BaseModel +SECRET_KEY = "6323081020d8939e6385dd688a26cbca0bb34ed91997959167637319ba4f6f3e" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + + +redis_conn = Redis(host='localhost', port=6379, db=0, decode_responses=True) + + +class Settings(BaseModel): + authjwt_secret_key: str = SECRET_KEY + authjwt_denylist_enabled: bool = False + authjwt_denylist_token_checks: set = {"access", "refresh"} + access_expires: int = timedelta(minutes=15) + refresh_expires: int = timedelta(days=30) + + +settings = Settings() diff --git a/backend/api/conftest.py b/backend/api/conftest.py new file mode 100644 index 0000000..11049e1 --- /dev/null +++ b/backend/api/conftest.py @@ -0,0 +1,43 @@ +import os +import shutil +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine, select +from sqlmodel.pool import StaticPool +from database.exercices.models import Exercice +from main import app + +from database.db import get_session + + +@pytest.fixture(name="session") +def session_fixture(): + engine = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, echo=False + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + yield session + #cleanup uploads + exos = session.exec(select(Exercice)).all() + for e in exos: + try: + shutil.rmtree(os.path.join('uploads', e.id_code)) + except: + pass + +@pytest.fixture(name="client") +def client_fixture(session: Session): + + def get_session_override(): + return session + + app.dependency_overrides[get_session] = get_session_override + + client = TestClient(app) + + yield client + + app.dependency_overrides.clear() + + \ No newline at end of file diff --git a/backend/api/database/auth/crud.py b/backend/api/database/auth/crud.py new file mode 100644 index 0000000..d8ab4de --- /dev/null +++ b/backend/api/database/auth/crud.py @@ -0,0 +1,74 @@ +import uuid +from services.password import get_password_hash +from database.auth.models import User, UserEdit +from sqlmodel import Session, select + +def create_user_db(username:str , password: str, db: Session): + user = User(username=username, hashed_password=password, clientId=uuid.uuid4()) + db.add(user) + db.commit() + db.refresh(user) + return user + +def get_user_from_username_db(username, db: Session): + user = db.exec(select(User).where(User.username == username)).first() + return user + +def get_user_from_clientId_db(clientId: str , db: Session): + user = db.exec(select(User).where(User.clientId == clientId)).first() + return user + +def update_user_db(clientId: str, user: UserEdit, db: Session): + db_user = get_user_from_clientId_db(clientId, db) + + if not db_user: + return None + + user_data = user.dict(exclude_unset=True) + + for key, value in user_data.items(): + setattr(db_user, key, value) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + +def update_password_db(id: int, password: str, db: Session): + user = db.get(User, id) + if not user: + return None + + user.hashed_password = get_password_hash(password) + + db.add(user) + db.commit() + db.refresh(user) + + return user + +def delete_user_db(id: int, db: Session): + user = db.get(User, id) + if not user: + return False + db.delete(user) + db.commit() + return True + +def check_unique_username(username: str, db: Session): + user = db.exec(select(User).where(User.username == username)).first() + if not user: + return username + return None + +def change_user_uuid(id: int, db: Session): + user = db.get(User, id) + if not user: + return None + + user.clientId = uuid.uuid4() + + db.add(user) + db.commit() + db.refresh(user) + return user.clientId + diff --git a/backend/api/database/auth/models.py b/backend/api/database/auth/models.py new file mode 100644 index 0000000..b4a245f --- /dev/null +++ b/backend/api/database/auth/models.py @@ -0,0 +1,76 @@ +from typing import List, Optional +from typing import TYPE_CHECKING, Optional +from uuid import UUID +import uuid +from sqlmodel import Field, SQLModel, Relationship +from pydantic import validator, BaseModel +from database.db import get_session, get_session +from services.password import validate_password +from services.schema import as_form + +if TYPE_CHECKING: + from database.exercices.models import Exercice, Tag + from database.room.models import Member + +class UserBase(SQLModel): + username: str = Field(max_length=20, index= True) + firstname: Optional[str] + name: Optional[str] + email: Optional[str] + +class User(UserBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + clientId: Optional[UUID] = Field(default=uuid.uuid4(), index=True) + hashed_password: str + + exercices: List['Exercice'] = Relationship(back_populates='author') + tags: List['Tag'] = Relationship(back_populates='author') + members: List['Member'] = Relationship(back_populates='user') + +@as_form +class UserEdit(UserBase): + pass + +class UserRead(UserBase): + id: int + + +@as_form +class UserRegister(BaseModel): + username: str = Field(max_length=20) + password: str + password_confirm: str + + + + @validator('password') + def password_validation(cls, v): + is_valid = validate_password(v) + if is_valid != True: + raise ValueError(is_valid) + return v + + @validator('password_confirm') + def password_match(cls, v, values): + if 'password' in values and v != values['password']: + raise ValueError('Les mots de passe ne correspondent pas') + return v + +@as_form +class PasswordSet(BaseModel): + old_password: str + password: str + password_confirm: str + + @validator('password') + def password_validation(cls, v): + is_valid = validate_password(v) + if is_valid != True: + raise ValueError(is_valid) + return v + + @validator('password_confirm') + def password_match(cls, v, values): + if 'password' in values and v != values['password']: + raise ValueError('Les mots de passe ne correspondent pas') + return v diff --git a/backend/api/database/db.py b/backend/api/database/db.py new file mode 100644 index 0000000..e2005e7 --- /dev/null +++ b/backend/api/database/db.py @@ -0,0 +1,17 @@ +import random +import string +from sqlmodel import SQLModel, create_engine, Session, select + +sqlite_file_name = "database.db" +sqlite_url = f"sqlite:///{sqlite_file_name}" + +engine = create_engine(sqlite_url, echo=False, connect_args={"check_same_thread": False}) + + +def create_db_and_tables(): + SQLModel.metadata.create_all(engine) + +def get_session(): + with Session(engine) as s: + yield s + diff --git a/backend/api/database/exercices/FileField.py b/backend/api/database/exercices/FileField.py new file mode 100644 index 0000000..486325f --- /dev/null +++ b/backend/api/database/exercices/FileField.py @@ -0,0 +1,46 @@ + +import io +import os +from typing import IO, Type + +from services.io import add_fast_api_root, get_filename, get_or_create_dir, remove_fastapi_root + + +class FileFieldMeta(type): + def __getitem__(self, upload_root: str,) -> Type['FileField']: + return type('MyFieldValue', (FileField,), {'upload_root': upload_root}) + + +class FileField(str, metaclass=FileFieldMeta): + upload_root: str + + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, value: str | IO, values): + upload_root = get_or_create_dir( + add_fast_api_root(cls.upload_root)) + if not isinstance(value, str): + value.seek(0) + + is_binary = isinstance(value, io.BytesIO) + + name = get_filename(value, 'exo_source.py') + + parent = get_or_create_dir(os.path.join( + upload_root, values['id_code'])) + + mode = 'w+' if not is_binary else 'wb+' + + path = os.path.join(parent, name) + with open(path, mode) as f: + f.write(value.read()) + + return remove_fastapi_root(path) + + else: + if not os.path.exists(add_fast_api_root(value)): + raise ValueError('File does not exist') + return value diff --git a/backend/api/database/exercices/crud.py b/backend/api/database/exercices/crud.py new file mode 100644 index 0000000..4ac26da --- /dev/null +++ b/backend/api/database/exercices/crud.py @@ -0,0 +1,172 @@ +from fastapi import Query +import os +import shutil +from typing import IO, List +from sqlmodel import Session, select, or_ +from database.auth.models import User +from database.db import get_session +from fastapi import Depends +from database.exercices.models import ExerciceCreate, Exercice, ExerciceEdit, ExerciceRead, ExercicesTagLink, Tag, TagCreate +from services.auth import get_current_user +from services.database import generate_unique_code +from services.io import add_fast_api_root, get_ancestor, get_or_create_dir + + +def create_exo_db(exercice: ExerciceCreate, user: User, exo_source: IO, db: Session): + + exo_db = Exercice(**exercice.dict(exclude_unset=True), + author_id=user.id, id_code=generate_unique_code(Exercice, db), exo_source=exo_source) + + db.add(exo_db) + db.commit() + db.refresh(exo_db) + return exo_db + + +def clone_exo_source(path: str, id_code: str): + if not os.path.exists(add_fast_api_root(path)): + return None + upload_root = get_ancestor(path, 2) + path = add_fast_api_root(path) + new_path = add_fast_api_root( + os.path.join(upload_root, id_code)) + dir_path = get_or_create_dir(new_path) + final_path = shutil.copy(path, dir_path) + return final_path + + +def clone_exo_db(exercice: Exercice, user: User, db: Session): + if exercice.author_id == user.id: + return 'Vous ne pouvez pas copier un de vos exercices' + + new_exo = Exercice.from_orm(exercice) + new_exo.id = None + new_exo.author_id = user.id + new_exo.origin_id = exercice.id + + new_id_code = generate_unique_code(Exercice, db) + new_exo.id_code = new_id_code + + new_exo_source = clone_exo_source(exercice.exo_source, new_id_code) + if not new_exo_source: + return "Erreur lors de la copie de l'exercice, fichier source introuvable" + new_exo.exo_source = new_exo_source + + db.add(new_exo) + db.commit() + db.refresh(new_exo) + return new_exo + + +def update_exo_db(old_exo: Exercice, new_exo: ExerciceEdit, exo_source: IO | None, db: Session): + + exo_data = new_exo.dict(exclude_unset=True, exclude_none=True) + + for key, value in exo_data.items(): + setattr(old_exo, key, value) + + if exo_source: + os.remove(add_fast_api_root(old_exo.exo_source)) + old_exo.exo_source = exo_source + db.add(old_exo) + db.commit() + db.refresh(old_exo) + return old_exo + + +def delete_exo_db(exo: Exercice, db: Session): + db.delete(exo) + db.commit() + return True + + +def get_or_create_tag(tag: TagCreate, user: User, db: Session): + tag_db = db.exec(select(Tag).where(Tag.author_id == user.id).where(or_( + Tag.id_code == tag.id_code, Tag.label == tag.label))).first() + if tag_db is not None: + return tag_db + id_code = generate_unique_code(Tag, db) + tag_db = Tag(**{**tag.dict(exclude_unset=True), + 'id_code': id_code, 'author_id': user.id}) + db.add(tag_db) + db.commit() + db.refresh(tag_db) + return tag_db + + +def add_tags_db(exo: Exercice, tags: List[TagCreate], user: User, db: Session): + for tag in tags: + tag_db = get_or_create_tag(tag, user, db) + exo.tags.append(tag_db) + db.add(exo) + db.commit() + db.refresh(exo) + return exo + + +def remove_tag_db(exo: Exercice, tag: Tag, db: Session): + exo.tags.remove(tag) + db.add(tag) + db.commit() + return exo + + +def parse_exo_tags(exo_id: int, user_id: int, db: Session): + + exo_tags = db.exec(select(Tag, ExercicesTagLink).where(ExercicesTagLink.exercice_id == + exo_id, Tag.id == ExercicesTagLink.tag_id, Tag.author_id == user_id)).all() # select -> (Exercice, ExerciceLink) + exo_tags = [t[0] for t in exo_tags] + return exo_tags + + + +# Dependencies + +def get_exo_dependency(id_code: str, db: Session = Depends(get_session)): + with db.no_autoflush: + exo = db.exec(select(Exercice).where( + Exercice.id_code == id_code)).first() + return exo + + +def check_exercice_author(exo: Exercice | None = Depends(get_exo_dependency), user: User = Depends(get_current_user)): + if not exo: + return None + if exo.author_id != user.id: + return False + return exo + + +def check_tag_author(tag_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_session)): + tag = db.exec(select(Tag).where(Tag.id_code == tag_id)).first() + if not tag: + return None + + is_owner = tag.author_id == user.id + if is_owner == False: + return False + + return tag + + +def get_tags_dependency(tags: List[str] | None = Query(None), db: Session = Depends(get_session)): + if tags is None: + return None + validated_tags = [] + for t in tags: + tag = db.exec(select(Tag.id).where(Tag.id_code == t)).first() + if tag is not None: + validated_tags.append(tag) + return validated_tags + + +#Serialize + +def check_author(exo: Exercice, user_id:int): + return exo.author_id == user_id + +def serialize_exo(*, exo: Exercice, user_id: User = None, db: Session): + tags = parse_exo_tags(exo_id=exo.id, user_id=user_id, + db=db) if user_id is not None else [] + is_author = user_id is not None and check_author(exo=exo, user_id=user_id) + return ExerciceRead(**exo.dict(), author=exo.author, original=exo.original, tags=tags, is_author=is_author) diff --git a/backend/api/database/exercices/models.py b/backend/api/database/exercices/models.py new file mode 100644 index 0000000..782ce0d --- /dev/null +++ b/backend/api/database/exercices/models.py @@ -0,0 +1,184 @@ +from sqlmodel import select +from sqlalchemy.inspection import inspect +from enum import Enum +import os +from pydantic import BaseModel +from pydantic import validator, root_validator +from generateur.generateur_main import Generateur +from services.exoValidation import get_support_from_path +from services.io import add_fast_api_root, get_filename_from_path +from services.schema import as_form +from typing import Any, List, Optional +from sqlmodel import SQLModel, Field, Relationship, Session +from database.auth.models import User, UserRead +from typing import TYPE_CHECKING, Optional +from .FileField import FileField +from database.db import get_session +if TYPE_CHECKING: + from database.auth.models import User + + +class ExercicesTagLink(SQLModel, table=True): + exercice_id: Optional[int] = Field( + default=None, foreign_key='exercice.id', primary_key=True) + tag_id: Optional[int] = Field( + default=None, foreign_key='tag.id', primary_key=True) + + +class ExerciceBase(SQLModel): + name: str = Field(max_length=50, index=True) + consigne: Optional[str] = Field(max_length=200, default=None) + private: Optional[bool] = Field(default=False) + + +class Exercice(ExerciceBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + id_code: str = Field(unique=True, index=True) + + exo_source: FileField['/uploads'] + + author_id: int = Field(foreign_key='user.id') + author: "User" = Relationship(back_populates='exercices') + + origin_id: Optional[int] = Field( + foreign_key='exercice.id', default=None, nullable=True) + original: Optional['Exercice'] = Relationship(back_populates='duplicated', sa_relationship_kwargs=dict( + remote_side='Exercice.id' + )) + duplicated: List['Exercice'] = Relationship(back_populates='original') + + tags: List['Tag'] = Relationship( + back_populates='exercices', link_model=ExercicesTagLink) + + + def dict_relationship(self, *, exclude: List[str], include: List[str]): + relationships = inspect(self.__class__).relationships.items() + relationsData = {rname: getattr(self, rname) for rname, rp in relationships if rname not in exclude and (rname in include if len(include) != 0 else True)} + return {**self.dict(), **relationsData} + + class Config: + validate_assignment = True + extra='allow' + + + +class ColorEnum(Enum): + green='#00ff00' + red="#ff0000" + blue="#0000ff" + string="string" + +class TagBase(SQLModel): + label: str = Field(max_length=20) + color: ColorEnum + + +class Tag(TagBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + id_code: str = Field(unique=True, index=True) + + author_id: int = Field(foreign_key='user.id') + author: "User" = Relationship(back_populates='tags') + + exercices: List["Exercice"] = Relationship( + back_populates='tags', link_model=ExercicesTagLink) + + +class TagCreate(TagBase): + id_code: str = None + + +class TagRead(TagBase): + id_code: str + + +@as_form +class ExerciceCreate(ExerciceBase): + pass + + +class ExerciceEdit(ExerciceCreate): + pass + + +class ExerciceOrigin(SQLModel): + #id: int + id_code: str + name: str + + +class ExoSupport(BaseModel): + pdf: bool + csv: bool + web: bool + + +class ExampleEnum(Enum): + csv = 'csv' + pdf = 'pdf' + web = 'web' + undisponible = 'undisponible' + + +class GeneratorOutput(BaseModel): + calcul: str + correction: str | None = None + + +class Example(BaseModel): + type: ExampleEnum = ExampleEnum.undisponible + examples: List[GeneratorOutput] | None = None + + +class Author(SQLModel): + username: str + + + + + +def get_source_path_from_name_and_id(name: str, id_code: str): + return f'/uploads/{id_code}/{name}' + +class ExerciceRead(ExerciceBase): + id_code: str + author: Author + original: ExerciceOrigin | None + tags: List[TagRead] = None + exo_source: str + + supports: ExoSupport = None + + examples: Example = None + + is_author: bool = None + + @validator('supports', always=True, pre=True) + def get_supports(cls, value, values): + exo_source = get_source_path_from_name_and_id(values['exo_source'], values['id_code']) + exo_source = add_fast_api_root(exo_source) + if os.path.exists(exo_source): + support_compatibility = get_support_from_path( + exo_source) + return support_compatibility + return None + + @validator('examples', always=True) + def get_examples(cls, value, values): + if values.get('supports') == None: + return {} + supports = values.get('supports').dict() + exo_source = get_source_path_from_name_and_id( + values['exo_source'], values['id_code']) + return { + "type": ExampleEnum.csv if supports['csv'] == True else ExampleEnum.web if supports['web'] == True else None, + "data": Generateur(add_fast_api_root(exo_source), 3, "csv" if supports['csv'] == True else "web" if supports['web'] == True else None, True) if supports['csv'] == True == True or supports['web'] == True == True else None + } + + @validator('exo_source') + def get_exo_source_name(cls, value, values): + if value is not None: + return get_filename_from_path(value) + return value + + diff --git a/backend/api/database/room/crud.py b/backend/api/database/room/crud.py new file mode 100644 index 0000000..a69f02d --- /dev/null +++ b/backend/api/database/room/crud.py @@ -0,0 +1,6 @@ +from sqlmodel import Session +from database.room.models import RoomCreate +from database.auth.models import User + +def create_room_db(*,room: RoomCreate, user: User | None = None,username: str, db: Session): + return \ No newline at end of file diff --git a/backend/api/database/room/models.py b/backend/api/database/room/models.py new file mode 100644 index 0000000..065543b --- /dev/null +++ b/backend/api/database/room/models.py @@ -0,0 +1,49 @@ +from typing import List, Optional, TYPE_CHECKING +from sqlmodel import SQLModel, Field, Relationship +if TYPE_CHECKING: + from database.auth.models import User + + +class RoomBase(SQLModel): + name: str = Field(max_length=20) + public: bool = Field(default=False) + +class RoomCreate(RoomBase): + pass + +class Room(RoomBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + id_code: str + + members: List['Member'] = Relationship(back_populates="room") + +class RoomRead(RoomBase): + id_code: str + #members: List[] + +class AnonymousBase(SQLModel): + username: str = Field(max_length=20) + +class AnonymousCreate(AnonymousBase): + pass + +class Anonymous(AnonymousBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + reconnect_code: str + + member: 'Member' = Relationship(back_populates="anonymous") + + +class Member(SQLModel, table = True): + id: Optional[int] = Field(default=None, primary_key=True) + + user_id: Optional[int] = Field(foreign_key="user.id", default=None) + anonymous_id: Optional[int] = Field(foreign_key="anonymous.id", default=None) + + anonymous: Optional[Anonymous] = Relationship(back_populates="member") + user: Optional['User'] = Relationship(back_populates='members') + + room_id: int = Field(foreign_key="room.id") + room: Room = Relationship(back_populates='members') + + \ No newline at end of file diff --git a/backend/api/dbo/auth/crud.py b/backend/api/dbo/auth/crud.py new file mode 100644 index 0000000..6de6d56 --- /dev/null +++ b/backend/api/dbo/auth/crud.py @@ -0,0 +1,30 @@ +from services.password import get_password_hash +from database.auth.models import User + +async def create_user_db(username, password): + #id_code = generate_unique_code(UserModel) + return await User.create(username=username, hashed_password=password) + +async def get_user_from_username_db(username): + return await User.get_or_none(username=username) + + +async def get_user_from_clientId_db(clientId): + return await User.get_or_none(clientId=clientId) + + +async def update_user_db(username: str, **kwargs): + user = await get_user_from_username_db(username) + await user.update_from_dict({**kwargs}).save() + return user + +async def delete_user_db(username: str): + user = await get_user_from_username_db(username) + await user.delete() + + +async def update_password_db(username: str, password: str): + user = await get_user_from_username_db(username) + + await user.update_from_dict({'hashed_password': get_password_hash(password)}).save(update_fields=["hashed_password"]) + return user diff --git a/backend/api/dbo/auth/models.py b/backend/api/dbo/auth/models.py new file mode 100644 index 0000000..cbd6771 --- /dev/null +++ b/backend/api/dbo/auth/models.py @@ -0,0 +1,22 @@ +import uuid +from tortoise.models import Model +from tortoise import fields + + +class User(Model): + id = fields.IntField(pk=True) + clientId = fields.UUIDField(unique=True, default=uuid.uuid4) + username = fields.CharField(max_length=100, unique=True) + hashed_password = fields.CharField(max_length=255) + email = fields.CharField(null=True, max_length=255) + + name = fields.CharField(null=True, max_length=255) + firstname = fields.CharField(null=True, max_length=255) + + disabled = fields.BooleanField(default=False) + + class PydanticMeta: + exclude = ['hashed_password'] + + class Meta: + table = "users" diff --git a/backend/api/dbo/db.sqlite3 b/backend/api/dbo/db.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..36b52cc555459a600da2361f29a549c1571e4ea1 GIT binary patch literal 45056 zcmeI5U2G#)6@X{#IEkIuceiQQ>rJhi(ABQBi8FuxEl73z7q5R3|K+cXf@4pz?k0BD zj<>rjXqBo6o+_Y12(_vN5)XYq6;hGl0g(EFDDYCLs7O4lKtgIO$^%*iDi55QJ9fsq zjd$C)>Y|=;Y+v7d=G=3?d*|NqIg@cAm#*#0uB}FMx4iEXnG=k`z&zn{F$`mX=NLRY zn+Xy_oe%KN>e5Fg4UA{wjFtSGv5daV*q$JNAV0ExF!qA=mzMdlZSytA#0?1`0VIF~ zkN^@u0y+W*E0z)a*|Y3(SNF@CJFBg(=peyzG&KY z-r|DoFtb*jb|te#S9TGerF7brkHus8ST+(X97@4%(;(<}EoNQOSULvEBjG|M9F5IT zbzs3pUriuzv=WQ^AZZ@4I~?qDyK2kIa-*ELiv_^a;b(C_H$5_9pO|2uzo51SS`L4sy{(`)D7#P8gj#i@(x}RZ7WOp3RmvuF zr5N;8JX}f_UFXhejoK-1$~%WUiFY@<(%5M<4<$aT;f<&3aijxLl48HRokxXgalH`Fdlc)o3>>AX|*B9v+*TruJU7yf0Tb z$}skd$xN(J3}=?Q1MpB`q?CsPxwz4l(e~eK)~gpYzJ%@F#@a1yrG7PT0a;x=pqY29UO_K6%xu%;>lu1ad zv$sB7!B(x=+CQ?iT5Bmw+ntM2cgS%Tx7#>u|FZq4PFA^Y2%wEg=3~6@nAcyB{n1QhIkhd{@TE56 zbs_Fwynd}v$rtKE_R3Q?GF!stW&d?Pv6#7;*bVYGws-efsM0~J^hv^|9FVtW1HzVX zR@&MO&X#50)~r*YvtxthM>DYo0Wh*;XIEL`FJ za&tMpwk^w1q5f23FMoBjF5YNcpmhfF8be+q?|lBc7+Z`4kN^@u0!RP}AOR$R1dsp{ zKmthMfh2I=SYq$T7hyPOESc}egFwIkzsHb&k@p_R7Nb!}00|%gB!C2v01`j~NB{{S z0VIF~kif?vFlL-$wM!HXW~0MQ(^m2>VmP!tIu0VIF~kN^@u0!RP} zAOR$R1dsp{_zVe*8;nl2bH@kgh|xH~!mUN@V9|KTDl==ev+8{S3|7M!<6v(x8&AoV{kg4jYac{5w&nWVX1Q`ju2<*a^IUsxr%|r9=BTbYeqV0wZ|uT-b9Ov? zHv~=)X1U-jC%Je&Byu4x=m`cOGkAf651h&RGv?b55}^a)1VE2=02NpAi`m5vAmMm~ zsIoqUydi)j1U;hUr}PHJ z3#sCIWH1I%UH39b5Ij6Dz}6)+sW+$)DO@ZJh!E9vFN1i|BS-Eccsg9QS zsRE`pKm2+8-atUorn&?25Hz_vvs}#Ngr=qAmj~(1l}V_{y>254$~8QgVth^rKJH4Q>v@4L6SG*2r`WwWBM8Ag*N}_bQT#oAv$}KD>)}wa)rhCU=UGVx)XIS5E8s0frGhO z;Qd`DB*{GU*`29>#RTiK)2^(~ERzhcW|!7``=j$Ugq9t}S9I~d5D!bCd19uN_Xu1rNuIVrPMZ{l{JJr55j$U$v5TWo)29JY5Da*Ld?y+d9l?~@^tBX5&9A>l1t>-Rw%Hza@rkN^@u0!RP}AOR$R1dsp{ zxL*Q~*oPU%#921Av~qE=u+!LSZf~CFW&}=};oOEtPeG~u92?86E*9g;b-aR7&T}*B z9|%l>z@(d9jTNH#tRkS4!|%?`C{NJfdKd&8ZgX^fIaQj!R%`6YR79yjsmAS^pb|?9 z7ZbU~MzdVsmODy#AplC84=GlhHLn%HigdHlJ`~`T;*(H3;WjTVij{6VQ3R&U(foR}l&s6w_rJIW8^@ipDT=4e#mq_} zQmV^OYx$?GkZ+$dr?SQ5dZa10+MPY@<5bl`csa40RB&j!oEd|HsVVb(DxHnRL6r-x zsn*II7ASK}nKS9wd^(}*kUM2vccCGN#C=`NunYCoDxGKvX zeWymW{H0trk%5aAI`s_Gde-s_Ynd<&+-pUrO;7|ax|ob-igEP4A0)Zw58{WGeMVMLApt8Rs1B}v@~ zCM8Mz25S7Dx(d|zKXnbL_y425044s Exercice: + code = await generate_unique_code(Exercice) + return await Exercice.create(*args, **{**kwargs, 'id_code': code}) + + +async def update_exo_db(id_code: str,exo_source: BinaryIO | None = None, **kwargs) -> Exercice: + exo = await Exercice.get(id_code=id_code) + if exo_source != None: + path = add_fast_api_root(exo.exo_source) + + remove_if_exists(path) + kwargs['exo_source'] = exo_source + + await exo.update_from_dict({**kwargs, 'origin_id': exo.origin_id, 'isOriginal': exo.isOriginal}).save() + return exo + + +async def delete_exo_db(id_code: str): + exo = await Exercice.get(id_code=id_code) + path = add_fast_api_root(exo.exo_source) + + parent = get_parent_dir(path) + + shutil.rmtree(parent) + + return await exo.delete() + + +def clone_exo_source(path, id_code): + upload_root = get_ancestor(path, 2) + path = add_fast_api_root(path) + new_path = add_fast_api_root( + os.path.join(upload_root, id_code)) + dir_path = get_or_create_dir(new_path) + final_path = shutil.copy(path, dir_path) + return final_path + +def exclude_dict_key(dict, key): + return {k: v for k, v in dict.items() if k != key} + + +async def clone_exo_db(id_code: str, user_id): + exo = await Exercice.get(id_code=id_code) + new_id_code = await generate_unique_code(Exercice) + + exo_obj = await Exercice_schema.from_tortoise_orm(exo) + exo_obj = exo_obj.dict(exclude_unset=True) + print("eaoiuezaoiueza", exo_obj) + exo_obj.pop('tags') + exo_obj = exclude_dict_key(exo_obj, 'tags') + exo_obj = exclude_dict_key(exo_obj, 'author') + #exo_obj.pop('exercices') + + path = clone_exo_source(exo.exo_source, new_id_code) + print('\n\npatiuar', path, exo_obj, '\n', + exclude_dict_key(exo_obj, 'tags')) + new_exo = Exercice(**{**exo_obj, 'id_code': new_id_code, 'exo_source': path, + "isOriginal": False, 'author_id': user_id}, origin_id=exo.id) + + await new_exo.save() + return new_exo + + +async def get_exo_source_path(id_code: str): + exo = await Exercice.get(id_code=id_code) + path = add_fast_api_root(exo.exo_source) + return path + +#tags +async def get_or_create_tag(id_code: str, data: List[TagIn_schema]): + tag = await Tag.get_or_none(id_code=id_code) + if tag == None: + code = await generate_unique_code(Tag) + return await Tag.create(**{**data, 'id_code': code}) + return tag + + +async def add_tag_db(exo: Exercice, tags_data: List[TagIn], user_id: int) -> Exercice: + for t in tags_data: + tag = await get_or_create_tag(t.id_code, {**t.dict(exclude_unset=True), 'owner_id': user_id}) + await exo.tags.add(tag) + return exo + +''' +async def add_tag_db(id_code: str, tag: Tag) -> Exercice: + exo = await Exercice.get(id_code=id_code) + await exo.tags.add(tag) + return exo + ''' +async def delete_tag_db(exo_id: str, tag_id: str) -> Exercice: + exo = await Exercice.get(id_code=exo_id) + tag = await exo.tags.all().get(id_code=tag_id) + await exo.tags.remove(tag) + return exo diff --git a/backend/api/dbo/exercices/models.py b/backend/api/dbo/exercices/models.py new file mode 100644 index 0000000..31cf15e --- /dev/null +++ b/backend/api/dbo/exercices/models.py @@ -0,0 +1,105 @@ +import asyncio +from io import BytesIO +import io +import os +import random +import string + +from tortoise.models import Model +from tortoise import fields +from tortoise.contrib.pydantic import pydantic_model_creator +import async_to_sync as sync +from tortoise.manager import Manager +from generateur.generateur_main import Generateur + +from services.io import add_fast_api_root + +from database.utils.ExoSourceValidator import ExoSourceValidator +from services.exoValidation import get_support_from_path, get_support_from_data + +from database.utils.FileField import FileField + + +class Tag(Model): + id = fields.IntField(pk=True) + id_code = fields.CharField(unique=True, default="", max_length=255) + + label = fields.CharField(max_length=35) + color = fields.CharField(max_length=100) + + owner = fields.ForeignKeyField('models.User') + + +class Exercice(Model): + id = fields.IntField(pk=True) + id_code = fields.CharField(default="", max_length=10, unique=True) + + name = fields.CharField(max_length=50) + + consigne = fields.CharField(max_length=200, null=True, default="") + + exo_source = FileField(upload_root="/uploads", + validators=[ExoSourceValidator()]) + + updated_at = fields.DatetimeField(auto_now=True) + + private = fields.BooleanField(default=False) + + tags = fields.ManyToManyField('models.Tag', null=True) + + origin = fields.ForeignKeyField('models.Exercice', null=True) + isOriginal = fields.BooleanField(default=True) + + author = fields.ForeignKeyField('models.User') + + def pdfSupport(self) -> bool: + if not isinstance(self.exo_source, io.BytesIO) and not isinstance(self.exo_source, io.StringIO): + if os.path.exists(add_fast_api_root(self.exo_source)): + support_compatibility = get_support_from_path( + add_fast_api_root(self.exo_source)) + return support_compatibility['isPdf'] + return False + else: + self.exo_source.seek(0) + support_compatibility = get_support_from_data( + self.exo_source.read()) + return support_compatibility['isPdf'] + + def csvSupport(self) -> bool: + if not isinstance(self.exo_source, io.BytesIO) and not isinstance(self.exo_source, io.StringIO): + if os.path.exists(add_fast_api_root(self.exo_source)): + support_compatibility = get_support_from_path( + add_fast_api_root(self.exo_source)) + return support_compatibility['isCsv'] + return False + else: + self.exo_source.seek(0) + support_compatibility = get_support_from_data( + self.exo_source.read()) + return support_compatibility['isCsv'] + + def webSupport(self) -> bool: + + if not isinstance(self.exo_source, io.BytesIO) and not isinstance(self.exo_source, io.StringIO): + if os.path.exists(add_fast_api_root(self.exo_source)): + support_compatibility = get_support_from_path( + add_fast_api_root(self.exo_source)) + return support_compatibility['isWeb'] + return False + else: + self.exo_source.seek(0) + support_compatibility = get_support_from_data( + self.exo_source.read()) + return support_compatibility['isWeb'] + + def examples(self) -> dict: + if not isinstance(self.exo_source, io.BytesIO) and not isinstance(self.exo_source, io.StringIO): + return { + "type": "Csv" if self.csvSupport() else "web" if self.webSupport() else None, + "data": Generateur(add_fast_api_root(self.exo_source), 3, "csv" if self.csvSupport() else "web" if self.pdfSupport() else None, True) if self.csvSupport() == True or self.webSupport() == True else None + } + return {} + + class PydanticMeta: + computed = ["pdfSupport", "csvSupport", "webSupport", 'examples'] + exclude = ["exo_source", "exercices", 'id'] diff --git a/backend/api/dbo/rooms/crud.py b/backend/api/dbo/rooms/crud.py new file mode 100644 index 0000000..28b91f7 --- /dev/null +++ b/backend/api/dbo/rooms/crud.py @@ -0,0 +1,106 @@ +from schemas.rooms import AnonymousIn_schema, RoomIn_schema +from schemas.users import User_schema +from database.auth.models import User +from database.rooms.models import AnonymousMember, Room, RoomOwner, Waiter +from services.database import generate_unique_code + + +async def create_room_with_user_db(room: RoomIn_schema, user: User_schema): + code = await generate_unique_code(Room) + + room_obj = await Room.create(**room.dict(exclude_unset=True), id_code=code) + + user = await User.get(id=user.id) + await room_obj.users.add(user) + await RoomOwner.create(room_id=room_obj.id, user_id=user.id) + return room_obj + + +async def create_room_anonymous_db(room: RoomIn_schema, anonymous: AnonymousIn_schema): + code = await generate_unique_code(Room) + + room_obj = await Room.create(**room.dict(exclude_unset=True), id_code=code) + + anonymous_code = await generate_unique_code(AnonymousMember) + anonymous = await AnonymousMember.create(**anonymous.dict(exclude_unset=True), id_code=anonymous_code, room_id=room_obj.id) + + await RoomOwner.create(room_id=room_obj.id, anonymous_id=anonymous.id) + return room_obj + + +async def get_room_db(room_id: str): + room = await Room.get_or_none(id_code=room_id) + return room + + +async def check_user_in_room(room: Room, user: User): + return await room.users.filter(id=user.id).count() != 0 + + +async def get_member_by_code(room: Room, code: str): + anonymous = await room.anonymousmembers + filtered_anonymous = [ + m for m in anonymous if m.id_code == code] + if len(filtered_anonymous) == 0: + return None + return filtered_anonymous[0] + + +async def check_user_owner(room: Room, user: User): + room_owner = await room.room_owner + user_owner = await room_owner.user + if user_owner == None: + return False + return user_owner.id == user.id + + +async def check_anonymous_owner(room: Room, anonymous: AnonymousMember): + room_owner = await room.room_owner + anonymous_owner = await room_owner.anonymous + if anonymous_owner == None: + return False + return anonymous_owner.id_code == anonymous.id_code + + +async def create_waiter_by_user(room: Room, user: User): + code = await generate_unique_code(Waiter) + return await Waiter.create(room_id=room.id, user_id=user.id, name=user.username, id_code=code) + + +async def create_waiter_anonymous(room: Room, name: str): + code = await generate_unique_code(Waiter) + return await Waiter.create(name=name, id_code=code, room_id=room.id) + + +async def connect_room(room: Room, code): + online = room.online + await room.update_from_dict({'online': [*online, code]}).save(update_fields=['online']) + return + + +async def disconnect_room(room: Room, code): + online = room.online + await room.update_from_dict({'online': [o for o in online if o != code]}).save(update_fields=['online']) + return + + +async def validate_name_in_room(room: Room, name): + anonymous = await room.anonymousmembers + if len([a for a in anonymous if a == name]) != 0: + return "Pseudo déjà utilisé" + if len(name) < 3: + return "Pseudo trop court" + if len(name) > 30: + return "Pseudo trop long" + return True + + +async def check_user_in_room(room_id: str , user: User): + room = await Room.get(id_code = room_id) + users = await room.users.filter(id=user.id) + return users.count() != 0 + +async def check_user_in_room(room_id: str , anonymous: AnonymousMember): + room = await Room.get(id_code = room_id) + members = await room.anonymousmembers.filter(id=anonymous.id) + return members.count() != 0 \ No newline at end of file diff --git a/backend/api/dbo/rooms/models.py b/backend/api/dbo/rooms/models.py new file mode 100644 index 0000000..c356eb9 --- /dev/null +++ b/backend/api/dbo/rooms/models.py @@ -0,0 +1,74 @@ +from tortoise.models import Model +from tortoise import fields + + +class Room(Model): + id = fields.IntField(pk=True) + id_code = fields.CharField(max_length=30, unique=True) + + name = fields.CharField(max_length=255) + + created_at = fields.DatetimeField(auto_now_add=True) + + public_result = fields.BooleanField(default=False) + private = fields.BooleanField(default=True) + + online = fields.JSONField(default=list) + + users = fields.ManyToManyField('models.UserModel') + + class PydanticMeta: + exlude = ('users__email') + + +class AnonymousMember(Model): + id = fields.IntField(pk=True) + id_code = fields.CharField(max_length=30, unique=True) + + name = fields.CharField(max_length=255) + + room = fields.ForeignKeyField('models.Room') + + class PydanticMeta: + exclude = ['room_owner', "id_code", 'id', 'challenger'] + + +class Waiter(Model): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=255) + id_code = fields.CharField(max_length=30, unique=True) + room = fields.ForeignKeyField('models.Room') + user = fields.ForeignKeyField("models.UserModel", null=True) + + +class RoomOwner(Model): + user = fields.ForeignKeyField('models.UserModel', null=True) + anonymous = fields.OneToOneField('models.AnonymousMember', null=True) + room = fields.OneToOneField('models.Room', null=True) + + class Meta: + table = 'room_owner' + + +class Parcours(Model): + id = fields.IntField(pk=True) + id_code = fields.CharField(max_length=30, unique=True) + + name = fields.CharField(max_length=255) + created_at = fields.DateField(auto_now_add=True) + + room = fields.ForeignKeyField('models.Room') + + timer = fields.IntField(default=10) + + exercices = fields.JSONField(default=list) + + success_condition = fields.IntField(default=10) + + +class Challenger(Model): + parcours = fields.ForeignKeyField('models.Parcours') + anonymous = fields.OneToOneField('models.AnonymousMember', null=True) + user = fields.OneToOneField("models.UserModel", null=True) + + challenges = fields.JSONField(default=list) diff --git a/backend/api/dbo/utils/ExoSourceValidator.py b/backend/api/dbo/utils/ExoSourceValidator.py new file mode 100644 index 0000000..205d076 --- /dev/null +++ b/backend/api/dbo/utils/ExoSourceValidator.py @@ -0,0 +1,19 @@ + +from tortoise.exceptions import ValidationError +from tortoise.validators import Validator +import typing +from services.exoValidation import get_support_from_data + +class ExoSourceValidator(Validator): + """ + A validator to validate ... + """ + + def __call__(self, value: typing.IO): + exo_supports_compatibility = get_support_from_data( + value.read()) + if not exo_supports_compatibility['isPdf'] and not exo_supports_compatibility['isCsv'] and not exo_supports_compatibility['isWeb']: + raise ValidationError( + '[Error] : Exercice non valide (compatible avec aucun support)') + + diff --git a/backend/api/dbo/utils/FileField.py b/backend/api/dbo/utils/FileField.py new file mode 100644 index 0000000..bbbe05b --- /dev/null +++ b/backend/api/dbo/utils/FileField.py @@ -0,0 +1,61 @@ +import os +import typing +import uuid +from fastapi import UploadFile +from tortoise.fields import TextField +from tortoise import ConfigurationError +import io +from services.io import delete_root_slash, add_fast_api_root, get_filename_from_path, get_or_create_dir, is_binary_file, get_filename, remove_fastapi_root + + +class FileField(TextField): + def __init__(self, *, upload_root: str, **kwargs): + super().__init__(**kwargs) + self.upload_root = delete_root_slash(upload_root) + + self.upload_root = get_or_create_dir(os.path.join( + os.environ.get('FASTAPI_ROOT_URL'), self.upload_root)) + + def _is_binary(self, file: UploadFile): + return isinstance(file, io.BytesIO) + + def to_db_value(self, value: typing.IO, instance): + if not isinstance(value, str): + super().validate(value) + value.seek(0) + is_binary = self._is_binary(value) + name = get_filename(value) + + parent = get_or_create_dir(os.path.join( + self.upload_root, instance.id_code)) + + mode = 'w+' if not is_binary else 'wb+' + + path = os.path.join(self.upload_root, parent, name) + + with open(path, mode) as f: + f.write(value.read()) + + return remove_fastapi_root(path) + return value + + def to_python_value(self, value: str): + return value + +""" +abs_path = get_abs_path_from_relative_to_root(value) + if is_binary_file(abs_path): + mode = 'rb' + buffer = io.BytesIO() + else: + mode = 'r' + buffer = io.StringIO() + + buffer.name = get_filename_from_path(value) + + with open(abs_path, mode) as f: + buffer.write(f.read()) + + buffer.seek(0) + return buffer +""" \ No newline at end of file diff --git a/backend/api/generateur/generateur_csv.py b/backend/api/generateur/generateur_csv.py new file mode 100644 index 0000000..5bca4a4 --- /dev/null +++ b/backend/api/generateur/generateur_csv.py @@ -0,0 +1,72 @@ +from .generateur_main import Generateur +PAGE_LINES = { + 10: 53, + 12: 49, + 14: 39, + 16: 34, + 18: 31 +} +MAX_LENGTH = { + 10: 38, + 12: 32, + 14: 25, + 16: 23, + 18: 20 +} + + +def Csv_generator(path, nb_in_serie, nb_page, police, consigne, writer): + exo_exemple = Generateur(path, 1, 'csv') + if len(consigne) < MAX_LENGTH[police] and len(consigne) > len(exo_exemple): + longueur_max = len(consigne) + 5 + elif len(consigne) > MAX_LENGTH[police] and len(consigne) > len(exo_exemple): + longueur_max = MAX_LENGTH[police] + elif len(consigne) > MAX_LENGTH[police] and len(consigne) < len(exo_exemple): + longueur_max = len(exo_exemple) + elif len(consigne) < MAX_LENGTH[police] and len(consigne) < len(exo_exemple): + longueur_max = len(exo_exemple) + else: + longueur_max = len(exo_exemple) + + consigne_lines = [] + if len(consigne) > 30: + cons = consigne.replace(',', ' ').split(' ') + text_longueur = '' + for i in cons: + text_longueur = text_longueur + i + ' ' + if len(text_longueur) > longueur_max: + consigne_lines.append(text_longueur) + text_longueur = '' + # print(text_longueur) + else: + consigne_lines.append(consigne) + serie_page_vertical = int(PAGE_LINES[police] / + (nb_in_serie + 1 + len(consigne_lines))) + + rest_line = PAGE_LINES[police] - (serie_page_vertical * nb_in_serie + + serie_page_vertical * len(consigne_lines) + serie_page_vertical) + + max_length = len(exo_exemple) if len( + exo_exemple) > longueur_max else longueur_max + max_in_line = 2 * MAX_LENGTH[police] + space = max_in_line / 8 + + nb_in_line = int(max_in_line / (max_length + space)) + 1 + + for p in range(nb_page): + for c in range(serie_page_vertical): + + for w in consigne_lines: + writer.writerow([*[w, ""] * nb_in_line]) + + for k in range(nb_in_serie): + calcul_list = list( + map(lambda calc: calc['calcul'], Generateur(path, nb_in_line, 'csv'))) + n = 1 + for i in range(n, len(calcul_list) + n + 1, n+1): + calcul_list.insert(i, '') + writer.writerow(calcul_list) + writer.writerow(['']) + + for r in range(rest_line): + writer.writerow(['']) diff --git a/backend/api/generateur/generateur_main.py b/backend/api/generateur/generateur_main.py new file mode 100644 index 0000000..75bab60 --- /dev/null +++ b/backend/api/generateur/generateur_main.py @@ -0,0 +1,49 @@ +import re +import importlib.util + + +def getObjectKey(obj, key): + if obj[key] == None: + return None + return key if obj[key] != False else 'calcul' if obj['calcul'] != False else None + + +def getCorrectionKey(obj, key): + return key if (obj[key] != False and obj['correction'] == False) else 'calcul' if(obj['calcul'] != False and obj['correction'] == False) else 'correction' if obj['correction'] != False else None + + +def parseCorrection(calc, replacer='...'): + exp_list = re.findall(r"\[([A-Za-z0-9_]+)\]", calc) + for exp in exp_list: + calc = calc.replace(f'[{exp}]', replacer) + return calc + + +def Generateur(path, quantity, key, forcedCorrection=False): + spec = importlib.util.spec_from_file_location( + "tmp", path) + tmp = importlib.util.module_from_spec(spec) + spec.loader.exec_module(tmp) + try: + main_func = tmp.main + except: + return None + main_result = main_func() + default_object = {"calcul": False, 'pdf': False, 'csv': False, + 'web': False, 'correction': False} # les valeurs par défaut + # Si l'utilisateur n'a pas entré une valeur, elle est définie à False + + result_object = {**default_object, **main_result} + object_key = getObjectKey(result_object, key) + correction_key = getCorrectionKey(result_object, key) + op_list = [] + try: + replacer = tmp.CORRECTION_REPLACER + except: + replacer = '...' + for i in range(quantity): + main_result = main_func() + main = {**default_object, **main_result} + op_list.append({'calcul': parseCorrection(main[ + object_key], replacer) if (forcedCorrection or (key != 'web' and main['correction'] == False)) else main[object_key], 'correction': main[correction_key]}) + return op_list diff --git a/backend/api/main.py b/backend/api/main.py new file mode 100644 index 0000000..0a84a16 --- /dev/null +++ b/backend/api/main.py @@ -0,0 +1,138 @@ +#import schemas.base +from services.password import get_password_hash +from sqlmodel import Session, select +from database.auth.crud import create_user_db +from services.auth import get_current_user_optional, jwt_required +from fastapi.openapi.utils import get_openapi +from database.auth.models import User, UserBase, UserRead +from database.exercices.models import Exercice, ExerciceRead +import database.db +from fastapi_pagination import add_pagination +from fastapi.responses import PlainTextResponse +from fastapi.exceptions import RequestValidationError, ValidationError +from fastapi import FastAPI, HTTPException, Depends, Request, status, Header +from fastapi_jwt_auth import AuthJWT +from fastapi_jwt_auth.exceptions import AuthJWTException +from fastapi.responses import JSONResponse +from typing import List, Sequence +from tortoise.contrib.pydantic import pydantic_model_creator +from fastapi import FastAPI, HTTPException, params +from tortoise import Tortoise +from fastapi.middleware.cors import CORSMiddleware +from tortoise.contrib.fastapi import register_tortoise +from pydantic import BaseModel +from database.db import create_db_and_tables, get_session +from services.jwt import revoke_access, revoke_refresh +import routes.base +from redis import Redis +from fastapi.encoders import jsonable_encoder +import config +from sqladmin import Admin, ModelView +from database.db import engine +from fastapi.security import OAuth2PasswordBearer, HTTPBearer +from pydantic import Field +app = FastAPI(title="API Generateur d'exercices") +origins = [ + "http://localhost:8000", + "https://localhost:8001", + "http://localhost", + "http://localhost:8080", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=['*'], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +admin = Admin(app, engine) + + +class UserAdmin(ModelView, model=User): + column_list = [User.id, User.username] + + +admin.add_view(UserAdmin) + +@app.on_event("startup") +def on_startup(): + create_db_and_tables() + + +@app.exception_handler(RequestValidationError) +@app.exception_handler(ValidationError) +async def validation_exception_handler(request, exc: RequestValidationError|ValidationError): + errors = {} + print(exc.errors()) + for e in exc.errors(): + errors[e['loc'][-1] + "_error"] = e['msg'] + + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=jsonable_encoder({"detail": errors}), + ) + + +#JWT AUTH + + + + +@AuthJWT.load_config +def get_config(): + return config.settings + +# exception handler for authjwt +# in production, you can tweak performance using orjson response +@app.exception_handler(AuthJWTException) +def authjwt_exception_handler(request: Request, exc: AuthJWTException): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.message} + ) + + +#REDIS + + +''' +@AuthJWT.token_in_denylist_loader +def check_if_token_in_denylist(decrypted_token): + jti = decrypted_token['jti'] + entry = config.redis_conn.get(jti) + return entry and entry == 'true' + ''' + +#ROUTES + +app.include_router(routes.base.api_router) + + +@app.delete('/access-revoke') +def access_revoke(Authorize: AuthJWT = Depends()): + Authorize.jwt_required() + revoke_access(Authorize.get_raw_jwt()) + return {"detail": "Access token has been revoke"} + + +@app.delete('/refresh-revoke') +def refresh_revoke(Authorize: AuthJWT = Depends()): + Authorize.jwt_refresh_token_required() + revoke_refresh(Authorize.get_raw_jwt()) + return {"detail": "Refresh token has been revoke"} + + +class user(UserRead): + exercices: List[ExerciceRead] = None + +@app.post('/test', response_model=List[ExerciceRead] ) +def test(db:Session= Depends(get_session)): + #create_user_db('lilian', get_password_hash('Pomme937342'), db) + create_user_db('lilian2', get_password_hash('Pomme937342'), db) + exos = db.exec(select(Exercice)).all() + return exos + + +add_pagination(app) diff --git a/backend/api/routes/auth/routes.py b/backend/api/routes/auth/routes.py new file mode 100644 index 0000000..9f1a6ac --- /dev/null +++ b/backend/api/routes/auth/routes.py @@ -0,0 +1,87 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from services.jwt import revoke_access +from services.password import get_password_hash, verify_password +from services.auth import get_current_clientId, get_current_user, get_current_user_optional, jwt_refresh_required +from database.auth.crud import change_user_uuid, check_unique_username, create_user_db, delete_user_db, update_password_db, update_user_db +from services.auth import authenticate_user +from database.auth.models import PasswordSet, User, UserEdit, UserRead, UserRegister +from pydantic import BaseModel +from fastapi_jwt_auth import AuthJWT +from sqlmodel import Session,select +from database.db import get_session +router = APIRouter(tags=['Authentification']) + + +class Token(BaseModel): + access_token: str + token_type: str + refresh_token: str + + +@router.post("/login", response_model=Token) +def login_for_access_token(user: User = Depends(authenticate_user)): + Authorize = AuthJWT() + access_token = Authorize.create_access_token( + subject=str(user.clientId), fresh=True) + refresh_token = Authorize.create_refresh_token(subject=str(user.clientId)) + return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"} + +@router.post('/register', response_model=Token) +def register(user: UserRegister = Depends(UserRegister.as_form), Authorize: AuthJWT = Depends(), db: Session = Depends(get_session)): + username = check_unique_username(user.username, db) + if not username: + raise HTTPException(status_code = status.HTTP_400_BAD_REQUEST,detail={'username_error': "Nom d'utilisateur indisponible"}) + user = create_user_db(username, get_password_hash(user.password), db) + access_token = Authorize.create_access_token( + subject=str(user.clientId)) + refresh_token = Authorize.create_refresh_token(subject=str(user.clientId)) + return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"} + +@router.get('/users', response_model=List[UserRead]) +def get_users(db: Session = Depends(get_session)): + users = db.exec(select(User)).all() + return users + +@router.put('/user' , response_model=UserRead,) +def update_user(user: UserEdit = Depends(UserEdit.as_form), clientId: str = Depends(get_current_clientId), db: Session = Depends(get_session)): + user_obj = update_user_db(clientId, user, db) + return user_obj + + +@router.put('/user/password') +def update_password(password: PasswordSet = Depends(PasswordSet.as_form), user: User = Depends(get_current_user), db: Session = Depends(get_session), Authorize: AuthJWT = Depends()): + isValid = verify_password(password.old_password, user.hashed_password) + if not isValid: + raise HTTPException(status_code=401, detail={'old_password_error': 'Mot de passe invalide'}) + + user_obj = update_password_db(user.id, password.password, db) + user_obj = change_user_uuid(user.id, db) + + access_token = Authorize.create_access_token( + subject=str(user_obj)) + refresh_token = Authorize.create_refresh_token(subject=str(user_obj)) + return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"} + + +@router.post('/logout') +def logout(user: User = Depends(get_current_user), db: Session = Depends(get_session),): + change_user_uuid(user.id, db) + return {'ok': True} + + +@router.delete('/user') +def delete_user(user: User = Depends(authenticate_user), db: Session = Depends(get_session)): + delete_user_db(user.id, db) + return {'ok': True} + + +@router.post('/check-access',) +def check_token(user: User = Depends(get_current_user_optional)): + return {'username': user.username} if user != None else False + +@router.post('/refresh') +def refresh(Authorize: AuthJWT = Depends(jwt_refresh_required)): + current_user = Authorize.get_jwt_subject() + new_access_token = Authorize.create_access_token(subject=current_user) + return {"access_token": new_access_token} diff --git a/backend/api/routes/base.py b/backend/api/routes/base.py new file mode 100644 index 0000000..ea335e6 --- /dev/null +++ b/backend/api/routes/base.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +import routes.auth.routes +import routes.exercices.routes +import routes.room.routes +api_router = APIRouter() + + +api_router.include_router(routes.auth.routes.router) +api_router.include_router(routes.exercices.routes.router) +api_router.include_router(routes.room.routes.router) diff --git a/backend/api/routes/exercices/routes.py b/backend/api/routes/exercices/routes.py new file mode 100644 index 0000000..2ee2dd5 --- /dev/null +++ b/backend/api/routes/exercices/routes.py @@ -0,0 +1,183 @@ +from typing import List +from fastapi import APIRouter, Depends, UploadFile, HTTPException, status +from database.auth.models import User +from database.db import get_session +from database.exercices.models import Exercice, ExerciceCreate, ExerciceEdit, ExerciceRead, ExercicesTagLink, Tag, TagCreate, TagRead +from services.auth import get_current_user, get_current_user_optional +from sqlmodel import Session, select, col +from database.exercices.crud import add_tags_db, check_exercice_author, check_tag_author, create_exo_db, delete_exo_db, get_exo_dependency, clone_exo_db, parse_exo_tags, remove_tag_db, serialize_exo, update_exo_db, get_tags_dependency +from services.exoValidation import validate_file, validate_file_optionnal +from services.io import add_fast_api_root, get_filename_from_path +from fastapi.responses import FileResponse +from sqlmodel import func + +router = APIRouter(tags=['exercices']) + +def filter_exo_by_tags(exos: List[tuple[Exercice, str]], tags: List[Tag]): + valid_exos = [exo for exo, tag in exos if all( + str(t) in tag.split(',') for t in tags)] + return valid_exos + + +def queryFilters_dependency(search: str = "", tags: List[int] | None = Depends(get_tags_dependency)): + return search, tags + + +@router.post('/exercices', response_model=ExerciceRead, status_code=status.HTTP_201_CREATED) +def create_exo(exercice: ExerciceCreate = Depends(ExerciceCreate.as_form), file: UploadFile = Depends(validate_file), user: User = Depends(get_current_user), db: Session = Depends(get_session)): + file_obj = file.file._file + file_obj.name = file.filename + exo_obj = create_exo_db(exercice=exercice, user=user, + exo_source=file_obj, db=db) + return serialize_exo(exo=exo_obj, user_id=user.id, db=db) + + +@router.post('/clone/{id_code}', response_model=ExerciceRead) +def clone_exo(exercice: Exercice | None = Depends(get_exo_dependency), user: User = Depends(get_current_user), db: Session = Depends(get_session)): + if not exercice: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={ + "Exercice introuvable"}) + exo_obj = clone_exo_db(exercice, user, db) + if type(exo_obj) == str: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=exo_obj) + return serialize_exo(exo=exo_obj, user_id=user.id, db=db) + + + +@router.get('/exercices/user', response_model=List[ExerciceRead]) +def get_user_exercices(user: User = Depends(get_current_user), queryFilters: tuple[str, List[int] | None] = Depends(queryFilters_dependency), db: Session = Depends(get_session)): + search, tags = queryFilters + + if tags is not None and len(tags) != 0: + statement = select(Exercice, func.group_concat( + ExercicesTagLink.tag_id)) + else: + statement = select(Exercice) + + statement = statement.where(Exercice.author_id == user.id) + statement = statement.where(Exercice.name.startswith(search)) + + if tags is not None and len(tags) != 0: + statement = statement.join(ExercicesTagLink).where( + col(ExercicesTagLink.tag_id).in_(tags)).group_by(ExercicesTagLink.exercice_id) + + exercices = db.exec(statement).all() + if tags is not None and len(tags) != 0: + exercices = filter_exo_by_tags(exercices, tags) + + exercices = [ + serialize_exo(exo=e, user_id=user.id, db= db) for e in exercices] + return exercices + + +@router.get('/exercices/public', response_model=List[ExerciceRead]) +def get_public_exercices(user: User | None = Depends(get_current_user_optional), queryFilters: tuple[str, List[int] | None] = Depends(queryFilters_dependency), db: Session = Depends(get_session)): + search, tags = queryFilters + + if user is not None: + if tags is not None and len(tags) != 0: + statement = select(Exercice, func.group_concat( + ExercicesTagLink.tag_id)) + else: + statement = select(Exercice) + + statement = statement.where(Exercice.author_id != user.id) + statement = statement.where(Exercice.private == False) + statement = statement.where(Exercice.name.startswith(search)) + + if tags is not None and len(tags) != 0: + statement = statement.join(ExercicesTagLink).where( + col(ExercicesTagLink.tag_id).in_(tags)).group_by(ExercicesTagLink.exercice_id) + + exercices = db.exec(statement).all() + + if tags is not None and len(tags) != 0: + exercices = filter_exo_by_tags(exercices, tags) + + exercices = [ + serialize_exo(exo=e, user_id=user.id, db=db) for e in exercices] + return exercices + else: + statement = select(Exercice) + statement = statement.where(Exercice.private == False) + statement = statement.where(Exercice.name.startswith(search)) + exercices = db.exec(statement).all() + return [serialize_exo(exo=e, user_id=None, db=db) for e in exercices] + + +@router.get('/exercice/{id_code}', response_model=ExerciceRead) +def get_exercice(exo: Exercice = Depends(get_exo_dependency), user: User | None = Depends(get_current_user_optional), db: Session = Depends(get_session)): + return serialize_exo(exo=exo, user_id=getattr(user, 'id', None), db=db) + + +@router.put('/exercice/{id_code}', response_model=ExerciceRead) +def update_exo(file: UploadFile = Depends(validate_file_optionnal), exo: Exercice = Depends(check_exercice_author), exercice: ExerciceEdit = Depends(ExerciceEdit.as_form), db: Session = Depends(get_session)): + if exo is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail='Exercice introuvable') + if exo == False: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail='Cet exercice ne vous appartient pas') + + file_obj = None + if file: + file_obj = file.file._file + file_obj.name = file.filename + exo_obj = update_exo_db(exo, exercice, file_obj, db) + return serialize_exo(exo=exo_obj, user_id=exo_obj.author_id, db=db) + + +@router.delete('/exercice/{id_code}') +def delete_exercice(exercice: Exercice | bool | None = Depends(check_exercice_author), db: Session = Depends(get_session)): + if exercice is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail="Exercice introuvable") + if exercice == False: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail='Cet exercice ne vous appartient pas') + delete_exo_db(exercice, db) + return {'detail': 'Exercice supprimé avec succès'} + + +@router.post('/exercice/{id_code}/tags', response_model=ExerciceRead, tags=['tags']) +def add_tags(tags: List[TagCreate], exo: Exercice | None = Depends(get_exo_dependency), db: Session = Depends(get_session), user: User = Depends(get_current_user)): + if exo is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail='Exercice introuvable') + exo_obj = add_tags_db(exo, tags, user, db) + return serialize_exo(exo=exo_obj, user_id=user.id, db=db) + + +@router.delete('/exercice/{id_code}/tags/{tag_id}', response_model=ExerciceRead, tags=['tags']) +def remove_tag(exo: Exercice | None = Depends(get_exo_dependency), tag: Tag | None | bool = Depends(check_tag_author), db: Session = Depends(get_session)): + if exo is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail='Exercice introuvable') + if tag is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail='Tag introuvable') + if tag == False: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Vous n'êtes pas le propriétaire du tag") + + exo_obj = remove_tag_db(exo, tag, db) + return serialize_exo(exo=exo_obj, user_id=tag.author_id, db=db) + + +@router.get('/exercice/{id_code}/exo_source') +async def get_exo_source(exo: Exercice = Depends(check_exercice_author), db: Session = Depends(get_session)): + if exo is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail='Exercice introuvable') + if exo == False: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail='Cet exercice ne vous appartient pas') + path = add_fast_api_root(exo.exo_source) + filename = get_filename_from_path(path) + return FileResponse(path, headers={'content-disposition': 'attachment;filename='+filename}) + + +@router.get('/tags', response_model=List[TagRead], tags=['tags']) +def get_tags(user: User = Depends(get_current_user), db: Session = Depends(get_session)): + return user.tags diff --git a/backend/api/routes/exercices/routes_old.py b/backend/api/routes/exercices/routes_old.py new file mode 100644 index 0000000..c037694 --- /dev/null +++ b/backend/api/routes/exercices/routes_old.py @@ -0,0 +1,247 @@ +from pydantic import BaseModel +import csv +import io +from typing import List +from fastapi.exceptions import HTTPException +from fastapi.responses import FileResponse, StreamingResponse +from fastapi import APIRouter, Depends, UploadFile, status, Query +from services.database import get_exo +from services.io import add_fast_api_root, get_filename_from_path +from database.exercices.crud import add_tag_db, clone_exo_db, create_exo_db, delete_exo_db, get_exo_source_path, update_exo_db +from services.exoValidation import validate_file +from database.auth.models import User +from services.auth import check_author_exo, get_current_user, get_current_user_optional +from database.exercices.models import Exercice, Tag +from fastapi_jwt_auth import AuthJWT + +from schemas.exercices import ExerciceIn_form, ExerciceSchema, Exercices_schema, Exercice_schema, Exercices_withoutTags, Tag_schema, TagFull_schema, TagIn +from fastapi_pagination import paginate, Page + +from generateur.generateur_csv import Csv_generator + +router = APIRouter(tags=['exercices']) + + + +def get_exercice_data(exo: Exercice, user: User | None = None): + return {} + +# Exercices + + +class Exo(BaseModel): + name: str + id_code: str + tags: List[TagFull_schema] + +async def get_tags(tags: List[str] | None=Query(None)): + if tags is None: + return None + validated_tags = [] + for t in tags: + tag = await Tag.get_or_none(id_code=t) + if tag is not None: + validated_tags.append(tag) + return validated_tags + + +async def get_exo_by_tags(exos: Query, tags: List[Tag]): + valid_exos = [] + for e in exos: + exo_tags = await e.tags.all() + if (all(t in exo_tags for t in tags)): + valid_exos.append(e) + return valid_exos + +@router.get("/exercices", response_model=Page[Exo]) +async def get_exercices(search: str = "", tags: List[Tag] | None = Depends(get_tags)): + exos = Exercice.all() + print(Exo.schema_json(indent=4)) + print(Exercice_schema.schema_json(indent=4)) + if tags != None: + exos = await exos.filter(tags__id_code__in=[t.id_code for t in tags]).distinct() + exos = await get_exo_by_tags(exos, tags) + print(exos, [e.id_code for e in exos]) + exos = Exercice.filter(id_code__in = [e.id_code for e in exos]) + print( await exos) + exos = exos.filter(name__icontains= search) + exo_list = await Exercices_schema.from_queryset(exos) + return paginate(exo_list) + +async def get_exo_with_user_tags(exo: Exercice, user: User) -> Exercices_schema: + exo_data = await Exercice_schema.from_tortoise_orm(exo) + + exo_tags = await exo.tags.all() + exo_data = {**exo_data.dict(), 'tags': await TagFull_schema.from_queryset(exo.tags.filter(owner_id=user.id))} + + return exo_data + + + +@router.get('/exercices/user', response_model=Page[Exercices_schema]) +async def get_user_exercices(user: User = Depends(get_current_user), search: str = "", tags: List[Tag] | None = Depends(get_tags)): + + exos = Exercice.filter(author_id=user.id) + print('tatgs', tags) + if tags != None: + print('lolilol') + exos = await exos.filter(tags__id_code__in=[t.id_code for t in tags]).distinct() + exos = await get_exo_by_tags(exos, tags) + exos = Exercice.filter(id_code__in=[e.id_code for e in exos]) + exos = await exos.filter(name__icontains=search) + + exo_list = [await get_exo_with_user_tags(e, user) for e in exos] + print(len(exo_list)) + return paginate(exo_list) + + +def exclude_dict_key(dict, key): + return {k: v for k, v in dict.items() if k != key} + +@router.get('/exercices/public', response_model=Page[Exercices_schema | Exercices_withoutTags]) +async def get_public_exercices(user: User | None = Depends(get_current_user_optional), search: str = "", tags: List[Tag] | None = Depends(get_tags)): + is_authenticated = user != None + if is_authenticated: + exos = Exercice.filter(author_id__not=user.id, private = False, isOriginal=True) + if tags != None: + exos = await exos.filter(tags__id_code__in=[t.id_code for t in tags]).distinct() + exos = await get_exo_by_tags(exos, tags) + exos = Exercice.filter(id_code__in=[e.id_code for e in exos]) + else: + exos = Exercice.filter(private=False, isOriginal=True) + + exos = await exos.filter(name__icontains=search) + + if is_authenticated: + exo_list = [await get_exo_with_user_tags(e, user) for e in exos] + else: + exo_list = await Exercices_withoutTags.from_queryset(Exercice.all()) + + return paginate(exo_list) + +async def get_exo_tags(exo: Exercice, user: User) -> Exercice_schema: + exo_data = await Exercice_schema.from_tortoise_orm(exo) + + exo_tags = await exo.tags.all() + exo_data = {**exo_data.dict(), 'tags': await TagFull_schema.from_queryset(exo.tags.filter(owner_id=user.id))} + + return exo_data + +@router.get('/exercice/{id_code}', response_model=ExerciceSchema) +async def get_exercice(id_code: str, user: User = Depends(get_current_user_optional)): + is_authenticated = user != None + print(TagFull_schema.schema_json(indent=4), '\n\n', ExerciceSchema.schema_json(indent=4)) + exo = await Exercice.get_or_none(id_code=id_code) + if exo is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="exercice not found") + + if is_authenticated: + + author = await exo.author + is_author = author.id == user.id + + exo_obj = await Exercice_schema.from_tortoise_orm(exo) + exo_obj = await get_exo_tags(exo, user) + print(exo_obj) + exo_dict = exo_obj + exo_dict['is_author'] = is_author + exo_dict['exo_source_name'] = get_filename_from_path(exo.exo_source) + + return exo_dict + + exo_obj = await Exercice_schema.from_tortoise_orm(exo) + exo_dict = exo_obj.dict() + exo_dict['is_author'] = False + exo_dict['exo_source_name'] = get_filename_from_path(exo.exo_source) + exo_dict['tags'] = [] + return exo_dict + + +@router.post("/exercices", response_model=Exercice_schema) +async def create_exercice(file: UploadFile = Depends(validate_file), exo: ExerciceIn_form = Depends(ExerciceIn_form.as_form), user: User = Depends(get_current_user)): + file_obj = file.file._file + file_obj.name = file.filename + exo_obj = await create_exo_db(**{**exo.dict(exclude_unset=True)}, exo_source=file_obj, author_id=user.id) + return await Exercice_schema.from_tortoise_orm(exo_obj) + + +@router.delete("/exercice/{id_code}") +async def delete_exercice(id_code: str, author: User = Depends(check_author_exo)): + await delete_exo_db(id_code) + return {'detail': "Exercice successfully deleted"} + + + +@router.put("/exercice/{id_code}", response_model=Exercice_schema) +async def update_exercice(id_code: str, file: UploadFile = None, exo: ExerciceIn_form = Depends(ExerciceIn_form.as_form), author: User = Depends(check_author_exo)): + file_obj = None + if file != None: + file_obj = file.file._file + file_obj.name = file.filename + exo_obj = await update_exo_db(id_code, exo_source=file_obj, ** {**exo.dict(exclude_unset=True)}) + return await Exercice_schema.from_tortoise_orm(exo_obj) + + +@router.post('/exercices/{id_code}/clone', response_model=Exercice_schema) +async def clone_exercice(id_code: str, user: User = Depends(get_current_user)): + exo_obj = await clone_exo_db(id_code, user.id) + return await Exercice_schema.from_tortoise_orm(exo_obj) + + +@router.get('/exercices/{id_code}/exo_source') +async def get_exo_source(id_code: str, author: User = Depends(check_author_exo)): + path = await get_exo_source_path(id_code) + filename = get_filename_from_path(path) + return FileResponse(path, headers={'content-disposition': 'attachment;filename='+filename}) + + +# Tags +@router.get('/tags') +async def get_tags(user: User = Depends(get_current_user)): + return await Tag_schema.from_queryset(user.tags.all()) + + + +@router.post('/tags/{id_code}') +async def add_tags(id_code: str, tags: List[TagIn], user: User = Depends(get_current_user)): + exo = await Exercice.get(id_code = id_code) + exercice = await add_tag_db(exo, tags, user.id) + + return {'exo': await get_exo_with_user_tags(exo, user), 'tags': await TagFull_schema.from_queryset(user.tags.all())} + + +async def check_tag_owner(tag_id: str, user: User): + tag = await Tag.get(id_code=tag_id) + owner = await tag.owner + if owner.id != user.id: + raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail="Vous n'êtes pas le créateur du tag") + return tag + +@router.delete('/tags/{id_code}/{tag_id}') +async def delete_tag(id_code:str,tag_id: str, user: User = Depends(get_current_user)): + tag = await check_tag_owner(tag_id, user) + exo = await Exercice.get(id_code=id_code) + await exo.tags.remove(tag) + return await get_exo_with_user_tags(exo, user) + + +# Generation + +@router.get('/generator/csv/{exo_id}') +async def generate_csv(exo_id: str, filename: str): + exo = await Exercice.get(id_code=exo_id) + + if exo.csvSupport == False: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail='Impossible de générer cet exercice sur le support csv') + + source_path = add_fast_api_root(exo.exo_source) + consigne = exo.consigne + + buffer = io.StringIO() + writer = csv.writer(buffer, delimiter=',', + quotechar=',', quoting=csv.QUOTE_MINIMAL, dialect='excel') # mettre | comme sep un jour + Csv_generator(source_path, 10, 10, 12, consigne, writer) + + return StreamingResponse(iter([buffer.getvalue()]), headers={"Content-Disposition": f'attachment;filename="{filename}'}, media_type='text/csv') diff --git a/backend/api/routes/room/routes.py b/backend/api/routes/room/routes.py new file mode 100644 index 0000000..881643a --- /dev/null +++ b/backend/api/routes/room/routes.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["room"]) + +@router.post('/room') +def create_room(): + return \ No newline at end of file diff --git a/backend/api/routes/rooms/routes.py b/backend/api/routes/rooms/routes.py new file mode 100644 index 0000000..a604da4 --- /dev/null +++ b/backend/api/routes/rooms/routes.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends +from services.auth import get_current_user_optional +from database.rooms.crud import create_room_with_user_db, create_room_anonymous_db, get_room_db +from schemas.rooms import AnonymousIn_schema, RoomIn_schema, Room_schema +from database.auth.models import User +router = APIRouter() + + +@router.post('/rooms', response_model=Room_schema) +async def create_room(roomData: RoomIn_schema, anonymous: AnonymousIn_schema = None, user: User = Depends(get_current_user_optional)): + if user is not None: + room = await create_room_with_user_db(room=roomData, user=user) + return await Room_schema.from_tortoise_orm(room) + else: + room = await create_room_anonymous_db(room=roomData, anonymous=anonymous) + return await Room_schema.from_tortoise_orm(room) + + +@router.get('/room/{room_id}') +async def get_room(room_id: str): + room = await get_room_db(room_id) + if room is None: + return None + return await Room_schema.from_tortoise_orm(room) diff --git a/backend/api/schemas/base.py b/backend/api/schemas/base.py new file mode 100644 index 0000000..f39a2ad --- /dev/null +++ b/backend/api/schemas/base.py @@ -0,0 +1,7 @@ +from tortoise import Tortoise + + +Tortoise.init_models(['database.exercices.models', + 'database.auth.models'], "models") # "database.room.models" + +from schemas import exercices, rooms, users \ No newline at end of file diff --git a/backend/api/schemas/exercices.py b/backend/api/schemas/exercices.py new file mode 100644 index 0000000..a6a1ad5 --- /dev/null +++ b/backend/api/schemas/exercices.py @@ -0,0 +1,33 @@ +from tortoise.contrib.pydantic import pydantic_model_creator +from services.schema import as_form +from database.exercices.models import Exercice, Tag +from pydantic import BaseModel + +Exercice_schema = pydantic_model_creator(Exercice, name="Exercice", exclude=['tags.owner','author.clientId', 'created_at', 'updated_at']) + +ExerciceIn_schema = pydantic_model_creator( + Exercice, name="ExerciceIn", exclude_readonly=True, exclude=['id_code', 'exo_source', "tags_id", 'author_id', 'origin']) + + +Exercices_schema = pydantic_model_creator(Exercice, name="exercices", include=[ + 'name', 'id_code', 'tags'], exclude=['tags.owner']) + +Exercices_withoutTags = pydantic_model_creator(Exercice, name = 'exercices_wTags', include=['name', 'id_code']) +@as_form +class ExerciceIn_form(ExerciceIn_schema): + pass + +class ExerciceSchema(Exercice_schema): + is_author: bool + exo_source_name: str + + +Tag_schema = pydantic_model_creator(Tag, name="tag", include=['label', "id_code", "color"]) +TagFull_schema = pydantic_model_creator(Tag, name='tagFull', exclude=['owner', 'exercices']) +TagIn_schema = pydantic_model_creator( + Tag, name="tagIn", exclude_readonly=True, include=['label', 'id_code', 'color']) + +class TagIn(BaseModel): + label: str + id_code: str + color: str \ No newline at end of file diff --git a/backend/api/schemas/rooms.py b/backend/api/schemas/rooms.py new file mode 100644 index 0000000..ac9f72d --- /dev/null +++ b/backend/api/schemas/rooms.py @@ -0,0 +1,17 @@ +from tortoise.contrib.pydantic import pydantic_model_creator +from database.rooms.models import Room, AnonymousMember, Parcours +Room_schema = pydantic_model_creator( + Room, name='room', include=["id", 'name', 'id_code']) + +RoomIn_schema = pydantic_model_creator(Room, name='roomIn', exclude_readonly=True, exclude=[ + 'created_at', 'online', 'id_code', 'users_waiters']) + + +Anonymous_schema = pydantic_model_creator( + AnonymousMember, name='anonymousMember') +AnonymousIn_schema = pydantic_model_creator( + AnonymousMember, name='anonymousMemberIn', exclude_readonly=True, exclude=['id_code', 'room_id']) + +Parcours_schema = pydantic_model_creator(Parcours, name='parcours') +ParcoursIn_schema = pydantic_model_creator( + Parcours, name='parcoursIn', exclude_readonly=True) diff --git a/backend/api/schemas/users.py b/backend/api/schemas/users.py new file mode 100644 index 0000000..2c82daa --- /dev/null +++ b/backend/api/schemas/users.py @@ -0,0 +1,58 @@ +from typing import Optional +from tortoise.contrib.pydantic import pydantic_model_creator +from services.schema import as_form +from database.auth.models import User +from pydantic import BaseModel, validator +from services.password import validate_password +User_schema = pydantic_model_creator(User, name='users', include=[ + 'username', 'email', "name", "firstname"]) +UserIn_schema = pydantic_model_creator( + User, name='usersIn', exclude_readonly=True) + + +@as_form +class UserForm(BaseModel): + username: str + firstname: Optional[str] + name: Optional[str] + email: Optional[str] + + +@as_form +class UserIn_Form(UserIn_schema): + pass + + +@as_form +class UserLog(BaseModel): + username: str + password: str + + +@as_form +class UserRegister(UserLog): + password_confirm: str + + @validator('username') + def username_alphanumeric(cls, v): + assert v.isalnum(), 'must be alphanumeric' + return v + + @validator('password') + def password_validation(cls, v): + is_valid = validate_password(v) + if is_valid != True: + raise ValueError(is_valid) + return v + + @validator('password_confirm') + def password_match(cls, v, values): + if 'password' in values and v != values['password']: + raise ValueError('Les mots de passe ne correspondent pas') + return v + + +@as_form +class PasswordSet(BaseModel): + password: str + password_confirm: str diff --git a/backend/api/services/auth.py b/backend/api/services/auth.py new file mode 100644 index 0000000..524babd --- /dev/null +++ b/backend/api/services/auth.py @@ -0,0 +1,63 @@ +from services.password import verify_password +from fastapi_jwt_auth import AuthJWT +from fastapi import Depends, HTTPException, status +from database.auth.crud import get_user_from_clientId_db, get_user_from_username_db +from sqlmodel import Session +from database.db import get_session +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm + +bearer = OAuth2PasswordBearer(tokenUrl='/login') + + +def authenticate_user(user: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_session)): + user_db = get_user_from_username_db(user.username, db) + if not user_db: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"username_error": "Utilisateur introuvable"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + if not verify_password(user.password, user_db.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"password_error": "Mot de passe invalide"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + return user_db + +def jwt_required(Authorize: AuthJWT = Depends(), token: str = Depends(bearer)): + Authorize.jwt_required() + return Authorize + + +def jwt_optional(Authorize: AuthJWT = Depends()): + Authorize.jwt_optional() + return Authorize + + +def jwt_refresh_required(Authorize: AuthJWT = Depends(), token: str = Depends(bearer)): + Authorize.jwt_refresh_token_required() + return Authorize + + +def fresh_jwt_required(Authorize: AuthJWT = Depends(), token: str = Depends(bearer)): + Authorize.fresh_jwt_required() + return Authorize + +def get_current_clientId(Authorize: AuthJWT = Depends(jwt_required)): + return Authorize.get_jwt_subject() + + +def get_current_user(clientId: str = Depends(get_current_clientId), db: Session = Depends(get_session)): + user = get_user_from_clientId_db(clientId, db) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail='Utilisateur introuvable') + return user + +def get_current_user_optional(Authorize: AuthJWT = Depends(jwt_optional), db: Session = Depends(get_session)): + clientId = Authorize.get_jwt_subject() + if clientId: + return get_user_from_clientId_db(clientId, db) + return None + diff --git a/backend/api/services/database.py b/backend/api/services/database.py new file mode 100644 index 0000000..2877e10 --- /dev/null +++ b/backend/api/services/database.py @@ -0,0 +1,15 @@ +import random +import string +from sqlmodel import select, Session +from sqlmodel import SQLModel + + +def generate_unique_code(model: SQLModel, s: Session, length: int = 6): + while True: + code = ''.join(random.choices(string.ascii_uppercase, k=length)) + is_unique = s.exec(select(model).where( + model.id_code == code)).first() == None + if is_unique: + break + return code + diff --git a/backend/api/services/exoValidation.py b/backend/api/services/exoValidation.py new file mode 100644 index 0000000..8804620 --- /dev/null +++ b/backend/api/services/exoValidation.py @@ -0,0 +1,101 @@ + +from fastapi.exceptions import HTTPException +from fastapi import UploadFile, status +from tortoise.validators import Validator +from tortoise.exceptions import ValidationError +import types +from services.timeout import timeout +from services.io import is_binary_file +import os + +def checkExoSupportCompatibility(obj): + isPdf = False if (obj['pdf'] == None or ( + obj['calcul'] == False and obj['pdf'] == False)) else True + + isCsv = False if (obj['csv'] == None or ( + obj['calcul'] == False and obj['csv'] == False)) else True + + isWeb = False if (obj['web'] == None or ( + obj['calcul'] == False and obj['web'] == False)) else True + + return { + 'pdf': isPdf, 'csv': isCsv, 'web': isWeb} + + +def get_module_from_string(value: str, *args, **kwargs): + locs = {} + try: + exec(value, dict(), locs) + except Exception as err: + raise ValueError(err) + return locs + + +def execute_main_if_avalaible(spec, *args, **kwargs): + try: + return spec["main"]() + except KeyError as atrerror: + raise ValueError(f"Fonction 'main' introuvable") + except Exception as e: + raise ValueError(f'[Error] : {e}') + + +def get_spec_with_timeout(data, time): + return get_module_from_string(data) + with timeout(time, ValidationError('[Error] : Script took too long')): + return get_module_from_string(data) + + +def fill_empty_values(object): + default_object = {"calcul": False, 'pdf': False, 'csv': False, + 'web': False, 'correction': False} + return {**default_object, **object} + + +def get_support_from_data(data: str): + + locs = get_spec_with_timeout(data, 5) + result = execute_main_if_avalaible(locs) + result = fill_empty_values(result) + + exo_supports_compatibility = checkExoSupportCompatibility(result) + return exo_supports_compatibility + + +def get_support_from_path(path: str): + if not os.path.exists(path): + raise ValidationError('[Error] : No such file or directory') + is_binary = is_binary_file(path) + + if is_binary: + mode = 'rb' + else: + mode = 'r' + + with open(path, mode) as f: + data = f.read() if mode == "r" else f.read().decode('utf8') + return get_support_from_data(data) + + +async def validate_file(file: UploadFile): + data = await file.read() + try: + exo_supports_compatibility = get_support_from_data( + data) + if not exo_supports_compatibility['pdf'] and not exo_supports_compatibility['csv'] and not exo_supports_compatibility['web']: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"exo_source_error": + 'Exercice non valide (compatible avec aucun support)'}) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={ + "exo_source_error": e.args[0]}) + except HTTPException as e: + raise e + await file.seek(0) + return file + +async def validate_file_optionnal(file: UploadFile = None): + if not file: + return None + return await validate_file(file) + + diff --git a/backend/api/services/io.py b/backend/api/services/io.py new file mode 100644 index 0000000..21ff34a --- /dev/null +++ b/backend/api/services/io.py @@ -0,0 +1,62 @@ + +import os +import typing +import uuid + +TEXTCHARS = bytearray({7, 8, 9, 10, 12, 13, 27} | + set(range(0x20, 0x100)) - {0x7f}) + + +def delete_root_slash(path: str) -> str: + if path.startswith('/'): + path = path[1:] + return path + + +def add_fast_api_root(path): + fast_api_root = os.environ.get('FASTAPI_ROOT_URL') + if path.startswith(fast_api_root): + return path + return os.path.join(os.environ.get('FASTAPI_ROOT_URL'), delete_root_slash(path)) + +def remove_fastapi_root(path): + return path.replace(os.environ.get('FASTAPI_ROOT_URL'), "") + +def is_binary_file(file_path: str): + with open(file_path, 'rb') as f: + content = f.read(1024) + return bool(content.translate(None, TEXTCHARS)) + + +def get_or_create_dir(path: str) -> str: + if not os.path.exists(path): + os.mkdir(path) + return path + + +def get_filename(file: typing.IO, default: str = uuid.uuid4()) -> str: + if hasattr(file, 'name'): + return file.name + elif hasattr(file, 'filename'): + return file.filename + else: + return f"{default}.py" + + +def remove_if_exists(path): + if os.path.exists(path) and os.path.isfile(path): + os.remove(path) + + +def get_parent_dir(path): + return os.path.abspath(os.path.join(path, os.pardir)) + + +def get_ancestor(path: str, levels: int = 1): + for i in range(levels): + path = get_parent_dir(path) + return path + + +def get_filename_from_path(path): + return os.path.split(path)[-1] diff --git a/backend/api/services/jwt.py b/backend/api/services/jwt.py new file mode 100644 index 0000000..6997c75 --- /dev/null +++ b/backend/api/services/jwt.py @@ -0,0 +1,15 @@ +import config + +def revoke_access(decrypted_token): + if decrypted_token['type'] == 'access': + jti= decrypted_token['jti'] + config.redis_conn.setex(jti, config.settings.access_expires, 'true') + return {"detail": "Access token has been revoke"} + raise "Access token required" + +def revoke_refresh(decrypted_token): + if decrypted_token['type'] == 'refresh': + jti= decrypted_token['jti'] + config.redis_conn.setex(jti, config.settings.refresh_expires, 'true') + return {"detail": "Refresh token has been revoke"} + raise "Refresh token required" diff --git a/backend/api/services/password.py b/backend/api/services/password.py new file mode 100644 index 0000000..7e009ba --- /dev/null +++ b/backend/api/services/password.py @@ -0,0 +1,23 @@ +import re +from passlib.context import CryptContext + + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password): + return pwd_context.hash(password) + + +def validate_password(password): + if len(password) < 8: + return "Le mot de passe est trop court (8 caractères minimum)" + elif re.search('[0-9]', password) is None: + return 'Le mot de passe doit contenir au moins un chiffre' + elif re.search('[A-Z]', password) is None: + return "Le mot de passe doit contenir au moins une majuscule" + return True diff --git a/backend/api/services/schema.py b/backend/api/services/schema.py new file mode 100644 index 0000000..7181151 --- /dev/null +++ b/backend/api/services/schema.py @@ -0,0 +1,32 @@ +import inspect +from typing import Type + +from fastapi import Form +from pydantic import BaseModel +from pydantic.fields import ModelField + + +def as_form(cls: Type[BaseModel]): + new_parameters = [] + + for field_name, model_field in cls.__fields__.items(): + model_field: ModelField # type: ignore + + new_parameters.append( + inspect.Parameter( + model_field.alias, + inspect.Parameter.POSITIONAL_ONLY, + default=Form(...) if model_field.required else Form( + model_field.default), + annotation=model_field.outer_type_, + ) + ) + + async def as_form_func(**data): + return cls(**data) + + sig = inspect.signature(as_form_func) + sig = sig.replace(parameters=new_parameters) + as_form_func.__signature__ = sig # type: ignore + setattr(cls, 'as_form', as_form_func) + return cls diff --git a/backend/api/services/timeout.py b/backend/api/services/timeout.py new file mode 100644 index 0000000..5a3c3ec --- /dev/null +++ b/backend/api/services/timeout.py @@ -0,0 +1,21 @@ +from contextlib import contextmanager +import signal +def raise_timeout(signum, frame): + raise TimeoutError + +@contextmanager +def timeout(time:int, exception = TimeoutError): + # Register a function to raise a TimeoutError on the signal. + signal.signal(signal.SIGALRM, raise_timeout) + # Schedule the signal to be sent after ``time``. + signal.alarm(time) + + try: + yield + except TimeoutError: + print('TIMED OUT') + raise exception + finally: + # Unregister the signal so it won't be triggered + # if the timeout is not reached. + signal.signal(signal.SIGALRM, signal.SIG_IGN) diff --git a/backend/api/testing.py b/backend/api/testing.py new file mode 100644 index 0000000..16b10ac --- /dev/null +++ b/backend/api/testing.py @@ -0,0 +1,108 @@ +import uuid +from pydantic import validator +import io +import os +from fastapi import UploadFile, Form +from pathlib import Path +from typing import IO, Any, List, Optional, Type + +from fastapi import FastAPI +from sqlmodel import Field, Session, SQLModel, create_engine, select +from services.exoValidation import get_support_from_data + +from services.io import add_fast_api_root, get_filename, get_or_create_dir, remove_fastapi_root + + +class FileFieldMeta(type): + def __getitem__(self, upload_root: str,) -> Type['FileField']: + return type('MyFieldValue', (FileField,), {'upload_root': upload_root}) + + +class FileField(str, metaclass=FileFieldMeta): + upload_root: str + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, value: str | IO, values): + print(cls.upload_root, cls.default_naming_field) + upload_root = get_or_create_dir( + add_fast_api_root(cls.upload_root)) + + if not isinstance(value, str): + value.seek(0) + + is_binary = isinstance(value, io.BytesIO) + + name = get_filename(value, 'exo_source.py') + + parent = get_or_create_dir(os.path.join( + upload_root, values['id_code'])) + + mode = 'w+' if not is_binary else 'wb+' + + path = os.path.join(parent, name) + with open(path, mode) as f: + f.write(value.read()) + + return remove_fastapi_root(path) + + else: + if not os.path.exists(value): + raise ValueError('File does not exist') + return value + +class Hero(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + id_code : str + path: FileField['/testing', 'id_code'] + + + + +class HeroCreate(SQLModel): + path: FileField[42] + +class HeroRead(SQLModel): + id: int + id_code: str + path: str + + +sqlite_file_name = "testing.db" +sqlite_url = f"sqlite:///{sqlite_file_name}" + +connect_args = {"check_same_thread": False} +engine = create_engine(sqlite_url, echo=True, connect_args=connect_args) + + +def create_db_and_tables(): + SQLModel.metadata.create_all(engine) + + +app = FastAPI() + + +@app.on_event("startup") +def on_startup(): + create_db_and_tables() + + +@app.get("/heroes/", response_model=List[HeroRead]) +def read_heroes(): + with Session(engine) as session: + heroes = session.exec(select(Hero)).all() + return heroes + +@app.post("/heroes/", ) +def create_hero(file: UploadFile, name: str = Form()): + with Session(engine) as session: + file_obj = file.file._file + db_hero = Hero(path=file_obj, id_code=name) + session.add(db_hero) + session.commit() + session.refresh(db_hero) + return "db_hero" + + diff --git a/backend/api/tests/test_auth.py b/backend/api/tests/test_auth.py new file mode 100644 index 0000000..4dfa23b --- /dev/null +++ b/backend/api/tests/test_auth.py @@ -0,0 +1,249 @@ +from fastapi.testclient import TestClient + +VALID_USERNAME = 'lilian' +VALID_PASSWORD = 'Test12345' + +def test_register(client: TestClient, username = VALID_USERNAME): + print('usernae') + r = client.post('/register', data={"username": username, 'password': VALID_PASSWORD, 'password_confirm': VALID_PASSWORD}) + data = r.json() + print(data) + assert r.status_code == 200 + assert 'access_token' in data + assert 'refresh_token' in data + return {'access': data['access_token'], 'refresh': data['refresh_token']} + +def test_register_username_too_long(client: TestClient): + r = client.post('/register', data={"username": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 'password':VALID_PASSWORD, 'password_confirm':VALID_PASSWORD}) + data = r.json() + print(data) + assert r.status_code == 422 + assert data['detail']['username_error'] == 'ensure this value has at most 20 characters' + +def test_register_mdp_not_corresponding(client: TestClient): + r = client.post('/register', data={"username": VALID_USERNAME, + 'password': "Test12345", 'password_confirm': 'Test1234'}) + data = r.json() + print(data) + assert r.status_code == 422 + assert data['detail']['password_confirm_error'] == 'Les mots de passe ne correspondent pas' + +def test_register_mdp_missing_number(client: TestClient): + r = client.post('/register', data={"username": "lilian", + 'password': "Testttttt", 'password_confirm': 'Testttttt'}) + data = r.json() + print(data) + assert r.status_code == 422 + assert data['detail']['password_error'] == 'Le mot de passe doit contenir au moins un chiffre' + +def test_register_mdp_missing_maj(client: TestClient): + r = client.post('/register', data={"username":VALID_USERNAME, + 'password': "testttttt1", 'password_confirm': 'testttttt1'}) + data = r.json() + print(data) + assert r.status_code == 422 + assert data['detail']['password_error'] == 'Le mot de passe doit contenir au moins une majuscule' + +def test_register_mdp_too_short(client: TestClient): + r = client.post('/register', data={"username": VALID_USERNAME, + 'password': "t", 'password_confirm': 't'}) + data = r.json() + print(data) + assert r.status_code == 422 + assert data['detail'][ + 'password_error'] == 'Le mot de passe est trop court (8 caractères minimum)' + + +def test_register_username_indisponible(client: TestClient): + r = client.post('/register', data={"username": VALID_USERNAME, + 'password':VALID_PASSWORD, 'password_confirm':VALID_PASSWORD}) + rr = client.post('/register', data={"username": VALID_USERNAME, + 'password':VALID_PASSWORD, 'password_confirm':VALID_PASSWORD}) + data = rr.json() + print(data) + assert rr.status_code == 400 + assert data['detail'][ + 'username_error'] == "Nom d'utilisateur indisponible" + +def test_login(client: TestClient): + test_register(client) + + r = client.post('/login', data={"username": VALID_USERNAME, 'password': VALID_PASSWORD}) + data = r.json() + print(data) + assert r.status_code == 200 + assert 'access_token' in data + assert 'refresh_token' in data + return data['refresh_token'] + +def test_login_invalid_password(client: TestClient): + test_register(client) + + r = client.post('/login', data={"username": VALID_USERNAME, 'password': 'Test1234'}) + data = r.json() + print(data) + assert r.status_code == 401 + assert data['detail'][ + 'password_error'] == "Mot de passe invalide" + +def test_login_user_not_found(client: TestClient): + r = client.post('/login', data={"username": VALID_USERNAME, 'password': VALID_PASSWORD}) + data = r.json() + print(data) + assert r.status_code == 401 + assert data['detail'][ + 'username_error'] == "Utilisateur introuvable" + + +def test_check_token(client: TestClient): + token = test_register(client)['access'] + + r = client.post( + '/check-access', headers={'Authorization': 'Bearer ' + token}) + data = r.json() + print(data) + assert r.status_code == 200 + assert data['username'] == 'lilian' + +def test_refresh(client: TestClient): + refresh = test_login(client) + + r = client.post( + '/refresh', headers={'Authorization': 'Bearer ' + refresh}) + data = r.json() + assert r.status_code == 200 + assert 'access_token' in data + +#TODO : token invalid + +def test_update_user(client: TestClient): + token = test_register(client)['access'] + + r = client.put( + '/user', headers={'Authorization': 'Bearer ' + token}, data= {'username': 'lilian2', 'email': 'example@example.com', 'firstname': 'test', 'name': "test"}) + data = r.json() + print(data) + assert r.status_code == 200 + assert data['username'] == 'lilian2' + assert data['email'] == 'example@example.com' + assert data['firstname'] == 'test' + assert data['name'] == 'test' + +def test_update_user_invalid(client: TestClient): + token = test_register(client)['access'] + + r = client.put( + '/user', headers={'Authorization': 'Bearer ' + token}, data={'username': 'lilian222222222222222', 'email': 'example@example.com', 'firstname': 'test', 'name': "test"}) + data = r.json() + print(data) + assert r.status_code == 422 + assert data['detail']['username_error'] == 'ensure this value has at most 20 characters' + +def test_update_username_missing(client: TestClient): + token = test_register(client)['access'] + + r = client.put( + '/user', headers={'Authorization': 'Bearer ' + token}, data={ 'email': 'example@example.com', 'firstname': 'test', 'name': "test"}) + data = r.json() + print(data) + assert r.status_code == 422 + assert data['detail']['username_error'] == 'field required' +def test_update_username_missing(client: TestClient): + + r = client.put( + '/user', data={ 'email': 'example@example.com', 'firstname': 'test', 'name': "test"}) + data = r.json() + print(data) + assert r.status_code == 401 + assert data['detail'] == 'Not authenticated' + + +#TODO invalid jwt + +#Validation for delete user request work as same as login request so no need to test it +def test_delete_user(client: TestClient): + test_register(client) + + r = client.delete( + '/user', data={'username': VALID_USERNAME, 'password': VALID_PASSWORD}) + + data = r.json() + print(data) + assert r.status_code == 200 + assert data['ok'] == True + + +def test_delete_invalid_password(client: TestClient): + test_register(client) + + r = client.delete( + '/user', data={"username": VALID_USERNAME, 'password': 'Test1234'}) + data = r.json() + print(data) + assert r.status_code == 401 + assert data['detail'][ + 'password_error'] == "Mot de passe invalide" + + +def test_delete_user_not_found(client: TestClient): + r = client.delete( + '/user', data={"username": VALID_USERNAME, 'password': VALID_PASSWORD}) + data = r.json() + print(data) + assert r.status_code == 401 + assert data['detail'][ + 'username_error'] == "Utilisateur introuvable" + + +def test_update_password(client: TestClient): + tokens = test_register(client) + token = tokens['access'] + + new_password = "12345Test" + r = client.put( + '/user/password', data={'password': new_password, 'password_confirm': new_password, 'old_password': VALID_PASSWORD}, headers={'Authorization': 'Bearer ' + token}) + + data = r.json() + assert r.status_code == 200 + assert 'access_token' in data + assert 'refresh_token' in data + + new_token = data['access_token'] + + check_access = client.post('/check-access', headers = {'Authorization': 'Bearer ' + token}) + assert check_access.json() == False + + check_access = client.post( + '/check-access', headers={'Authorization': 'Bearer ' + new_token}) + + assert check_access.json()['username'] == VALID_USERNAME + + log = client.post("/login", data={'username': VALID_USERNAME, 'password': new_password}) + data = log.json() + assert log.status_code == 200 + assert 'access_token' in data + assert 'refresh_token' in data + + log = client.post("/login", data={'username': VALID_USERNAME, 'password': VALID_PASSWORD}) + data = log.json() + assert log.status_code == 401 + + +def test_logout(client: TestClient): + tokens = test_register(client) + token = tokens['access'] + + r = client.post('/logout', headers={'Authorization': 'Bearer ' + token}) + data = r.json() + + assert r.status_code == 200 + assert data['ok'] == True + + check_access = client.post( + '/check-access', headers={'Authorization': 'Bearer ' + token}) + assert check_access.json() == False + + + + \ No newline at end of file diff --git a/backend/api/tests/test_exos.py b/backend/api/tests/test_exos.py new file mode 100644 index 0000000..db957c4 --- /dev/null +++ b/backend/api/tests/test_exos.py @@ -0,0 +1,481 @@ +from typing import List +from pydantic import BaseModel +import time +from fastapi.testclient import TestClient +from tests.test_auth import test_register + + +def test_create(client: TestClient, name="test_exo", consigne="consigne", private=False, user=None): + + if user == None: + token = test_register(client, username="lilian")['access'] + username = 'lilian' + else: + token = user['token'] + username = user['username'] + r = client.post('/exercices', data={"name": name, "consigne": consigne, "private": private}, files={ + 'file': ('test.py', open('tests/testing_exo_source/exo_source.py', 'rb'))}, headers={"Authorization": "Bearer " + token}) + print(r.json()) + assert r.status_code == 201 + assert 'id_code' in r.json() + assert {**r.json(), 'id_code': None} == {'name': name, 'consigne': consigne, 'private': private, 'id_code': None, 'author': {'username': username}, 'original': None, 'tags': [], 'exo_source': 'test.py', 'supports': { + 'pdf': True, 'csv': True, 'web': False}, 'examples': {'type': 'csv', 'data': [{'calcul': 'None ++', 'correction': 'None ++'}, {'calcul': 'None ++', 'correction': 'None ++'}, {'calcul': 'None ++', 'correction': 'None ++'}]}, 'is_author': True} + return r.json() + + +def test_create_too_long(client: TestClient): + + token = test_register(client)['access'] + + r = client.post('/exercices', data={"name": "e"*51, "consigne": "e"*201, "private": False}, files={ + 'file': ('test.py', open('tests/testing_exo_source/exo_source.py', 'rb'))}, headers={"Authorization": "Bearer " + token}) + print('RESP', r.json()) + assert r.status_code == 422 + assert r.json()['detail'] == {'name_error': 'ensure this value has at most 50 characters', + 'consigne_error': 'ensure this value has at most 200 characters'} + + +def test_create_name_missing(client: TestClient): + token = test_register(client)['access'] + + r = client.post('/exercices', headers={"Authorization": "Bearer " + token}) + print(r.json()) + assert r.status_code == 422 + assert r.json()['detail'] == { + 'name_error': 'field required', 'file_error': 'field required'} + + +def test_create_missing_main(client: TestClient): + token = test_register(client)['access'] + + r = client.post('/exercices', data={"name": "test_exo", "consigne": "consigne", "private": False}, files={ + 'file': ('test.py', open('tests/testing_exo_source/exo_source_missing_main.py', 'rb'))}, headers={"Authorization": "Bearer " + token}) + print(r.json()) + assert r.status_code == 422 + assert r.json()['detail'] == { + 'exo_source_error': "Fonction 'main' introuvable"} + + +def test_create_invalid_source(client: TestClient): + token = test_register(client)['access'] + + r = client.post('/exercices', data={"name": "test_exo", "consigne": "consigne", "private": False}, files={ + 'file': ('test.py', open('tests/testing_exo_source/exo_source_invalid.py', 'rb'))}, headers={"Authorization": "Bearer " + token}) + print(r.json()) + assert r.status_code == 422 + assert r.json()['detail'] == { + 'exo_source_error': "Exercice non valide (compatible avec aucun support)"} + + +def test_clone(client: TestClient): + create = test_create(client) + id_code = create['id_code'] + token = test_register(client, username="lilian2")['access'] + rr = client.post('/clone/' + id_code, + headers={'Authorization': 'Bearer ' + token}) + print(rr.json()) + assert rr.status_code == 200 + assert 'id_code' in rr.json() + assert {**rr.json(), 'id_code': None} == {'name': 'test_exo', 'consigne': 'consigne', 'private': False, 'id_code': None, 'author': {'username': 'lilian2'}, 'original': {"id_code": id_code, "name": create['name']}, 'tags': [], 'exo_source': 'test.py', 'supports': { + 'pdf': True, 'csv': True, 'web': False}, 'examples': {'type': 'csv', 'data': [{'calcul': 'None ++', 'correction': 'None ++'}, {'calcul': 'None ++', 'correction': 'None ++'}, {'calcul': 'None ++', 'correction': 'None ++'}]}, 'is_author': True} + + +def test_update(client: TestClient): + token = test_register(client, username="lilian")['access'] + id_code = test_create(client, user={'token': token, 'username': "lilian"}, + consigne="testconsigne")['id_code'] + r = client.put('/exercice/' + id_code, data={"name": "name", "private": True}, files={ + 'file': ('test2.py', open('tests/testing_exo_source/exo_source.py', 'rb'))}, headers={"Authorization": "Bearer " + token}) + print(r.json()) + assert r.status_code == 200 + assert 'id_code' in r.json() + assert r.json() == {'name': "name", 'consigne': "testconsigne", 'private': True, 'id_code': id_code, 'author': {'username': 'lilian'}, 'original': None, 'tags': [], 'exo_source': 'test2.py', 'supports': { + 'pdf': True, 'csv': True, 'web': False}, 'examples': {'type': 'csv', 'data': [{'calcul': 'None ++', 'correction': 'None ++'}, {'calcul': 'None ++', 'correction': 'None ++'}, {'calcul': 'None ++', 'correction': 'None ++'}]}, 'is_author': True} + return r.json() + + +def test_update_missing_name(client: TestClient): + token = test_register(client, username="lilian")['access'] + id_code = test_create(client, user={'token': token, 'username': "lilian"})[ + 'id_code'] + r = client.put('/exercice/' + id_code, data={"consigne": "consigne", "private": False}, files={ + 'file': ('test2.py', open('tests/testing_exo_source/exo_source.py', 'rb'))}, headers={"Authorization": "Bearer " + token}) + print(r.json()) + assert r.status_code == 422 + assert r.json()['detail'] == {'name_error': 'field required'} + + +def test_update_missing_main(client: TestClient): + token = test_register(client, username="lilian")['access'] + id_code = test_create(client, user={'token': token, 'username': "lilian"})[ + 'id_code'] + r = client.put('/exercice/' + id_code, data={"consigne": "consigne", "private": False}, files={ + 'file': ('test2.py', open('tests/testing_exo_source/exo_source_missing_main.py', 'rb'))}, headers={"Authorization": "Bearer " + token}) + print(r.json()) + assert r.status_code == 422 + assert r.json()['detail'] == { + 'exo_source_error': "Fonction 'main' introuvable"} + + +def test_update_invalid(client: TestClient): + token = test_register(client, username="lilian")['access'] + id_code = test_create(client, user={'token': token, 'username': "lilian"})[ + 'id_code'] + r = client.put('/exercice/' + id_code, data={"consigne": "consigne", "private": False}, files={ + 'file': ('test2.py', open('tests/testing_exo_source/exo_source_invalid.py', 'rb'))}, headers={"Authorization": "Bearer " + token}) + print(r.json()) + assert r.status_code == 422 + assert r.json()['detail'] == { + 'exo_source_error': "Exercice non valide (compatible avec aucun support)"} + + +def test_update_too_long(client: TestClient): + + token = test_register(client, username="lilian")['access'] + id_code = test_create(client, user={'token': token, 'username': "lilian"})[ + 'id_code'] + r = client.put('/exercice/' + id_code, data={'name': 'e'*51, "consigne": "e"*201, "private": False}, files={ + 'file': ('test2.py', open('tests/testing_exo_source/exo_source.py', 'rb'))}, headers={"Authorization": "Bearer " + token}) + print(r.json()) + assert r.status_code == 422 + assert r.json()['detail'] == {'name_error': 'ensure this value has at most 50 characters', + 'consigne_error': 'ensure this value has at most 200 characters'} + + +def test_delete(client: TestClient): + token = test_register(client, username="lilian")['access'] + id_code = test_create(client, user={'token': token, 'username': "lilian"})[ + 'id_code'] + r = client.delete('/exercice/' + id_code, + headers={'Authorization': 'Bearer ' + token}) + print(r.json()) + assert r.status_code == 200 + assert r.json()['detail'] == 'Exercice supprimé avec succès' + + +def test_delete_not_found(client: TestClient): + token = test_register(client, username="lilian")['access'] + r = client.delete('/exercice/' + "test", + headers={'Authorization': 'Bearer ' + token}) + print(r.json()) + assert r.status_code == 404 + assert r.json()['detail'] == 'Exercice introuvable' + + +# TAGS +class Tags(BaseModel): + id_code: str | None + color: str + label: str + + +def test_add_tags(client: TestClient, name='name', tags: List[Tags] = [{'label': "name", 'color': "#ff0000", + 'id_code': "tag_id"}], user=None): + if user == None: + token = test_register(client, username="lilian")['access'] + user = {"token": token, 'username': "lilian"} + else: + token = user['token'] + + exo = test_create(client, name=name, user=user) + id_code = exo['id_code'] + r = client.post(f'/exercice/{id_code}/tags', json=tags, + headers={'Authorization': 'Bearer ' + token}) + print(r.json()) + data = r.json() + assert r.status_code == 200 + assert {**data, "tags": [{**t, "id_code": None} + for t in data['tags']]} == {**exo, 'tags': [*exo['tags'], *[{**t, 'id_code': None} for t in tags]]} + return r.json() + + +def test_add_tags_invalid_color(client: TestClient): + token = test_register(client, username="lilian")['access'] + exo = test_create(client, user={'token': token, 'username': "lilian"}) + id_code = exo['id_code'] + r = client.post(f'/exercice/{id_code}/tags', json=[ + {'label': "name", 'color': "color", 'id_code': "id_code"}], headers={'Authorization': 'Bearer ' + token}) + print(r.json()) + data = r.json() + assert r.status_code == 422 + assert data['detail'] == { + 'color_error': "value is not a valid enumeration member; permitted: '#00ff00', '#ff0000', '#0000ff', 'string'"} + + +def test_add_tags_too_long(client: TestClient): + token = test_register(client, username="lilian")['access'] + exo = test_create(client, user={'token': token, 'username': "lilian"}) + id_code = exo['id_code'] + r = client.post(f'/exercice/{id_code}/tags', json=[ + {'label': "n"*21, 'color': "#ff0000", 'id_code': "id_code"}], headers={'Authorization': 'Bearer ' + token}) + print(r.json()) + data = r.json() + assert r.status_code == 422 + assert data['detail'] == { + 'label_error': "ensure this value has at most 20 characters"} + + +def test_remove_tag(client: TestClient): + token = test_register(client, username="lilian")['access'] + exo = test_add_tags(client, user={"token": token, 'username': "lilian"}) + id_code = exo['id_code'] + tag_id = exo["tags"][0]["id_code"] + r = client.delete(f'/exercice/{id_code}/tags/{tag_id}', + headers={'Authorization': 'Bearer ' + token}) + print(r.json()) + assert r.json() == { + **exo, 'tags': exo['tags'][1:]} + + +def test_remove_tag_not_found(client: TestClient): + token = test_register(client, username="lilian")['access'] + exo = test_add_tags(client, user={"token": token, 'username': "lilian"}) + id_code = exo['id_code'] + tag_id = "none" + r = client.delete(f'/exercice/{id_code}/tags/{tag_id}', + headers={'Authorization': 'Bearer ' + token}) + print(r.json()) + assert r.json()['detail'] == 'Tag introuvable' + + +def test_remove_tag_exo_not_found(client: TestClient): + token = test_register(client, username="lilian")['access'] + + tag_id = "none" + id_code = "tets" + r = client.delete(f'/exercice/{id_code}/tags/{tag_id}', + headers={'Authorization': 'Bearer ' + token}) + print(r.json()) + assert r.json()['detail'] == 'Exercice introuvable' + + +def test_remove_tag_not_owner(client: TestClient): + token = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + exo = test_add_tags(client, user={"token": token, 'username': "lilian"}) + id_code = exo['id_code'] + tag_id = exo['tags'][0]['id_code'] + r = client.delete(f'/exercice/{id_code}/tags/{tag_id}', + headers={'Authorization': 'Bearer ' + token2}) + print(r.json()) + assert r.json()['detail'] == "Vous n'êtes pas le propriétaire du tag" + + +def test_exo_exo_source(client: TestClient): + token = test_register(client, username="lilian")['access'] + exo = test_create(client, user={'token': token, 'username': "lilian"}) + id_code = exo['id_code'] + r = client.get(f"/exercice/{id_code}/exo_source", + headers={'Authorization': 'Bearer ' + token}) + print(r.text) + print(r.headers) + assert r.text == open('tests/testing_exo_source/exo_source.py', 'r').read() + assert r.headers['content-disposition'].split('filename=')[-1] == 'test.py' + + +def test_get_users_exos(client: TestClient): + token1 = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + + prv = test_create(client, private=True, user={ + 'token': token1, 'username': "lilian"}) + exo_other_user = test_create( + client, user={'token': token2, 'username': "lilian2"}) + + r = client.get('/exercices/user', + headers={'Authorization': 'Bearer ' + token1}) + print(r.json()) + assert r.json() == [prv] + + +def test_get_user_with_search(client: TestClient): + token1 = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + + exo_other_user = test_create( + client, user={'token': token2, 'username': "lilian2"}) + + exo1 = test_create(client, name='test1', user={ + 'token': token1, 'username': "lilian"}) + exo2 = test_create(client, name='test2', user={ + 'token': token1, 'username': "lilian"}) + exo3 = test_create(client, name='text', user={ + 'token': token1, 'username': "lilian"}) + exo4 = test_create(client, name='autre', user={ + 'token': token1, 'username': "lilian"}) + exo5 = test_create(client, name='tes', user={ + 'token': token1, 'username': "lilian"}) + + r = client.get('/exercices/user', params={"search": "test"}, + headers={'Authorization': 'Bearer ' + token1}) + print(r.json()) + assert r.json() == [exo1, exo2] + + +def test_get_user_with_search(client: TestClient): + token1 = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + + exo_other_user = test_create( + client, user={'token': token2, 'username': "lilian2"}) + + exo1 = test_create(client, name='test1', user={ + 'token': token1, 'username': "lilian"}) + exo2 = test_create(client, name='test2', user={ + 'token': token1, 'username': "lilian"}) + exo3 = test_create(client, name='text', user={ + 'token': token1, 'username': "lilian"}) + exo4 = test_create(client, name='autre', user={ + 'token': token1, 'username': "lilian"}) + exo5 = test_create(client, name='tes', user={ + 'token': token1, 'username': "lilian"}) + + r = client.get('/exercices/user', params={"search": "test"}, + headers={'Authorization': 'Bearer ' + token1}) + print(r.json()) + assert r.json() == [exo1, exo2] + + +def test_get_user_with_tags(client: TestClient): + token1 = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + + tags1 = [{'label': "tag1", 'color': "#ff0000", 'id_code': None}] + tags2 = [{'label': "tag1", 'color': "#ff0000", 'id_code': None}, + {'label': "tag2", 'color': "#ff0000", 'id_code': None}] + tags3 = [{'label': "tag1", 'color': "#ff0000", 'id_code': None}, + {'label': "tag2", 'color': "#ff0000", 'id_code': None}, {'label': "tag3", 'color': "#ff0000", 'id_code': None}] + + exo_other_user = test_create( + client, user={'token': token2, 'username': "lilian2"}) + + exo1 = test_add_tags(client, user={ + 'token': token1, 'username': "lilian"}, tags=tags1) + + exo2 = test_add_tags(client, user={ + 'token': token1, 'username': "lilian"}, tags=tags2) + exo3 = test_add_tags(client, user={ + 'token': token1, 'username': "lilian"}, tags=tags3) + + tags1 = exo1['tags'] + tags2 = exo2['tags'] + tags3 = exo3['tags'] + r = client.get('/exercices/user', params={'tags': [*[t['id_code'] for t in tags2], 'notexist']}, + headers={'Authorization': 'Bearer ' + token1}) + print(r.json()) + assert r.json() == [exo2, exo3] + + +def test_get_user_with_tags_and_search(client: TestClient): + token1 = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + + tags1 = [{'label': "tag1", 'color': "#ff0000", 'id_code': None}] + tags2 = [{'label': "tag1", 'color': "#ff0000", 'id_code': None}, + {'label': "tag2", 'color': "#ff0000", 'id_code': None}] + tags3 = [{'label': "tag1", 'color': "#ff0000", 'id_code': None}, + {'label': "tag2", 'color': "#ff0000", 'id_code': None}, {'label': "tag3", 'color': "#ff0000", 'id_code': None}] + + exo_other_user = test_create( + client, user={'token': token2, 'username': "lilian2"}) + + exo1 = test_add_tags(client, user={ + 'token': token1, 'username': "lilian"}, tags=tags1, name="yes") + + exo2 = test_add_tags(client, user={ + 'token': token1, 'username': "lilian"}, tags=tags2, name="no") + + exo3 = test_add_tags(client, user={ + 'token': token1, 'username': "lilian"}, tags=tags3, name="yes") + + tags1 = exo1['tags'] + tags2 = exo2['tags'] + tags3 = exo3['tags'] + r = client.get('/exercices/user', params={"search": "yes", 'tags': [t['id_code'] for t in tags2]}, + headers={'Authorization': 'Bearer ' + token1}) + print(r.json()) + assert r.json() == [exo3] + + +def test_get_public_auth(client: TestClient): + token1 = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + + prv = test_create(client, private=True, user={ + 'token': token1, 'username': "lilian"}) + public1 = test_create( + client, user={'token': token2, 'username': "lilian2"}) + public2 = test_create( + client, user={'token': token1, 'username': "lilian"}) + + r = client.get('/exercices/public', + headers={'Authorization': 'Bearer ' + token2}) + print(r.json()) + assert r.json() == [{**public2, 'is_author': False}] + + +def test_get_public_auth_with_search(client: TestClient): + token1 = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + + prv = test_create(client, private=True, user={ + 'token': token1, 'username': "lilian"}) + public1 = test_create( + client, user={'token': token2, 'username': "lilian2"}) + + public2 = test_create( + client, user={'token': token1, 'username': "lilian"}, name="yes") + public3 = test_create( + client, user={'token': token1, 'username': "lilian"}, name="no") + + r = client.get('/exercices/public', + params={'search': "yes"}, headers={'Authorization': 'Bearer ' + token2}) + print(r.json()) + assert r.json() == [{**public2, 'is_author': False}] + +def test_get_public_no_auth(client: TestClient): + token1 = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + + prv = test_create(client, private=True, user={ + 'token': token1, 'username': "lilian"}) + public1 = test_create( + client, user={'token': token2, 'username': "lilian2"}) + public2 = test_create( + client, user={'token': token1, 'username': "lilian"}) + + r = client.get('/exercices/public') + print(r.json()) + assert r.json() == [{**public1, 'is_author': False}, + {**public2, 'is_author': False}] + + +def test_get_exo_no_auth(client: TestClient): + token = test_register(client, username="lilian")['access'] + exo = test_add_tags(client, user={'token': token, 'username': "lilian"}) + + r = client.get('/exercice/' + exo['id_code']) + assert r.json() == {**exo, "tags": [], 'is_author': False} + +def test_get_exo_auth(client: TestClient): + token = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + exo = test_add_tags(client, user={'token': token, 'username': "lilian"}) + + r = client.get('/exercice/' + exo['id_code'], + headers={'Authorization': 'Bearer ' + token2}) + print(r.json(), exo) + assert r.json() == {**exo, "tags": [], 'is_author': False} + +def test_get_tags(client: TestClient): + token = test_register(client, username="lilian")['access'] + token2 = test_register(client, username="lilian2")['access'] + exo = test_add_tags(client, user={'token': token, 'username': "lilian"}) + exo2 = test_add_tags(client, user={'token': token2, 'username': "lilian2"}) + + tags1 = exo['tags'] + tags2 = exo2['tags'] + + r = client.get('/tags', headers={'Authorization': 'Bearer ' + token2}) + print(r.json()) + assert r.json() == tags2 diff --git a/backend/api/tests/testing_exo_source/exo_source.py b/backend/api/tests/testing_exo_source/exo_source.py new file mode 100644 index 0000000..321b228 --- /dev/null +++ b/backend/api/tests/testing_exo_source/exo_source.py @@ -0,0 +1,11 @@ +import random + +""" +Fonction main() qui doit renvoyer un objet avec: + calcul: le calcul a afficher + result: la correction du calcul (pas de correction -> mettre None) +""" + + +def main(): + return {"csv": "None ++", 'web': None, "calcul": "1+1=2"} diff --git a/backend/api/tests/testing_exo_source/exo_source_invalid.py b/backend/api/tests/testing_exo_source/exo_source_invalid.py new file mode 100644 index 0000000..c3b3c47 --- /dev/null +++ b/backend/api/tests/testing_exo_source/exo_source_invalid.py @@ -0,0 +1,11 @@ +import random + +""" +Fonction main() qui doit renvoyer un objet avec: + calcul: le calcul a afficher + result: la correction du calcul (pas de correction -> mettre None) +""" + + +def main(): + return {"csv": None, 'web': None, "web": None} diff --git a/backend/api/tests/testing_exo_source/exo_source_missing_main.py b/backend/api/tests/testing_exo_source/exo_source_missing_main.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/api/tests/testing_exo_source/exo_source_missing_main.py @@ -0,0 +1 @@ + diff --git a/backend/api_old/apis/__init__.py b/backend/api_old/apis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api_old/apis/auth/route_auth.py b/backend/api_old/apis/auth/route_auth.py new file mode 100644 index 0000000..f2dc1d3 --- /dev/null +++ b/backend/api_old/apis/auth/route_auth.py @@ -0,0 +1,140 @@ +from typing import List, Optional +from fastapi_jwt_auth import AuthJWT +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, FastAPI, Form, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from jose import JWTError, jwt +from passlib.context import CryptContext +from pydantic import BaseModel +from api_old.schema.user import UserForm +from config import Room_schema, User_schema, UserIn_Form, UserIn_schema +from database.auth.crud import create_user_db, disable_user_db, delete_user_db, get_user_db, update_password_db, update_user_db +from services.auth import PasswordSet, check_unique_user, create_access_token, authenticate_user, fresh_jwt_required, get_current_clientId, get_current_user, jwt_refresh_required, jwt_required, User, UserRegister, validate_passwords, validate_register_user +from services.password import get_password_hash, validate_password +from database.auth.models import UserModel +from database.decorators import as_form + + + +class Token(BaseModel): + access_token: str + token_type: str + refresh_token: str + + +router = APIRouter() + + +@router.post("/login", response_model=Token) +async def login_for_access_token(user: User = Depends(authenticate_user), Authorize: AuthJWT = Depends()): + access_token = Authorize.create_access_token( + subject=user.username, fresh=True) + refresh_token = Authorize.create_refresh_token(subject=user.clientId) + return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"} + + +class Room(BaseModel): + name: str + id_code: str + owner: bool + + +class User(BaseModel): + username: str = None + firstname: str = None + name: str = None + email: str = None + rooms: list[Room] = [] + + class Config: + orm_mode = True + + +@router.get("/user", response_model=User) +async def read_users_me(Authorize: AuthJWT = Depends(get_current_user)): + Authorize.jwt_required() + clientId = Authorize.get_jwt_subject() + user = await get_user_db(clientId) + if user is not None and user.disabled == False: + print(user.room_owners) + sc = await User_schema.from_tortoise_orm(user) + sc = sc.dict() + # sc['rooms'] = await Room_schema.from_queryset(await user.rooms.all()) + f = await Room_schema.from_queryset(user.rooms.all()) + ro = await user.room_owners.all().values('room_id') + + rr = [r['room_id'] for r in ro] + ff = [r.id for r in f] + rooms = [{**r.dict(), "owner": r.id in ff} for r in f] + print(rooms) + sc = await User_schema.from_tortoise_orm(user) + sc = sc.dict() + + sc['rooms'] = rooms + return sc + + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail='User disabled') + + +@router.delete('/user') +async def delete_user(user: UserModel = Depends(authenticate_user)): + await delete_user_db(user.username) + return 'success' + + + + + +@router.put('/user') +async def update_user(user: UserForm = Depends(UserForm.as_form), username: str = Depends(get_current_clientId), Authorize: AuthJWT = Depends()): + user_obj = await update_user_db(username, **user.dict(exclude_unset=True)) + access_token = Authorize.create_access_token( + subject=user.clientId) + refresh_token = Authorize.create_refresh_token(subject=user.clientId) + return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"} + + +@router.post('/register', response_model=Token) +async def register(user: UserRegister = Depends(UserRegister.as_form), Authorize: AuthJWT = Depends()): + username = await check_unique_user(user.username) + + user = await create_user_db(user.username, get_password_hash(user.password)) + + access_token = Authorize.create_access_token( + subject=user.username) + refresh_token = Authorize.create_refresh_token(subject=user.username) + return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"} + + +@router.post('/user/disable') +async def disable_user(user: UserModel = Depends(authenticate_user)): + await disable_user_db(user.username) + return 'success' + + +@router.put('/user/password') +async def update_password(passwords: PasswordSet = Depends(validate_passwords),Authorize: AuthJWT=Depends(fresh_jwt_required)): + username = Authorize.get_jwt_subject() + user = await update_password_db(username, passwords.password) + return await User_schema.from_tortoise_orm(user) + + +@router.get('/users') +async def get_users(): + return await User_schema.from_queryset(UserModel.all()) + + +@router.post('/refresh') +async def refresh(Authorize: AuthJWT = Depends(jwt_refresh_required)): + current_user = Authorize.get_jwt_subject() + new_access_token = Authorize.create_access_token(subject=current_user) + return {"access_token": new_access_token} + + + +@router.post('/check-access') +async def check_token(Authorize: AuthJWT = Depends(jwt_required)): + return "" \ No newline at end of file diff --git a/backend/api_old/apis/base.py b/backend/api_old/apis/base.py new file mode 100644 index 0000000..0d9f4ee --- /dev/null +++ b/backend/api_old/apis/base.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter +import apis.exercices.route_exercices +import apis.auth.route_auth +import apis.room.route_room +import apis.room.websocket +api_router = APIRouter() + +api_router.include_router(apis.exercices.route_exercices.router) +api_router.include_router(apis.auth.route_auth.router) +api_router.include_router(apis.room.route_room.router) +api_router.include_router(apis.room.websocket.router) + + diff --git a/backend/api_old/apis/exercices/__init__.py b/backend/api_old/apis/exercices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api_old/apis/exercices/route_exercices.py b/backend/api_old/apis/exercices/route_exercices.py new file mode 100644 index 0000000..58ab77e --- /dev/null +++ b/backend/api_old/apis/exercices/route_exercices.py @@ -0,0 +1,165 @@ +import csv +import io +import os +import sys +from typing import List +from fastapi import APIRouter, Depends, Form, UploadFile, status +from api.schemas.exercices import ExerciceSchema +from database.exercices.validators import get_support_compatibility_for_exo_source_from_data +from database.auth.models import UserModel +from generateur.generateur_csv import Csv_generator +from database.decorators import as_form +from database.exercices.models import Exercice +from fastapi.exceptions import HTTPException +from database.exercices.crud import add_tag_db, create_exo_db, delete_tag_db, get_exo_source_path, update_exo_db, delete_exo_db, clone_exo_db +from config import Exercice_schema, ExerciceIn_form, ExerciceIn_schema, Exo_schema, TagIn_schema, User_schema +from services.auth import check_author_exo, get_current_clientId, get_current_user, jwt_optional, jwt_required +from services.io import get_abs_path_from_relative_to_root, get_filename_from_path +from fastapi.responses import FileResponse, Response, StreamingResponse +from pydantic import BaseModel +from fastapi_jwt_auth import AuthJWT +from fastapi_pagination import paginate, Page +router = APIRouter() + +# Exercices + + +@router.get("/exercices", response_model=Page[Exo_schema]) +async def get_exercices(): + exo_list = await Exo_schema.from_queryset(Exercice.all()) + return paginate(exo_list) + + +@router.get('/exercices/user', response_model=Page[Exo_schema]) +async def get_exercices(Authorize: AuthJWT=Depends(jwt_required)): + username = Authorize.get_jwt_subject() + user = await UserModel.get(username=username) + + exo_list = await Exo_schema.from_queryset(Exercice.filter(author_id=user.id)) + return paginate(exo_list) + +@router.get('/exercices/public', response_model=Page[Exo_schema]) +async def get_exercices(Authorize: AuthJWT=Depends(jwt_optional)): + username = Authorize.get_jwt_subject() + is_authenticated = username != None + if is_authenticated: + user = await UserModel.get(username=username) + exo_list = Exercice.filter(author_id__not = user.id) + return paginate(exo_list) + exo_list = await Exo_schema.from_queryset(Exercice.all()) + return paginate(exo_list) + + +@router.get('/exercice/{id_code}', response_model=ExerciceSchema) +async def get_exercice(id_code: str, Authorize: AuthJWT= Depends(jwt_optional)): + username = Authorize.get_jwt_subject() + is_authenticated = username != None + + exo = await Exercice.get(id_code=id_code) + if is_authenticated: + user = await UserModel.get(username=username) + author = await exo.author + is_author = author.id == user.id + exo_obj = await Exercice_schema.from_tortoise_orm(exo) + exo_dict = exo_obj.dict() + exo_dict['is_author'] = is_author + return exo_dict + + exo_obj = await Exercice_schema.from_tortoise_orm(exo) + exo_dict =exo_obj.dict() + exo_dict['is_author'] = False + return exo_dict + + +async def validate_file(file: UploadFile): + data = await file.read() + try: + exo_supports_compatibility = get_support_compatibility_for_exo_source_from_data( + data) + if not exo_supports_compatibility['isPdf'] and not exo_supports_compatibility['isCsv'] and not exo_supports_compatibility['isWeb']: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"exo_source": + '[Error] : Exercice non valide (compatible avec aucun support)'}) + + except Exception as e: + msg = e.args[0] + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"exo_source": msg}) + await file.seek(0) + return file + +@router.post("/exercices", response_model=Exercice_schema) +async def create_exercice(file: UploadFile = Depends(validate_file), exo: ExerciceIn_schema = Depends(ExerciceIn_form.as_form), current_user: UserModel = Depends(get_current_user)): + file_obj = file.file._file + file_obj.name = file.filename + exo_obj = await create_exo_db(**{**exo.dict(exclude_unset=True)}, exo_source=file_obj, author_id=current_user.id) + return await Exercice_schema.from_tortoise_orm(exo_obj) + + +@router.delete("/exercices/{id_code}", response_model=str) +async def delete_exercice(id_code: str, author: User_schema = Depends(check_author_exo)): + await delete_exo_db(id_code) + return "success" + + +@router.put("/exercices/{id_code}", response_model=Exercice_schema) +async def update_exercice(id_code: str, file: UploadFile, exo: ExerciceIn_form = Depends(ExerciceIn_form.as_form), author: User_schema = Depends(check_author_exo)): + file_obj = file.file._file + file_obj.name = file.filename + exo_obj = await update_exo_db(id_code, **{**exo.dict(exclude_unset=True)}, exo_source=file_obj) + return await Exercice_schema.from_tortoise_orm(exo_obj) + + +@router.post('/exercices/{id_code}/clone', response_model=Exercice_schema) +async def clone_exercice(id_code: str, user: User_schema = Depends(get_current_user)): + exo_obj = await clone_exo_db(id_code, user.id) + return await Exercice_schema.from_tortoise_orm(exo_obj) + + +@router.get('/exercices/{id_code}/exo_source') +async def get_exo_source(id_code: str, author: User_schema = Depends(check_author_exo)): + path = await get_exo_source_path(id_code) + filename = get_filename_from_path(path) + return FileResponse(path, filename=filename) + +# Tags + + +@router.post('/exercices/{id_code}/tags', response_model=Exercice_schema) +async def update_tag(id_code: str, tags_data: List[TagIn_schema], current_user: User_schema = Depends(get_current_user)): + exo_obj = await add_tag_db(id_code, tags_data, current_user.id) + return await Exercice_schema.from_tortoise_orm(exo_obj) + + +@router.delete('/exercices/{exo_id}/tags/{tags_id}', response_model=Exercice_schema) +async def remove_tag(exo_id: str, tag_id: str, owner: User_schema = Depends(check_author_exo)): + exo_obj = await delete_tag_db(exo_id, tag_id) + return await Exercice_schema.from_tortoise_orm(exo_obj) + + +@router.get('/generator/csv/{exo_id}') +async def generate_csv(exo_id: str, filename: str): + exo = await Exercice.get(id_code=exo_id) + + if exo.csvSupport == False: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail='Impossible de générer cet exercice sur le support csv') + + source_path = get_abs_path_from_relative_to_root(exo.exo_source) + consigne = exo.consigne + + buffer = io.StringIO() + writer = csv.writer(buffer, delimiter=',', + quotechar=',', quoting=csv.QUOTE_MINIMAL, dialect='excel') # mettre | comme sep un jour + Csv_generator(source_path, 10, 10, 12, consigne, writer) + + return StreamingResponse(iter([buffer.getvalue()]), headers={"Content-Disposition": f'attachment;filename="{filename}'}, media_type='text/csv') + + +class ExoOption(BaseModel): + id: str + nbInExo: int + nbOfExo: int + + +@router.post('/generator/pdf') +async def generate_pdf(exos_list: List[ExoOption]): + return diff --git a/backend/api_old/apis/room/route_room.py b/backend/api_old/apis/room/route_room.py new file mode 100644 index 0000000..4f50ab4 --- /dev/null +++ b/backend/api_old/apis/room/route_room.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, Depends, Request +from config import AnonymousIn_schema, Room_schema, RoomIn_schema, User_schema +from database.auth.models import UserModel +from database.room.crud import create_room_anonymous_db, create_room_with_user_db, get_room_db +from database.room.models import Room +from services.auth import get_current_user_optional +router = APIRouter() + +@router.post('/rooms') +async def create_room(roomData: RoomIn_schema, anonymous: AnonymousIn_schema = None, user: User_schema = Depends(get_current_user_optional)): + if user is not None: + room= await create_room_with_user_db(room = roomData, user=user) + return await Room_schema.from_tortoise_orm(room) + else: + room = await create_room_anonymous_db(room = roomData, anonymous = anonymous) + return await Room_schema.from_tortoise_orm(room) + +@router.get('/room/{room_id}') +async def get_room(room_id: str): + room = await get_room_db(room_id) + if room is None: + return None + return await Room_schema.from_tortoise_orm(room) + +@router.get('/room/check/{room_id}') +async def check_room(room_id:str): + room = await get_room_db(room_id) + if room is None: + return False + return True + +@router.post('/room/{room_id}/join') +async def join_room(room_id: str, anonymous: AnonymousIn_schema = None, user: User_schema = Depends(get_current_user_optional)): + room = await Room.get(id_code=room_id) + user = await UserModel.get(id=user.id) + if room.private == True: + if user is not None: + await room.users_waiters.add(user) + return 'waiting' + else: + return + else: + if user is not None: + await room.users.add(user) + return 'logged in' + + +@router.delete('/room/{room_id}') +async def delete_room(room_id): + return + + +@router.get('/rooms') +async def get_rooms(): + rooms = Room.all() + return await Room_schema.from_queryset(rooms) + + +@router.get('/test/{room_id}') +async def test(room_id): + room = await Room.get(id_code=room_id) + ano = await room.anonymousmembers + print(await ano.get(id_code='JTSGUC')) + return "user" \ No newline at end of file diff --git a/backend/api_old/apis/room/websocket.py b/backend/api_old/apis/room/websocket.py new file mode 100644 index 0000000..e45104d --- /dev/null +++ b/backend/api_old/apis/room/websocket.py @@ -0,0 +1,315 @@ +import json +from typing import Dict, List, Union +from fastapi import Cookie, Depends, FastAPI, HTTPException, Query, WebSocket, status, APIRouter, WebSocketDisconnect, status +from fastapi.responses import HTMLResponse +from config import User_schema +from database.auth.models import UserModel +from database.exercices.crud import generate_unique_code +from database.room.crud import check_anonymous_owner, check_user_in_room, check_user_owner, connect_room, create_waiter_anonymous, create_waiter_by_user, disconnect_room, get_member_by_code, validate_name_in_room +from database.room.models import AnonymousMember, Room, RoomOwner, Waiter +from services.auth import get_user_from_token +from services.io import get_abs_path_from_relative_to_root +import secrets + +router = APIRouter() + +@router.get('/') +def index(): + return HTMLResponse(open(get_abs_path_from_relative_to_root('/index.html'), 'r').read()) + + +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[str,List[WebSocket]] = {} + + async def connect(self, websocket: WebSocket, room_id): + await websocket.accept() + + if room_id not in self.active_connections: + self.active_connections[room_id] = [] + + self.active_connections[room_id].append(websocket) + + async def add(self, room_id, ws): + if room_id not in self.active_connections: + self.active_connections[room_id] = [] + + self.active_connections[room_id].append(ws) + + def remove(self, websocket: WebSocket, room_id): + if room_id in self.active_connections: + try: + self.active_connections[room_id].remove(websocket) + except: + pass + + async def send_personal_message(self, message: str, websocket: WebSocket): + await websocket.send_text(message) + + async def broadcast(self, message: str, room_id): + if room_id in self.active_connections: + for connection in self.active_connections[room_id]: + await connection.send_json(message) + + +manager = ConnectionManager() + +class Consumer(): + def __init__(self, ws: WebSocket): + self.ws : WebSocket = ws + + async def connect(self): + pass + + async def receive(self): + pass + + async def disconnect(self): + pass + + async def run(self): + await self.connect() + try: + while True: + data = await self.ws.receive_text() + await self.receive(data) + except WebSocketDisconnect: + await self.disconnect() + + +class RoomConsumer(Consumer): + def __init__(self, ws:WebSocket, room_id, manager:ConnectionManager): + self.ws:WebSocket = ws + self.room_id = room_id + self.manager:ConnectionManager = manager + self.owner = False + + async def connect(self): + await self.ws.accept() + self.clientId = secrets.token_hex(32) + await self.manager.add(self.ws, self.room_id) + self.room : Room = await Room.get(id_code=self.room_id) + self.status = None + self.waiter = None + self.user = None + await self.ws.send_json({'type': 'accept'}) + + + + async def receive(self, data): + json_data = json.loads(data) + payload = json_data['data'] + type = json_data['type'] + + if type == 'auth': + token = payload['token'] + self.user = await get_user_from_token(token) + + if self.user is not None: + await self.ws.send_json({'type': 'auth_success'}) + else: + await self.ws.send_json({'type': 'auth_failed'}) + + + if type == "login" and self.room.private == True and self.user is not None: + if await check_user_in_room(self.room, self.user): + if await check_user_owner(self.room, self.user): + self.owner = True + await self.manager.add(f'{self.room_id}__owner', self.ws) + else: + await self.manager.add(self.room_id, self.ws) + await connect_room(self.room, self.user.id) + await self.manager.broadcast({'type': 'joined', 'data': {"name": self.user.username}}, self.room_id) + await self.manager.broadcast({'type': 'joined', 'data': {"name": self.user.username}}, f'{self.room_id}__owner') + + else: + self.waiter = await create_waiter_by_user(self.room, self.user) + + await self.ws.send_json({'data': "waiting"}) + await self.manager.add(f'{self.room_id}__waiting__{self.user.id}', self.ws) + await self.manager.broadcast({'type': 'add_waiter', 'data': {"name": self.user.username, 'id': self.user.id}}, f'{self.room_id}__owner') + + + if type == "login" and self.room.private == True and self.user is None: + if 'relogin_code' in payload: + + anonymous = await get_member_by_code(self.room, payload['relogin_code']) + if anonymous is not None: + if await check_anonymous_owner(self.room, anonymous): + self.owner = True + await self.manager.add(f'{self.room_id}__owner', self.ws) + else: + await self.manager.add(self.room_id, self.ws) + self.anonymous = anonymous + await connect_room(self.room, self.anonymous.id_code) + await self.manager.broadcast( + {'type': 'joined', 'data': {"name": anonymous.name}}, self.room_id) + + else: + valid_username = await validate_name_in_room(payload['name']) + if valid_username == True: + self.waiter = await create_waiter_anonymous(self.room, payload['name']) + + await self.ws.send_json({'type': "waiting"}) + await self.manager.add(f'{self.room_id}__waiting__{self.waiter.id_code}', self.ws) + await self.manager.broadcast({'type': 'add_waiter', 'data': { + "name": self.waiter.name, 'id': self.waiter.id_code}}, f'{self.room_id}__owner') + else: + await self.ws.send_json({"type": "error", 'data': {"user_input": valid_username}}) + + + if type == "login" and self.room.private == False and self.user is not None: + if await check_user_in_room(self.room, self.user): + if await check_user_owner(self.room, self.user): + self.owner = True + await self.manager.add(f'{self.room_id}__owner', self.ws) + else: + await self.manager.add(self.room_id, self.ws) + await connect_room(self.room, self.user.id) + await self.manager.broadcast({'type': 'joined', 'data': {"name": self.user.username}}, self.room_id) + await self.manager.broadcast({'type': 'joined', 'data': {"name": self.user.username}}, f'{self.room_id}__owner') + else: + await self.room.users.add(self.user) + await self.manager.add(self.room_id, self.ws) + await connect_room(self.room, self.user.id) + await self.manager.broadcast( + {'type': 'joined', 'data': {"name": self.user.username}}, self.room_id) + + await self.manager.broadcast( + {'type': 'joined', 'data': {"name": self.user.username}}, f'{self.room_id}__owner') + + + if type == 'login' and self.room.private == False and self.user is None: + if 'relogin_code' in payload: + + anonymous = await get_member_by_code(self.room, payload['relogin_code']) + if anonymous is not None: + + if await check_anonymous_owner(self.room, anonymous): + self.owner = True + await self.manager.add(f'{self.room_id}__owner', self.ws) + else: + await self.manager.add(self.room_id, self.ws) + self.anonymous = anonymous + await connect_room(self.room, self.anonymous.id_code) + await self.manager.broadcast( + {'type': 'joined', 'data': {"name": anonymous.name}}, self.room_id) + else: + valid_username = await validate_name_in_room(self.room, payload['name']) + if valid_username == True: + code = await generate_unique_code(AnonymousMember) + self.anonymous = await AnonymousMember.create(name=payload['name'], id_code=code, room_id=self.room.id) + await self.manager.add(self.room_id, self.ws) + self.owner = False + await connect_room(self.room, self.anonymous.id_code) + await self.manager.broadcast({'type': 'joined', 'data': {'name': self.anonymous.name}}, self.room_id) + await self.manager.broadcast({'type': 'joined', 'data': {'name': self.anonymous.name, 'code': self.anonymous.id_code}}, f'{self.room_id}__owner') + else: + await self.ws.send({'type': "error", "data": {"user_input": valid_username}}) + + + + if type == 'accept_waiter': + if self.owner == True: + id = payload['id'] + await self.manager.broadcast({'type': 'log_waiter', 'data': {}}, f'{self.room_id}__waiting__{id}') + + if type == 'refuse_waiter': + if self.owner == True: + id = payload['id'] + await self.manager.broadcast({'type': 'reject_waiter', 'data': {}}, f'{self.room_id}__waiting__{id}') + + if type == 'log_waiter': + if self.user is not None: + await self.room.users.add(self.user) + await self.waiter.delete() + self.manager.remove('f{self.room_id}__waiting__{self.user.id}', self.ws) + await self.manager.add(self.room_id, self.ws) + + await connect_room(self.room, self.user.id) + await self.manager.broadcast( + {'type': 'joined', 'data': {"name": self.user.username}}, self.room_id) + + await self.manager.broadcast( + {'type': 'joined', 'data': {"name": self.user.username}}, f'{self.room_id}__owner') + + else: + code = await generate_unique_code(AnonymousMember) + self.anonymous = await AnonymousMember.create(name=self.waiter.name, id_code=code, room_id=self.room.id) + + self.manager.remove(self.ws, f'{self.room_id}__waiting__{self.waiter.id_code}') + await self.waiter.delete() + self.waiter = None + await self.manager.add(self.room_id, self.ws) + self.owner = False + + await connect_room(self.room, self.anonymous.id_code) + await self.manager.broadcast({'type': 'joined', 'data': {'name': self.anonymous.name}}, self.room_id) + await self.manager.broadcast({'type': 'joined', 'data': {'name': self.anonymous.name, 'code': self.anonymous.id_code}}, f'{self.room_id}__owner') + elif type == 'ban': + if self.owner == True: + status = payload['status'] + name = "" + if status == 'user': + user = await UserModel.get(id=payload['id']) + await disconnect_room(self.room, user.id) + name = user.username + await self.room.users.remove(user) + elif status == 'anonymous': + anonymous = await AnonymousMember.get(id_code=payload['id']) + name = anonymous.name + await disconnect_room(self.room, anonymous.id_code) + await anonymous.delete() + + await self.manager.broadcast({'type': 'leave', 'data': {"name": name}}, self.room_id) + await self.manager.broadcast({'type': 'leave', 'data': {"name": name}},f'{self.room_id}__owner') + + elif type == "leave": + name = "" + if self.user is not None: + name = self.user.username + await self.room.users.remove(self.user) + + else: + name = self.anonymous.name + await self.anonymous.delete() + + await self.manager.broadcast({'type': 'leave', 'data': {"name": name}}, self.room_id) + await self.manager.broadcast({'type': 'leave', 'data': {"name": name}}, f'{self.room_id}__owner') + + async def disconnect(self): + if self.waiter != None: + self.manager.remove(self.ws, f'{self.room_id}__waiting__{self.waiter.id_code}') + await self.manager.broadcast({'type': "disconnect_waiter", 'data': {'name': self.waiter.name}}, self.room_id) + await self.manager.broadcast({'type': "disconnect_waiter", 'data': {'name': self.waiter.name}}, f'{self.room_id}__owner') + await self.waiter.delete() + if self.owner == True: + if self.user is not None: + disconnect_room(self.room, self.user.id) + else: + disconnect_room(self.room, self.anonymous.id_code) + self.manager.remove(self.ws, f'{self.room_id}__owner') + else: + self.manager.remove(self.ws, self.room_id) + if self.user is not None: + disconnect_room(self.room, self.user.id) + await self.manager.broadcast({'type': "disconnect", 'data': {'name': self.user.username}}, self.room_id) + await self.manager.broadcast({'type': "disconnect", 'data': {'name': self.user.username}}, f'{self.room_id}__owner') + elif self.anonymous is not None: + disconnect_room(self.room, self.anonymous.id_code) + await self.manager.broadcast({'type': "disconnect_waiter", 'data': {'name': self.anonymous.name}}, self.room_id) + await self.manager.broadcast({'type': "disconnect_waiter", 'data': {'name': self.anonymous.name}}, f'{self.room_id}__owner') + await self.manager.broadcast({'type': "disconnect", 'data': {'name': self}}, self.room_id) + await self.manager.broadcast({'type': f"Client left the chat"}, f'{self.room_id}__owner') + + +async def check_room(room_id): + room = await Room.get_or_none(id_code = room_id) + if room == None: + raise HTTPException(status_code = status.HTTP_404_NOT_FOUND, detail = 'Room does not exist ') + return room_id + +@router.websocket('/ws/{room_id}') +async def room_ws(ws: WebSocket, room_id:str = Depends(check_room), ): + consumer = RoomConsumer(ws, room_id, manager) + await consumer.run() diff --git a/backend/api_old/config.py b/backend/api_old/config.py new file mode 100644 index 0000000..586fc64 --- /dev/null +++ b/backend/api_old/config.py @@ -0,0 +1,58 @@ +from tortoise.contrib.pydantic import pydantic_model_creator +from database.decorators import as_form +from database.auth.models import UserModel +from database.exercices.models import Exercice, Tag +from database.room.models import Room, Parcours, AnonymousMember +from tortoise import Tortoise + + +Tortoise.init_models(['database.exercices.models', + 'database.auth.models', "database.room.models"], "models") + +Exercice_schema = pydantic_model_creator(Exercice, name="exercice", include=[ + "name", "tags", 'id_code', "consigne", 'pdfSupport', "csvSupport", 'examples']) + +ExerciceIn_schema = pydantic_model_creator( + Exercice, name="exerciceIn", exclude_readonly=True, exclude=['id_code', 'exo_source', "tags_id", 'author_id', 'origin']) + +Exo_schema = pydantic_model_creator(Exercice, name="exerciceszzz", include=[ + 'name', 'id_code', 'tags']) +@as_form +class ExerciceIn_form(ExerciceIn_schema): + pass + + +Tag_schema = pydantic_model_creator(Tag, name="tag", exclude=['owner', "id", ]) +TagIn_schema = pydantic_model_creator( + Tag, name="tagIn", exclude_readonly=True, exclude=['owner_id']) + +User_schema = pydantic_model_creator(UserModel, name='users', include=[ + 'username', 'email', "name", "firstname"]) +UserIn_schema = pydantic_model_creator( + UserModel, name='usersIn', exclude_readonly=True) + + +@as_form +class UserIn_Form(UserIn_schema): + pass + + +Room_schema = pydantic_model_creator( + Room, name='room', include=["id", 'name', 'id_code']) + +RoomIn_schema = pydantic_model_creator(Room, name='roomIn', exclude_readonly=True, exclude=[ + 'created_at', 'online', 'id_code', 'users_waiters']) + +Anonymous_schema = pydantic_model_creator( + AnonymousMember, name='anonymousMember') +AnonymousIn_schema = pydantic_model_creator( + AnonymousMember, name='anonymousMemberIn', exclude_readonly=True, exclude=['id_code', 'room_id']) + +Parcours_schema = pydantic_model_creator(Parcours, name='parcours') +ParcoursIn_schema = pydantic_model_creator( + Parcours, name='parcoursIn', exclude_readonly=True) + + +SECRET_KEY = "6323081020d8939e6385dd688a26cbca0bb34ed91997959167637319ba4f6f3e" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 diff --git a/backend/api_old/database/__init__.py b/backend/api_old/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api_old/database/auth/crud.py b/backend/api_old/database/auth/crud.py new file mode 100644 index 0000000..4798018 --- /dev/null +++ b/backend/api_old/database/auth/crud.py @@ -0,0 +1,40 @@ +from config import User_schema +from database.exercices.crud import generate_unique_code +from services.password import get_password_hash +from .models import UserModel + + +async def get_user_db(username): + return await UserModel.get_or_none(username=username) + +async def get_user_from_clientId_db(clientId): + return await UserModel.get_or_none(clientId=clientId) + + +async def create_user_db(username, password): + #id_code = generate_unique_code(UserModel) + return await UserModel.create(username=username, hashed_password=password) + +async def disable_user_db(username): + user =await UserModel.get(username=username) + user_obj = await User_schema.from_tortoise_orm(user) + await user.update_from_dict({**user_obj.dict(exclude_unset=True), 'disabled': True}).save() + + return user + +async def delete_user_db(username): + user = await UserModel.get(username=username) + await user.delete() + + +async def update_user_db(username_id: str, **kwargs): + user = await UserModel.get(username=username_id) + await user.update_from_dict({**kwargs}).save() + return user + +async def update_password_db(username, password): + print(username) + user = await UserModel.get(username=username) + + await user.update_from_dict({'hashed_password': get_password_hash(password)}).save(update_fields=["hashed_password"]) + return user \ No newline at end of file diff --git a/backend/api_old/database/auth/models.py b/backend/api_old/database/auth/models.py new file mode 100644 index 0000000..b2a238a --- /dev/null +++ b/backend/api_old/database/auth/models.py @@ -0,0 +1,22 @@ +import uuid +from tortoise.models import Model +from tortoise import fields + + +class UserModel(Model): + id = fields.IntField(pk=True) + clientId = fields.UUIDField(unique=True, default = uuid.uuid4) + username = fields.CharField(max_length = 100, unique=True) + hashed_password = fields.CharField(max_length = 255) + email = fields.CharField(null=True, max_length=255) + + name = fields.CharField(null=True, max_length=255) + firstname=fields.CharField(null=True, max_length=255) + + disabled = fields.BooleanField(default=False) + + class PydanticMeta: + exclude=['hashed_password'] + + class Meta: + table = "users" \ No newline at end of file diff --git a/backend/api_old/database/db.sqlite3 b/backend/api_old/database/db.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..d85eea8210113f6fadd0f0ffe18c5dfb28320b2b GIT binary patch literal 110592 zcmeI&Z*SW~9Ki9UU6U^THz>snnB-O3x~LN@tA>OCLFmjVwB4Gfz#bsWOU-5}i90*l z#uKE44e<&l@qkx=#2Y|J@EGwPc*qmPGX&xZXV+)PX_I!O);#H3wTWY&FXzwi&UfeQ z=EkbkGS$y)r(v|zrOb)U$VlcLRn25FBjWER@h5*K#l|uDLA+#11epJ)EFcsI0R#|0009ILKmY**5I_Kd1O)j1{{&FU5&;AdKmY**5I_I{ z1Q0*~fs_fb{-3gdP#gphKmY**5I_I{1Q0*~0R$2dVEsP0R#|0009ILKmY** z5I`Vh0<8b1EFcsI0R#|0009ILKmY**5I_Kd1O!RKmY**5I_I{1Q0*~0R#|8K!ElC1W?Hm0R#|0009ILKmY**5I_KdlnJo@ zpR#~Z90U+R009ILKmY**5I_I{1QHNn{XYRzvP1v@1Q0*~0R#|0009ILKpPps z5Q>8U0tg_000IagfB*srAb>yu0{s7f0;pt(00IagfB*srAbcme|e-`dcd^7$>VJ-h?{QI$A^5*Do zV~@s+k+Y-Ujo!)pnEhww??V-%^ZIjn<>bligSD1%qi)_ctd{BQzRoRIv~pEbtL1lB zHFbV>=e&BZpo*V)YkOWj)Hh@@BWJU39x^G(dIUspzwOz@M+6W+0D&hh(3vabmBq#E zmxkYY8cnbb3Bc#Jkg;qp^N4WQ6?4bJ*}dxFKe6Xyq7{0*c$^B)vN33Wo=ax1BK=C=5qP6Rw##9ghPBg^&P#ai5@C%`uIZxvd+md9}K-rfpWsYu6$^ zVf0>kYqhE_Z&d_iRS(ky>O1Wlb*rX3rrWNIEMoTI$rof|$GRiFtzWWIkj!q@t)@uz z!KMh3tTKh-!ttE)##}aIHMh-s?(Mo5^65sqW&59X&s8sZuZ519%PX_9*#`@LAX>&v z@tco_LF_r=x?Ae1l0n@K0Tvd5ON&?IrLI$g1U0*EJ5hruUFd2NlIL#IbOzV-wre`> zpsIc*pHp6+?Pn=)^pbcPe{lX-UO9Ct`}LwPY2GuPnpHF9`&ism?mlYRE`meCUg|m} zgl)RkO|M$Xo6SYfR^1yT^Pa7{cH60mv{mhs=w=e+)ZW=1uE`8;1+uYN?p4RSX*G@d zLG48}f?M`*H9}S`{~N=))qV5q3U1#U=&NHnW%*RUS|xMPOL8lBZ8Ya~MA!Y=6*Otr zyxlgNHM8gAu~=Q}Ie5-%SLel<#m*<$yrL-ChckYjenaqIMq=mB%N8=keYjS)%w}sv z^fFmsgVTpRtog@fh{QJC) zGC1&=<+!cBN4G84n@IO|pJD5`P$(8ha>`puzv%j)ddYtq6}{-V*OPkd|I@iBS=<@D)nr{VYg{*1uy zkD{%Y;-i4*KsaXgcMUO(l!HXk^*6-awjK_qd`_Tn3k9?CeAdVnekcLCtQq#F%f-)O2w!r@Vc# z-_U4x2|_RJzCS%D4g`_NBej<9irb?32~!_sU{xpKN6v|l{ipB`U~ zwx3&i_K+v}j}LS|gW1m7Gycuwff?OQ{3B7u&&I21bYQru>fICmk4{iW{bI<5Dd)@G z$XyNHqwQywW)68$DLUyy=Zk4S zau2Wgk&BK*TQl*=`WQ@|1r~7SwCWOzkHvHM@#M=E>@3m*E{$Y+Wi#uvbXpw0~-*x ztcEF`>h1q@v?QPS$ga0f?Qz>R@l?bWH|yrM)$%*D12T-=3S^ODr z*zbPZ)BN>P&yGp{|9_|fEhi&@00IagfB*srAb<5d!TLh>Jr)pq&D7YfA_aAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oU&f Ffd{_OAyWVV literal 0 HcmV?d00001 diff --git a/backend/api_old/database/db.sqlite3-wal b/backend/api_old/database/db.sqlite3-wal new file mode 100644 index 0000000000000000000000000000000000000000..d6e35266faea81feb49e76ed2f5358be00a8ae68 GIT binary patch literal 12392 zcmeI&y-LGS6bJAdqopZGMe(CJh!_Wlq~^OB1cQSZESf=#=#qZK#HKM#D3&gwlas5f zvnZ}ULZ8ET(Air{!6$Gi|HHk@Sq|rSw?Ego&D|5>bA^yOGA#9FEwZ20)XKf>w>CC) z+M4>?AADWRe{3y!v$V++9|=7u2tWV=5P$##AOHafKmY;|fB*#kMId1crLx<%x z#jH!J?+Kn#mjzWdeW4<4HwE8lC=JP!E1GIXVHA2XFV}cU=AFGON3Xhi?Km#S2co6* z#ZJF#hgrLK8YQhX@!g~9;aOn0mx1U6H|3sYcjzQ`xY{@`c-=MLpRN}=1=?eZ--I3% z1Rwwb2tWV=5P$##AOHafKmY<0CBU&IZj>p|wWjG^9p?h=^_>TQuyj89FMwQNqMjYg hKmY;|fB*y_009U<00Izz00e#o@(jDob+cxg{Q&hHMZf?6 literal 0 HcmV?d00001 diff --git a/backend/api_old/database/decorators.py b/backend/api_old/database/decorators.py new file mode 100644 index 0000000..f3d299d --- /dev/null +++ b/backend/api_old/database/decorators.py @@ -0,0 +1,32 @@ +import inspect +from typing import Type + +from fastapi import Form +from pydantic import BaseModel +from pydantic.fields import ModelField + +def as_form(cls: Type[BaseModel]): + new_parameters = [] + + for field_name, model_field in cls.__fields__.items(): + model_field: ModelField # type: ignore + + new_parameters.append( + inspect.Parameter( + model_field.alias, + inspect.Parameter.POSITIONAL_ONLY, + default=Form(...) if model_field.required else Form( + model_field.default), + annotation=model_field.outer_type_, + ) + ) + + async def as_form_func(**data): + return cls(**data) + + sig = inspect.signature(as_form_func) + sig = sig.replace(parameters=new_parameters) + as_form_func.__signature__ = sig # type: ignore + setattr(cls, 'as_form', as_form_func) + return cls + diff --git a/backend/api_old/database/exercices/crud.py b/backend/api_old/database/exercices/crud.py new file mode 100644 index 0000000..d97cbe1 --- /dev/null +++ b/backend/api_old/database/exercices/crud.py @@ -0,0 +1,102 @@ +import os +import random +import shutil +import string +from typing import List +from config import Exercice_schema, TagIn_schema +from services.io import get_abs_path_from_relative_to_root, get_ancestor, get_parent_dir, remove_fastapi_root, remove_if_exists, get_or_create_dir +from tortoise import Model +from generateur.generateur_main import Generateur +from .models import Exercice, Tag + + + + + + +async def create_exo_db(*args, **kwargs) -> Exercice: + code = await generate_unique_code(Exercice) + return await Exercice.create(*args, **{**kwargs, 'id_code': code}) + + +async def delete_exo_db(id_code: str): + exo = await Exercice.get(id_code=id_code) + path = get_abs_path_from_relative_to_root( exo.exo_source) + + parent = get_parent_dir(path) + + shutil.rmtree(parent) + + return await exo.delete() + +async def update_exo_db(id_code: str, **kwargs) -> Exercice: + exo = await Exercice.get(id_code=id_code) + path = get_abs_path_from_relative_to_root(exo.exo_source) + + remove_if_exists(path) + + await exo.update_from_dict({**kwargs, 'origin_id': exo.origin_id, 'isOriginal': exo.isOriginal}).save() + return exo + + +#flag +async def get_or_create_tag(id_code: str, data: List[TagIn_schema]): + tag = await Tag.get_or_none(id_code=id_code) + if tag == None: + code = await generate_unique_code(Tag) + return await Tag.create(**{**data,'id_code': code}) + return tag + +async def add_or_remove_tag(exo_id_code: str, tag: Tag): + exo = await Exercice.get(id_code = exo_id_code) + is_present = await exo.tags.all().get_or_none(id_code=tag.id_code) + if is_present == None: + await exo.tags.add(tag) + else: + await exo.tags.remove(tag) + return exo + +async def add_tag_db(id_code: str, tags_data: List[TagIn_schema], user_id:int) -> Exercice: + exo = await Exercice.get(id_code = id_code) + for t in tags_data: + tag = await get_or_create_tag(t.id_code, {**t.dict(exclude_unset=True), 'owner_id': user_id}) + await exo.tags.add(tag) + return exo + +async def delete_tag_db(exo_id: str, tag_id: str) -> Exercice: + exo = await Exercice.get(id_code=exo_id) + tag = await exo.tags.all().get(id_code=tag_id) + await exo.tags.remove(tag) + return exo + + +def clone_exo_source(path, id_code): + upload_root = get_ancestor(path, 2) + path = get_abs_path_from_relative_to_root(path) + new_path = get_abs_path_from_relative_to_root(os.path.join(upload_root, id_code)) + get_or_create_dir((new_path)) + + return remove_fastapi_root(shutil.copy(path, new_path)) + + +async def clone_exo_db(id_code:str, user_id): + exo = await Exercice.get(id_code=id_code) + new_id_code = await generate_unique_code(Exercice) + + exo_obj = await Exercice_schema.from_tortoise_orm(exo) + exo_obj = exo_obj.dict(exclude_unset=True) + exo_obj.pop('tags') + exo_obj.pop('exercices') + + path = clone_exo_source(exo.exo_source, new_id_code) + + new_exo = Exercice(**{**exo_obj, 'id_code': new_id_code, 'exo_source': path, + "isOriginal": False, 'author_id': user_id}, origin_id=exo.id) + + await new_exo.save() + return new_exo + +async def get_exo_source_path(id_code: str): + exo = await Exercice.get(id_code=id_code) + path = get_abs_path_from_relative_to_root(exo.exo_source) + return path \ No newline at end of file diff --git a/backend/api_old/database/exercices/customField.py b/backend/api_old/database/exercices/customField.py new file mode 100644 index 0000000..b2b960e --- /dev/null +++ b/backend/api_old/database/exercices/customField.py @@ -0,0 +1,62 @@ +import os +import typing +import uuid +from fastapi import UploadFile +from tortoise.fields import TextField +from tortoise import ConfigurationError +import io +from services.io import delete_root_slash, get_abs_path_from_relative_to_root, get_filename_from_path, get_or_create_dir, is_binary_file, get_filename, remove_fastapi_root + + + + +class FileField(TextField): + def __init__(self, *, upload_root: str, **kwargs): + super().__init__(**kwargs) + self.upload_root = delete_root_slash(upload_root) + + self.upload_root = get_or_create_dir(os.path.join(os.environ.get('FASTAPI_ROOT_URL'), self.upload_root)) + + + def _is_binary(self, file: UploadFile): + return isinstance(file, io.BytesIO) + + def to_db_value(self, value: typing.IO, instance): + if not isinstance(value, str): + print(instance) + super().validate(value) + value.seek(0) + is_binary = self._is_binary(value) + name = get_filename(value) + + parent = get_or_create_dir(os.path.join(self.upload_root, instance.id_code)) + + mode = 'w+' if not is_binary else 'wb+' + + path = os.path.join(self.upload_root, parent, name) + + with open(path, mode) as f: + f.write(value.read()) + + return remove_fastapi_root(path) + return value + + def to_python_value(self, value: str): + + if not self._is_binary: + if is_binary_file(value): + mode = 'rb' + buffer = io.BytesIO() + else: + mode = 'r' + buffer = io.StringIO() + + buffer.name =get_filename_from_path(value) + + with open(get_abs_path_from_relative_to_root(value), mode) as f: + buffer.write(f.read()) + + buffer.seek(0) + return buffer + return value + diff --git a/backend/api_old/database/exercices/models.py b/backend/api_old/database/exercices/models.py new file mode 100644 index 0000000..41cbb5e --- /dev/null +++ b/backend/api_old/database/exercices/models.py @@ -0,0 +1,106 @@ +import asyncio +from io import BytesIO +import io +import os +import random +import string + +from tortoise.models import Model +from tortoise import fields +from tortoise.contrib.pydantic import pydantic_model_creator +import async_to_sync as sync +from tortoise.manager import Manager +from generateur.generateur_main import Generateur + +from services.io import get_abs_path_from_relative_to_root + +from .validators import ExoSourceValidator, get_support_compatibility_for_exo_source_from_path, get_support_compatibility_for_exo_source_from_data + +from .customField import FileField + + +class Tag(Model): + id = fields.IntField(pk=True) + id_code = fields.CharField(unique=True, default="", max_length=15) + + name = fields.CharField(max_length=35) + color = fields.CharField(max_length=100) + + owner = fields.ForeignKeyField('models.UserModel') + + +class Exercice(Model): + id = fields.IntField(pk=True) + id_code = fields.CharField(default="", max_length=10, unique=True) + + name = fields.CharField(max_length=50) + + consigne = fields.CharField(max_length=200, null=True, default="") + + exo_source = FileField(upload_root="/uploads", + validators=[ExoSourceValidator()]) + + updated_at = fields.DatetimeField(auto_now=True) + + private = fields.BooleanField(default=False) + + tags = fields.ManyToManyField('models.Tag', null=True) + + origin = fields.ForeignKeyField('models.Exercice', null = True) + isOriginal = fields.BooleanField(default = True) + + author = fields.ForeignKeyField('models.UserModel') + + def pdfSupport(self) -> bool: + if not isinstance(self.exo_source, io.BytesIO): + if os.path.exists(get_abs_path_from_relative_to_root(self.exo_source)): + support_compatibility = get_support_compatibility_for_exo_source_from_path( + get_abs_path_from_relative_to_root(self.exo_source)) + return support_compatibility['isPdf'] + return False + else: + self.exo_source.seek(0) + support_compatibility = get_support_compatibility_for_exo_source_from_data( + self.exo_source.read()) + return support_compatibility['isPdf'] + + def csvSupport(self) -> bool: + if not isinstance(self.exo_source, io.BytesIO): + if os.path.exists(get_abs_path_from_relative_to_root(self.exo_source)): + support_compatibility = get_support_compatibility_for_exo_source_from_path( + get_abs_path_from_relative_to_root(self.exo_source)) + return support_compatibility['isCsv'] + return False + else: + self.exo_source.seek(0) + support_compatibility = get_support_compatibility_for_exo_source_from_data( + self.exo_source.read()) + return support_compatibility['isCsv'] + + def webSupport(self) -> bool: + + if not isinstance(self.exo_source, io.BytesIO): + if os.path.exists(get_abs_path_from_relative_to_root(self.exo_source)): + support_compatibility = get_support_compatibility_for_exo_source_from_path( + get_abs_path_from_relative_to_root(self.exo_source)) + return support_compatibility['isWeb'] + return False + else: + self.exo_source.seek(0) + support_compatibility = get_support_compatibility_for_exo_source_from_data( + self.exo_source.read()) + return support_compatibility['isWeb'] + + def examples(self) -> dict: + if not isinstance(self.exo_source, io.BytesIO): + return { + "type": "Csv" if self.csvSupport() else "web" if self.webSupport() else None, + "data": Generateur(get_abs_path_from_relative_to_root(self.exo_source), 3, "csv" if self.csvSupport() else "web" if self.pdfSupport() else None, True) if self.csvSupport() == True or self.webSupport() == True else None + } + return {} + + class PydanticMeta: + computed = ["pdfSupport", "csvSupport", "webSupport", 'examples'] + exclude = ["exo_source", 'id', "exercices"] + + diff --git a/backend/api_old/database/exercices/validators.py b/backend/api_old/database/exercices/validators.py new file mode 100644 index 0000000..e8270df --- /dev/null +++ b/backend/api_old/database/exercices/validators.py @@ -0,0 +1,93 @@ +from contextlib import contextmanager +import os +import typing +from tortoise.validators import Validator +from tortoise.exceptions import ValidationError +import importlib.util +import types +from services.timeout import timeout + +from .customField import is_binary_file + + +def checkExoSupportCompatibility(obj): + isPdf = False if (obj['pdf'] == None or ( + obj['calcul'] == False and obj['pdf'] == False)) else True + + isCsv = False if (obj['csv'] == None or ( + obj['calcul'] == False and obj['csv'] == False)) else True + + isWeb = False if (obj['web'] == None or ( + obj['calcul'] == False and obj['web'] == False)) else True + + return { + 'isPdf': isPdf, 'isCsv': isCsv, 'isWeb': isWeb} + +def get_module_from_string(value: str) -> types.ModuleType: + spec = types.ModuleType('exo') + try: + exec(value, spec.__dict__) + except Exception as err: + raise ValidationError(f'[Error] : {err}') + + return spec + + + +def execute_main_if_present(spec): + try: + return spec.main() + except AttributeError as atrerror: + raise ValidationError(f"[Error] : function 'main' is missing") + except Exception as e: + raise ValidationError(f'[Error] : {e}') + +def get_spec_with_timeout(data, time): + with timeout(time, ValidationError('[Error] : Script took too long')): + return get_module_from_string(data) + +def fill_empty_values(object): + default_object = {"calcul": False, 'pdf': False, 'csv': False, + 'web': False, 'correction': False} + return {**default_object, **object} + + + +def get_support_compatibility_for_exo_source_from_data(data: str): + + spec = get_spec_with_timeout(data, 5) + result = execute_main_if_present(spec) + + + result = fill_empty_values(result) + + exo_supports_compatibility = checkExoSupportCompatibility(result) + return exo_supports_compatibility + + +def get_support_compatibility_for_exo_source_from_path(path): + if not os.path.exists(path): + raise ValidationError('[Error] : No such file or directory') + is_binary = is_binary_file(path) + + if is_binary: + mode = 'rb' + else: + mode = 'r' + with open(path, mode) as f: + data = f.read() if mode == "r" else f.read().decode('utf8') + return get_support_compatibility_for_exo_source_from_data(data) + + + + + +class ExoSourceValidator(Validator): + """ + A validator to validate ... + """ + def __call__(self, value: typing.IO): + exo_supports_compatibility = get_support_compatibility_for_exo_source_from_data( + value.read()) + if not exo_supports_compatibility['isPdf'] and not exo_supports_compatibility['isCsv'] and not exo_supports_compatibility['isWeb']: + raise ValidationError('[Error] : Exercice non valide (compatible avec aucun support)') diff --git a/backend/api_old/database/main.py b/backend/api_old/database/main.py new file mode 100644 index 0000000..590894b --- /dev/null +++ b/backend/api_old/database/main.py @@ -0,0 +1,79 @@ +import time +from typing import List + +from fastapi import Depends, FastAPI, HTTPException + +import backend.api.database.exercices.crud as crud, database, backend.api.database.exercices.models as models, schemas +from database import db_state_default + +database.db.connect() +database.db.create_tables([models.User, models.Item]) +database.db.close() + +app = FastAPI() + +sleep_time = 10 + + +async def reset_db_state(): + database.db._state._state.set(db_state_default.copy()) + database.db._state.reset() + + +def get_db(db_state=Depends(reset_db_state)): + try: + database.db.connect() + yield + finally: + if not database.db.is_closed(): + database.db.close() + + +@app.post("/users/", response_model=schemas.User, dependencies=[Depends(get_db)]) +def create_user(user: schemas.UserCreate): + db_user = crud.get_user_by_email(email=user.email) + if db_user: + raise HTTPException(status_code=400, detail="Email already registered") + return crud.create_user(user=user) + + +@app.get("/users/", response_model=List[schemas.User], dependencies=[Depends(get_db)]) +def read_users(skip: int = 0, limit: int = 100): + users = crud.get_users(skip=skip, limit=limit) + return users + + +@app.get( + "/users/{user_id}", response_model=schemas.User, dependencies=[Depends(get_db)] +) +def read_user(user_id: int): + db_user = crud.get_user(user_id=user_id) + if db_user is None: + raise HTTPException(status_code=404, detail="User not found") + return db_user + + +@app.post( + "/users/{user_id}/items/", + response_model=schemas.Item, + dependencies=[Depends(get_db)], +) +def create_item_for_user(user_id: int, item: schemas.ItemCreate): + return crud.create_user_item(item=item, user_id=user_id) + + +@app.get("/items/", response_model=List[schemas.Item], dependencies=[Depends(get_db)]) +def read_items(skip: int = 0, limit: int = 100): + items = crud.get_items(skip=skip, limit=limit) + return items + + +@app.get( + "/slowusers/", response_model=List[schemas.User], dependencies=[Depends(get_db)] +) +def read_slow_users(skip: int = 0, limit: int = 100): + global sleep_time + sleep_time = max(0, sleep_time - 1) + time.sleep(sleep_time) # Fake long processing request + users = crud.get_users(skip=skip, limit=limit) + return users diff --git a/backend/api_old/database/room/crud.py b/backend/api_old/database/room/crud.py new file mode 100644 index 0000000..8578ce9 --- /dev/null +++ b/backend/api_old/database/room/crud.py @@ -0,0 +1,90 @@ +from config import AnonymousIn_schema, RoomIn_schema, User_schema +from database.auth.models import UserModel +from .models import AnonymousMember, Room, RoomOwner, Waiter +from database.exercices.crud import generate_unique_code + + + +async def create_room_with_user_db(room: RoomIn_schema, user: User_schema): + code = await generate_unique_code(Room) + + room_obj = await Room.create(**room.dict(exclude_unset=True), id_code=code) + + user = await UserModel.get(id=user.id) + await room_obj.users.add(user) + await RoomOwner.create(room_id=room_obj.id, user_id=user.id) + return room_obj + +async def create_room_anonymous_db(room: RoomIn_schema, anonymous: AnonymousIn_schema): + code = await generate_unique_code(Room) + + room_obj = await Room.create(**room.dict(exclude_unset=True), id_code=code) + + anonymous_code = await generate_unique_code(AnonymousMember) + anonymous = await AnonymousMember.create(**anonymous.dict(exclude_unset=True), id_code=anonymous_code, room_id=room_obj.id) + + await RoomOwner.create(room_id=room_obj.id, anonymous_id=anonymous.id) + return room_obj + + +async def get_room_db(room_id:str): + room = await Room.get_or_none(id_code=room_id) + return room + + + +async def check_user_in_room(room: Room, user: UserModel): + return await room.users.filter(id=user.id).count() != 0 + +async def get_member_by_code(room: Room, code: str): + anonymous = await room.anonymousmembers + filtered_anonymous = [ + m for m in anonymous if m.id_code == code] + if len(filtered_anonymous) == 0: + return None + return filtered_anonymous[0] + +async def check_user_owner(room: Room, user: UserModel): + room_owner = await room.room_owner + user_owner = await room_owner.user + if user_owner == None: + return False + return user_owner.id == user.id + +async def check_anonymous_owner(room: Room, anonymous: AnonymousMember): + room_owner = await room.room_owner + anonymous_owner = await room_owner.anonymous + if anonymous_owner == None: + return False + return anonymous_owner.id_code == anonymous.id_code + + + +async def create_waiter_by_user(room: Room, user: UserModel ): + code = await generate_unique_code(Waiter) + return await Waiter.create(room_id=room.id, user_id=user.id, name=user.username, id_code=code) + +async def create_waiter_anonymous(room: Room, name: str): + code = await generate_unique_code(Waiter) + return await Waiter.create(name=name, id_code=code, room_id=room.id) + +async def connect_room(room: Room, code): + online = room.online + await room.update_from_dict({'online': [*online, code]}).save(update_fields=['online']) + return +async def disconnect_room(room: Room, code): + online = room.online + await room.update_from_dict({'online': [o for o in online if o!=code]}).save(update_fields=['online']) + return + +async def validate_name_in_room(room: Room, name): + anonymous = await room.anonymousmembers + if len([a for a in anonymous if a == name]) != 0: + return "Pseudo déjà utilisé" + if len(name) < 3: + return "Pseudo trop court" + if len(name) > 30: + return "Pseudo trop long" + return True + + diff --git a/backend/api_old/database/room/models.py b/backend/api_old/database/room/models.py new file mode 100644 index 0000000..58a4179 --- /dev/null +++ b/backend/api_old/database/room/models.py @@ -0,0 +1,72 @@ +from email.policy import default +from tortoise.models import Model +from tortoise import fields + + +class Room(Model): + id = fields.IntField(pk=True) + id_code = fields.CharField(max_length=30, unique=True) + + name = fields.CharField(max_length=255) + + created_at = fields.DatetimeField(auto_now_add=True) + + public_result = fields.BooleanField(default=False) + private = fields.BooleanField(default = True) + + online = fields.JSONField(default = list) + + users = fields.ManyToManyField('models.UserModel') + + class PydanticMeta: + exlude=('users__email') + +class AnonymousMember(Model): + id = fields.IntField(pk=True) + id_code = fields.CharField(max_length=30, unique=True) + + name = fields.CharField(max_length=255) + + room = fields.ForeignKeyField('models.Room') + + class PydanticMeta: + exclude=['room_owner', "id_code", 'id', 'challenger'] + + +class Waiter(Model): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=255) + id_code = fields.CharField(max_length=30, unique=True) + room = fields.ForeignKeyField('models.Room') + user = fields.ForeignKeyField("models.UserModel", null=True) + +class RoomOwner(Model): + user = fields.ForeignKeyField('models.UserModel', null = True) + anonymous = fields.OneToOneField('models.AnonymousMember', null=True) + room = fields.OneToOneField('models.Room', null=True) + class Meta: + table='room_owner' + + +class Parcours(Model): + id = fields.IntField(pk=True) + id_code = fields.CharField(max_length=30, unique=True) + + name = fields.CharField(max_length=255) + created_at = fields.DateField(auto_now_add=True) + + room = fields.ForeignKeyField('models.Room') + + timer = fields.IntField(default=10) + + exercices = fields.JSONField(default=list) + + success_condition = fields.IntField(default = 10) + + +class Challenger(Model): + parcours = fields.ForeignKeyField('models.Parcours') + anonymous = fields.OneToOneField('models.AnonymousMember', null=True) + user = fields.OneToOneField("models.UserModel", null=True) + + challenges = fields.JSONField(default=list) \ No newline at end of file diff --git a/backend/api_old/db.sqlite3 b/backend/api_old/db.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..c1c736229ea1f17a7014daa901c6587b845106c6 GIT binary patch literal 28672 zcmeI&J#W)M7{GD6NgNd^4NDZd9cG}G3PVxFN(gR<8l2Wm5D`N#joVtvOJ%zhCRF%< zd;>lP3k!F)^QJZuBSim`U(SBck8{8Ca&}IRTS4rLvq?DeV)0D7uj#t>LI_RMirFf6 zNipO$pVie)U#PFD7PY7E_e(NrluWIBxb=JMLFq>+mIo9B5I_I{1Q0*~0R#|0;GYvX zGK}iuiXH{yf&W>~l}7!^_1F*l!9X1p>#kkv*rMsYvfl}FDQ=3klb$lgjyb)`w0Z@j z`lOmo0nVw_spOfsWmc~jskuFIX!mx7 znceDb&8@$#xjTFN`_`Yb0bUs@9EV zvgt*CL6+uSsd3>&7yh7s=|$1?B$OktiY2^GcL*E+?zof4Z?#X`X len(exo_exemple): + longueur_max = len(consigne) + 5 + elif len(consigne) > MAX_LENGTH[police] and len(consigne) > len(exo_exemple): + longueur_max = MAX_LENGTH[police] + elif len(consigne) > MAX_LENGTH[police] and len(consigne) < len(exo_exemple): + longueur_max = len(exo_exemple) + elif len(consigne) < MAX_LENGTH[police] and len(consigne) < len(exo_exemple): + longueur_max = len(exo_exemple) + else: + longueur_max = len(exo_exemple) + + consigne_lines = [] + if len(consigne) > 30: + cons = consigne.replace(',', ' ').split(' ') + text_longueur = '' + for i in cons: + text_longueur = text_longueur + i + ' ' + if len(text_longueur) > longueur_max: + consigne_lines.append(text_longueur) + text_longueur = '' + # print(text_longueur) + else: + consigne_lines.append(consigne) + serie_page_vertical = int(PAGE_LINES[police] / + (nb_in_serie + 1 + len(consigne_lines))) + + rest_line = PAGE_LINES[police] - (serie_page_vertical * nb_in_serie + + serie_page_vertical * len(consigne_lines) + serie_page_vertical) + + max_length = len(exo_exemple) if len( + exo_exemple) > longueur_max else longueur_max + max_in_line = 2 * MAX_LENGTH[police] + space = max_in_line / 8 + + nb_in_line = int(max_in_line / (max_length + space)) + 1 + + for p in range(nb_page): + for c in range(serie_page_vertical): + + for w in consigne_lines: + writer.writerow([*[w, ""] * nb_in_line]) + + for k in range(nb_in_serie): + calcul_list = list( + map(lambda calc: calc['calcul'], Generateur(path, nb_in_line, 'csv'))) + n = 1 + for i in range(n, len(calcul_list) + n + 1, n+1): + calcul_list.insert(i, '') + writer.writerow(calcul_list) + writer.writerow(['']) + + for r in range(rest_line): + writer.writerow(['']) diff --git a/backend/api_old/generateur/generateur_main.py b/backend/api_old/generateur/generateur_main.py new file mode 100644 index 0000000..37014da --- /dev/null +++ b/backend/api_old/generateur/generateur_main.py @@ -0,0 +1,49 @@ +import re +import importlib.util + + +def getObjectKey(obj, key): + if obj[key] == None: + return None + return key if obj[key] != False else 'calcul' if obj['calcul'] != False else None + + +def getCorrectionKey(obj, key): + return key if (obj[key] != False and obj['correction'] == False) else 'calcul' if(obj['calcul'] != False and obj['correction'] == False) else 'correction' if obj['correction'] != False else None + + +def parseCorrection(calc, replacer='...'): + exp_list = re.findall(r"\[([A-Za-z0-9_]+)\]", calc) + for exp in exp_list: + calc = calc.replace(f'[{exp}]', replacer) + return calc + + +def Generateur(path, quantity, key, forcedCorrection=False): + spec = importlib.util.spec_from_file_location( + "tmp", path) + tmp = importlib.util.module_from_spec(spec) + spec.loader.exec_module(tmp) + try: + main_func = tmp.main + except: + return None + main_result = main_func() + default_object = {"calcul": False, 'pdf': False, 'csv': False, + 'web': False, 'correction': False} # les valeurs par défaut + # Si l'utilisateur n'a pas entré une valeur, elle est définie à False + print(main_result) + result_object = {**default_object, **main_result} + object_key = getObjectKey(result_object, key) + correction_key = getCorrectionKey(result_object, key) + op_list = [] + try: + replacer = tmp.CORRECTION_REPLACER + except: + replacer = '...' + for i in range(quantity): + main_result = main_func() + main = {**default_object, **main_result} + op_list.append({'calcul': parseCorrection(main[ + object_key], replacer) if (forcedCorrection or (key != 'web' and main['correction'] == False)) else main[object_key], 'correction': main[correction_key]}) + return op_list diff --git a/backend/api_old/index.html b/backend/api_old/index.html new file mode 100644 index 0000000..45d6204 --- /dev/null +++ b/backend/api_old/index.html @@ -0,0 +1,135 @@ + + + + Chat + + +

WebSocket Room

+ +
+

Connection

+
+ + + +
+
+ +
+

Room

+ +
+

Create or Join

+ +
+ + + + +
+
+
+ +

Members

+ + +

Waiters

+ + + + + diff --git a/backend/api_old/main.py b/backend/api_old/main.py new file mode 100644 index 0000000..ee614ed --- /dev/null +++ b/backend/api_old/main.py @@ -0,0 +1,127 @@ +from fastapi_pagination import add_pagination +from fastapi.responses import PlainTextResponse +from fastapi.exceptions import RequestValidationError, ValidationError +from datetime import timedelta +from fastapi import FastAPI, HTTPException, Depends, Request, status +from fastapi_jwt_auth import AuthJWT +from fastapi_jwt_auth.exceptions import AuthJWTException +from fastapi.responses import JSONResponse +from typing import List +from tortoise.contrib.pydantic import pydantic_model_creator +from fastapi import FastAPI, HTTPException +from tortoise import Tortoise +from database.exercices.models import Exercice +from fastapi.middleware.cors import CORSMiddleware +from tortoise.contrib.fastapi import register_tortoise +from pydantic import BaseModel +import apis.base +import config +from redis import Redis +from fastapi.encoders import jsonable_encoder + +app = FastAPI(title="Tortoise ORM FastAPI example") +origins = [ + "http://localhost:8000", + "https://localhost:8001", + "http://localhost", + "http://localhost:8080", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=['*'], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.exception_handler(RequestValidationError) +@app.exception_handler(ValidationError) +async def validation_exception_handler(request, exc: RequestValidationError): + errors = {} + for e in exc.errors(): + errors[e['loc'][-1] + "_error"] = e['msg'] + + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=jsonable_encoder({"detail": errors}), + ) + + +class Settings(BaseModel): + authjwt_secret_key: str = config.SECRET_KEY + authjwt_denylist_enabled: bool = True + authjwt_denylist_token_checks: set = {"access", "refresh"} + access_expires: int = timedelta(minutes=15 ) + refresh_expires: int = timedelta(days=30) + +# callback to get your configuration + +settings = Settings() +@AuthJWT.load_config +def get_config(): + return settings + + +# exception handler for authjwt +# in production, you can tweak performance using orjson response +@app.exception_handler(AuthJWTException) +def authjwt_exception_handler(request: Request, exc: AuthJWTException): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.message} + ) + + +redis_conn = Redis(host='localhost', port=6379, db=0, decode_responses=True) + + +@AuthJWT.token_in_denylist_loader +def check_if_token_in_denylist(decrypted_token): + jti = decrypted_token['jti'] + entry = redis_conn.get(jti) + return entry and entry == 'true' + +app.include_router(apis.base.api_router) + + +@app.delete('/access-revoke') +def access_revoke(Authorize: AuthJWT = Depends()): + Authorize.jwt_required() + + # Store the tokens in redis with the value true for revoked. + # We can also set an expires time on these tokens in redis, + # so they will get automatically removed after they expired. + jti = Authorize.get_raw_jwt()['jti'] + redis_conn.setex(jti, settings.access_expires, 'true') + return {"detail": "Access token has been revoke"} + + +@app.delete('/refresh-revoke') +def refresh_revoke(Authorize: AuthJWT = Depends()): + Authorize.jwt_refresh_token_required() + + jti = Authorize.get_raw_jwt()['jti'] + redis_conn.setex(jti, settings.refresh_expires, 'true') + return {"detail": "Refresh token has been revoke"} +add_pagination(app) + +TORTOISE_ORM = { + "connections": {"default": "sqlite://database/db.sqlite3"}, + "apps": { + "models": { + "models": ["database.exercices.models", 'database.auth.models', "database.room.models","aerich.models"], + "default_connection": "default", + }, + }, +} + + +register_tortoise( + app, + config=TORTOISE_ORM, + #db_url="sqlite://database/db.sqlite3", + modules={"models": ["database.exercices.models", 'database.auth.models']}, + generate_schemas=True, + add_exception_handlers=True, +) diff --git a/backend/api_old/pyproject.toml b/backend/api_old/pyproject.toml new file mode 100644 index 0000000..e961eef --- /dev/null +++ b/backend/api_old/pyproject.toml @@ -0,0 +1,4 @@ +[tool.aerich] +tortoise_orm = "main.TORTOISE_ORM" +location = "./migrations" +src_folder = "./." diff --git a/backend/api_old/schema/user.py b/backend/api_old/schema/user.py new file mode 100644 index 0000000..68f204b --- /dev/null +++ b/backend/api_old/schema/user.py @@ -0,0 +1,47 @@ +from typing import Optional +from pydantic import BaseModel, validator +from services.password import validate_password + +from database.decorators import as_form + + +@as_form +class UserForm(BaseModel): + username: str + firstname: Optional[str] + name: Optional[str] + email: Optional[str] + +@as_form +class User(BaseModel): + username: str + password: str + + +@as_form +class UserRegister(User): + password_confirm: str + + @validator('username') + def username_alphanumeric(cls, v): + assert v.isalnum(), 'must be alphanumeric' + return v + + @validator('password') + def password_validation(cls, v): + is_valid = validate_password(v) + if is_valid != True: + raise ValueError(is_valid) + return v + + @validator('password_confirm') + def password_match(cls, v, values): + if 'password' in values and v != values['password']: + raise ValueError('Les mots de passe ne correspondent pas') + return v + + +@as_form +class PasswordSet(BaseModel): + password: str + password_confirm: str diff --git a/backend/api_old/services/auth.py b/backend/api_old/services/auth.py new file mode 100644 index 0000000..78c237a --- /dev/null +++ b/backend/api_old/services/auth.py @@ -0,0 +1,138 @@ +from uuid import UUID +from database.decorators import as_form +from services.password import get_password_hash, validate_password +from fastapi_jwt_auth import AuthJWT +from datetime import datetime, timedelta +from jose import jwt, JWTError +from fastapi import Depends, HTTPException, Request, status +from config import SECRET_KEY, ALGORITHM, ExerciceIn_schema, User_schema +from database.auth.crud import get_user_db +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from pydantic import BaseModel +from fastapi.exceptions import RequestValidationError + +from database.auth.models import UserModel +from .jwt import create_access_token +from passlib.context import CryptContext +from .password import verify_password +from database.exercices.models import Exercice, Tag +from fastapi.security.utils import get_authorization_scheme_param +from schema.user import User, UserRegister, PasswordSet +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login", auto_error=False) +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class TokenData(BaseModel): + clientId: str | None = None + + + +async def authenticate_user(user: User = Depends(User.as_form)): + user_db = await get_user_db(user.username) + if not user_db: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"username_error":"Utilisateur introuvable"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + if not verify_password(user.password, user_db.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"password_error": "Mot de passe invalide"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + return user_db + + +async def get_user(username): + user = await get_user_db(username) + if user: + return await User_schema.from_tortoise_orm(user) + + +async def get_user_from_token(token: str): + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + clientId: str = payload.get("sub") + if clientId is None: + return None + user = await UserModel.get_or_none(clientId=clientId) + return user + + + +''' async def check_tag_owner(exo_id: str, user: User_schema = Depends(get_current_active_user), ): + tag = await Tag.get(id_code=exo_id) + if tag.owner_id != user.id: + raise HTTPException(status_code=401, detail="Non autorisé") + return user ''' + + +def jwt_required(Authorize: AuthJWT = Depends()): + Authorize.jwt_required() + return Authorize + + +def jwt_optional(Authorize: AuthJWT = Depends()): + Authorize.jwt_optional() + return Authorize + + +def jwt_refresh_required(Authorize: AuthJWT = Depends()): + Authorize.jwt_refresh_token_required() + return Authorize + + +def fresh_jwt_required(Authorize: AuthJWT = Depends()): + Authorize.fresh_jwt_required() + return Authorize + + + + +def get_current_clientId(Authorize: AuthJWT = Depends(jwt_required)): + return Authorize.get_jwt_subject() + +async def get_current_user(clientId: str = Depends(get_current_clientId)): + user = await UserModel.get_or_none(clientId=clientId) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='User not found') + return user + +async def get_current_user_optional(Authorize: AuthJWT = Depends(jwt_optional)): + clientId = Authorize.get_jwt_subject() + if clientId: + return await UserModel.get_or_none(clientId=clientId) + return None + +async def check_unique_user(username: str): + user = await UserModel.get_or_none(username=username) + if user is not None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"username_error": "Déjà pris "}) + return username + + +def validate_passwords(passwords: PasswordSet = Depends(PasswordSet.as_form)): + if passwords.password != passwords.password_confirm: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail='Les mots de passe ne correspondent pas !') + is_valid = validate_password(passwords.password) + if is_valid is not True: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Invalid password : {is_valid}') + return passwords.password + + +async def validate_register_user(user: UserRegister = Depends(UserRegister.as_form)): + username = await check_unique_user(user.username) + password_set = PasswordSet( + password=user.password, password_confirm=user.password_confirm) + validate_passwords(password_set) + return user + + +async def check_author_exo(id_code: str, user: UserModel = Depends(get_current_user)): + exo = await Exercice.get(id_code=id_code) + if exo.author_id != user.id: + raise HTTPException(status_code=401, detail="Non autorisé") + return user diff --git a/backend/api_old/services/io.py b/backend/api_old/services/io.py new file mode 100644 index 0000000..4610973 --- /dev/null +++ b/backend/api_old/services/io.py @@ -0,0 +1,56 @@ + + +import os +import typing +import uuid + +TEXTCHARS = bytearray({7, 8, 9, 10, 12, 13, 27} | + set(range(0x20, 0x100)) - {0x7f}) +def delete_root_slash(path: str) -> str: + if path.startswith('/'): + path = path[1:] + return path + + +def get_abs_path_from_relative_to_root(path): + return os.path.join(os.environ.get('FASTAPI_ROOT_URL'), delete_root_slash(path)) + + +def remove_fastapi_root(path): + return path.replace(os.environ.get('FASTAPI_ROOT_URL'), "") + +def is_binary_file(file_path: str): + with open(file_path, 'rb') as f: + content = f.read(1024) + return bool(content.translate(None, TEXTCHARS)) +def get_or_create_dir(path: str) -> str: + if not os.path.exists(path): + os.mkdir(path) + return path + + +def get_filename(file: typing.IO, default: str = uuid.uuid4()) -> str: + if hasattr(file, 'name'): + return file.name + elif hasattr(file, 'filename'): + return file.filename + else: + return f"{default.id_code}.py" + +def remove_if_exists(path): + if os.path.exists(path) and os.path.isfile(path): + os.remove(path) + + +def get_parent_dir(path): + return os.path.abspath(os.path.join(path, os.pardir)) + + +def get_ancestor(path:str, levels: int = 1): + for i in range(levels): + path = get_parent_dir(path) + return path + + +def get_filename_from_path(path): + return os.path.split(path)[-1] diff --git a/backend/api_old/services/jwt.py b/backend/api_old/services/jwt.py new file mode 100644 index 0000000..2bf94cd --- /dev/null +++ b/backend/api_old/services/jwt.py @@ -0,0 +1,14 @@ +from datetime import datetime, timedelta +from jose import jwt, JWTError +from config import SECRET_KEY, ALGORITHM + + +def create_access_token(data: dict, expires_delta: timedelta | None = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt diff --git a/backend/api_old/services/password.py b/backend/api_old/services/password.py new file mode 100644 index 0000000..ab48b6d --- /dev/null +++ b/backend/api_old/services/password.py @@ -0,0 +1,24 @@ +import re +from passlib.context import CryptContext + + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password): + return pwd_context.hash(password) + + +def validate_password(password): + if len(password) < 8: + return "Le mot de passe est trop court (8 caractères minimum)" + elif re.search('[0-9]', password) is None: + return 'Le mot de passe doit contenir au moins un chiffre' + elif re.search('[A-Z]', password) is None: + return "Le mot de passe doit contenir au moins une majuscule" + return True + diff --git a/backend/api_old/services/timeout.py b/backend/api_old/services/timeout.py new file mode 100644 index 0000000..5a3c3ec --- /dev/null +++ b/backend/api_old/services/timeout.py @@ -0,0 +1,21 @@ +from contextlib import contextmanager +import signal +def raise_timeout(signum, frame): + raise TimeoutError + +@contextmanager +def timeout(time:int, exception = TimeoutError): + # Register a function to raise a TimeoutError on the signal. + signal.signal(signal.SIGALRM, raise_timeout) + # Schedule the signal to be sent after ``time``. + signal.alarm(time) + + try: + yield + except TimeoutError: + print('TIMED OUT') + raise exception + finally: + # Unregister the signal so it won't be triggered + # if the timeout is not reached. + signal.signal(signal.SIGALRM, signal.SIG_IGN) diff --git a/backend/api_old/tests/__init__.py b/backend/api_old/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api_old/tests/conftest.py b/backend/api_old/tests/conftest.py new file mode 100644 index 0000000..0747cd1 --- /dev/null +++ b/backend/api_old/tests/conftest.py @@ -0,0 +1,49 @@ +from typing import Any +from typing import Generator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from apis.base import api_router +from tortoise.contrib.fastapi import register_tortoise + + +TORTOISE_ORM = { + "connections": {"default": "sqlite://database/db.sqlite3"}, + "apps": { + "models": { + "models": ["database.models", "aerich.models"], + "default_connection": "default", + }, + }, +} + +def start_application(): + app = FastAPI() + app.include_router(api_router) + + return app + + +@pytest.fixture(scope="function") +def app() -> Generator[FastAPI, Any, None]: + _app = start_application() + register_tortoise( + app, + config=TORTOISE_ORM, + #db_url="sqlite://database/db.sqlite3", + modules={"models": ["database.models"]}, + generate_schemas=True, + add_exception_handlers=True, + ) + yield _app + + + + +@pytest.fixture(scope="module") +def client( + app: FastAPI +) -> Generator[TestClient, Any, None]: + with TestClient(app) as client: + yield client diff --git a/backend/api_old/tests/test_exercices.py b/backend/api_old/tests/test_exercices.py new file mode 100644 index 0000000..f88bd39 --- /dev/null +++ b/backend/api_old/tests/test_exercices.py @@ -0,0 +1,105 @@ + +from tortoise.contrib.fastapi import register_tortoise +import datetime +from fastapi.testclient import TestClient +import sys, os + +import requests +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from faker import Faker +from main import app + +fake = Faker() + +client = TestClient(app) + +fake_exercices = [] + +token = '' + +def test_register(): + global token + fail_response = requests.post('http://localhost:8001/register', data={ + 'username': "", 'password': 't', "password_confirm": "t"}) + + assert fail_response.status_code == 422 + assert fail_response.json()['detail'][0]['msg'] == 'field required' + + fail_response = requests.post('http://localhost:8001/register', data={ + 'username': "test", 'password': 'tt', "password_confirm": "t"}) + assert fail_response.status_code == 400 + assert fail_response.json()['detail'] == 'Les mots de passe ne correspondent pas !' + + fail_response = requests.post('http://localhost:8001/register', data={ + 'username': "test", 'password': 'tt', "password_confirm": "tt"}) + assert fail_response.status_code == 400 + assert fail_response.json( + )['detail'] == 'Invalid password : Password too short' + + fail_response = requests.post('http://localhost:8001/register', data={ + 'username': "test", 'password': 'testtest', "password_confirm": "testtest"}) + assert fail_response.status_code == 400 + assert fail_response.json( + )['detail'] == 'Invalid password : Password must have a figure' + fail_response = requests.post('http://localhost:8001/register', data={ + 'username': "test", 'password': 'testtest1', "password_confirm": "testtest1"}) + assert fail_response.status_code == 400 + assert fail_response.json( + )['detail'] == 'Invalid password : Password must have capital letter' + + success_response = requests.post('http://localhost:8001/register', data={'username': "test", 'password': 'Testtest1', "password_confirm": "Testtest1"}) + print(success_response.json()) + assert success_response.status_code == 200 + assert 'access_token' in success_response.json() + assert success_response.json()['token_type'] == 'bearer' + + fail_response = requests.post('http://localhost:8001/register', data={ + 'username': "test", 'password': 'Testtest1', "password_confirm": "Testtest1"}) + assert fail_response.status_code == 422 + assert 'UNIQUE constraint failed' in fail_response.json()['detail'][0]['msg'] + token = success_response.json()['access_token'] + +def test_login(): + global token + r = requests.post('http://localhost:8001/login', data = {"username": "teste", 'password': 'Testtest1'}) + assert r.status_code == 401 + assert r.json()['detail'] == 'Incorrect username or password' + r = requests.post('http://localhost:8001/login', data = {"username": "test", 'password': 'Testtest'}) + assert r.status_code == 401 + assert r.json()['detail'] == 'Incorrect username or password' + + r = requests.post('http://localhost:8001/login', data = {"username": "test", 'password': 'Testtest1'}) + assert r.status_code == 200 + assert 'access_token' in r.json() + assert r.json()['token_type'] == 'bearer' + token = r.json()['access_token'] + +def test_delete_user(): + r = requests.delete('http://localhost:8001/user', headers={'Authorization': f"Bearer {token}"}) + print(r.json()) + assert r.status_code == 200 + + +def test_create_exo(): + print('TOKEN', token) + headers = {'Authorization': f'Bearer ${token}'} + response = requests.post('http://localhost:8001/exercices/', params= {"name": "test", "consigne": "test", 'private': False}, files = {'file': ('1test_model.py', open("/home/lilian/1test_model.py", 'rb'), "text/x-python")}, headers=headers) + data = response.json() + fake_exercices.append(data) + assert 'id_code' in data + assert 'updated_at' in data + id_code = data.pop('id_code') + data.pop('updated_at') + assert response.status_code == 200 + assert data == {'name': 'test', 'consigne': 'test', 'private': False, + 'tags': [], 'origin': None, 'isOriginal': True, 'pdfSupport': True, 'csvSupport': False, 'webSupport': True} + +def delete_exo(): + response = requests.delete('http://localhost:8001/exercices/DATEMY') + assert response.status_code == 200 + +def test_ws(): + pass + + + diff --git a/backend/api_old/uploads/ERSIWQ/1test_model.py b/backend/api_old/uploads/ERSIWQ/1test_model.py new file mode 100644 index 0000000..391ed2d --- /dev/null +++ b/backend/api_old/uploads/ERSIWQ/1test_model.py @@ -0,0 +1,10 @@ +import random + +""" +Fonction main() qui doit renvoyer un objet avec: + calcul: le calcul a afficher + result: la correction du calcul (pas de correction -> mettre None) +""" + +def main(): + return {"csv": "None", 'pdf': "", "calcul": "1+1=2"} \ No newline at end of file diff --git a/backend/api_old/uploads/JZJGJR/1test_model.py b/backend/api_old/uploads/JZJGJR/1test_model.py new file mode 100644 index 0000000..17253ea --- /dev/null +++ b/backend/api_old/uploads/JZJGJR/1test_model.py @@ -0,0 +1,10 @@ +import random + +""" +Fonction main() qui doit renvoyer un objet avec: + calcul: le calcul a afficher + result: la correction du calcul (pas de correction -> mettre None) +""" + +def main(): + return {"csv": "1+1", 'pdf': "", "calcul": "1+1=2"} \ No newline at end of file diff --git a/backend/api_old/uploads/NAZABB/1test_model.py b/backend/api_old/uploads/NAZABB/1test_model.py new file mode 100644 index 0000000..7f9a584 --- /dev/null +++ b/backend/api_old/uploads/NAZABB/1test_model.py @@ -0,0 +1,10 @@ +import random + +""" +Fonction main() qui doit renvoyer un objet avec: + calcul: le calcul a afficher + result: la correction du calcul (pas de correction -> mettre None) +""" + +def main(): + return {"csv": None, 'pdf': "", "calcul": "1+1=2"} \ No newline at end of file diff --git a/backend/api_old/uploads/SSJCKT/1test_model.py b/backend/api_old/uploads/SSJCKT/1test_model.py new file mode 100644 index 0000000..391ed2d --- /dev/null +++ b/backend/api_old/uploads/SSJCKT/1test_model.py @@ -0,0 +1,10 @@ +import random + +""" +Fonction main() qui doit renvoyer un objet avec: + calcul: le calcul a afficher + result: la correction du calcul (pas de correction -> mettre None) +""" + +def main(): + return {"csv": "None", 'pdf': "", "calcul": "1+1=2"} \ No newline at end of file diff --git a/backend/api_old/uploads/TVLLES/1test_model.py b/backend/api_old/uploads/TVLLES/1test_model.py new file mode 100644 index 0000000..391ed2d --- /dev/null +++ b/backend/api_old/uploads/TVLLES/1test_model.py @@ -0,0 +1,10 @@ +import random + +""" +Fonction main() qui doit renvoyer un objet avec: + calcul: le calcul a afficher + result: la correction du calcul (pas de correction -> mettre None) +""" + +def main(): + return {"csv": "None", 'pdf': "", "calcul": "1+1=2"} \ No newline at end of file diff --git a/backend/api_old/uploads/UAPHYF/1test_model.py b/backend/api_old/uploads/UAPHYF/1test_model.py new file mode 100644 index 0000000..8e9fca2 --- /dev/null +++ b/backend/api_old/uploads/UAPHYF/1test_model.py @@ -0,0 +1,10 @@ +import random + +""" +Fonction main() qui doit renvoyer un objet avec: + calcul: le calcul a afficher + result: la correction du calcul (pas de correction -> mettre None) +""" + +def main(): + return {"csv": None, 'web': None, "calcul": "1+1=2"} \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..434f7bb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,34 @@ +## Usage + +Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`. + +This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template. + +```bash +$ npm install # or pnpm install or yarn install +``` + +### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) + +## Available Scripts + +In the project directory, you can run: + +### `npm dev` or `npm start` + +Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+ +### `npm run build` + +Builds the app for production to the `dist` folder.
+It correctly bundles Solid in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +## Deployment + +You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4868666 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + + +
+ + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f0fcfe7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "vite-template-solid", + "version": "0.0.0", + "description": "", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview" + }, + "license": "MIT", + "devDependencies": { + "sass": "^1.54.3", + "typescript": "^4.7.4", + "vite": "^3.0.0", + "vite-plugin-solid": "^2.3.0" + }, + "dependencies": { + "@solidjs/meta": "^0.28.0", + "@solidjs/router": "^0.4.2", + "axios": "^0.27.2", + "chroma-js": "^2.4.2", + "emotion-solid": "^1.1.1", + "jwt-decode": "^3.1.2", + "solid-forms": "^0.4.5", + "solid-icons": "^1.0.1", + "solid-js": "^1.4.7", + "solid-styled-components": "^0.28.4", + "solid-styled-jsx": "^0.27.1", + "solid-toast": "^0.3.4", + "styled-jsx": "^3.4.4" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..83fb7b4 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1336 @@ +lockfileVersion: 5.4 + +specifiers: + '@solidjs/meta': ^0.28.0 + '@solidjs/router': ^0.4.2 + axios: ^0.27.2 + chroma-js: ^2.4.2 + emotion-solid: ^1.1.1 + jwt-decode: ^3.1.2 + sass: ^1.54.3 + solid-forms: ^0.4.5 + solid-icons: ^1.0.1 + solid-js: ^1.4.7 + solid-styled-components: ^0.28.4 + solid-styled-jsx: ^0.27.1 + solid-toast: ^0.3.4 + styled-jsx: ^3.4.4 + typescript: ^4.7.4 + vite: ^3.0.0 + vite-plugin-solid: ^2.3.0 + +dependencies: + '@solidjs/meta': 0.28.0_solid-js@1.4.7 + '@solidjs/router': 0.4.2_solid-js@1.4.7 + axios: 0.27.2 + chroma-js: 2.4.2 + emotion-solid: 1.1.1_solid-js@1.4.7 + jwt-decode: 3.1.2 + solid-forms: 0.4.5_solid-js@1.4.7 + solid-icons: 1.0.1_solid-js@1.4.7 + solid-js: 1.4.7 + solid-styled-components: 0.28.4_solid-js@1.4.7 + solid-styled-jsx: 0.27.1_6vmtj257rx6jo5swpvcwaya7pe + solid-toast: 0.3.4_solid-js@1.4.7 + styled-jsx: 3.4.7 + +devDependencies: + sass: 1.54.3 + typescript: 4.7.4 + vite: 3.0.0_sass@1.54.3 + vite-plugin-solid: 2.3.0_solid-js@1.4.7+vite@3.0.0 + +packages: + + /@ampproject/remapping/2.2.0: + resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.1.1 + '@jridgewell/trace-mapping': 0.3.14 + dev: true + + /@babel/code-frame/7.18.6: + resolution: {integrity: sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.18.6 + dev: true + + /@babel/compat-data/7.18.8: + resolution: {integrity: sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/core/7.18.6: + resolution: {integrity: sha512-cQbWBpxcbbs/IUredIPkHiAGULLV8iwgNRMFzvbhEXISp4f3rUUXE5+TIw6KwUWUR3DwyI6gmBRnmAtYaWehwQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.2.0 + '@babel/code-frame': 7.18.6 + '@babel/generator': 7.18.7 + '@babel/helper-compilation-targets': 7.18.6_@babel+core@7.18.6 + '@babel/helper-module-transforms': 7.18.8 + '@babel/helpers': 7.18.6 + '@babel/parser': 7.18.8 + '@babel/template': 7.18.6 + '@babel/traverse': 7.18.8 + '@babel/types': 7.18.8 + convert-source-map: 1.8.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.1 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/generator/7.18.7: + resolution: {integrity: sha512-shck+7VLlY72a2w9c3zYWuE1pwOKEiQHV7GTUbSnhyl5eu3i04t30tBY82ZRWrDfo3gkakCFtevExnxbkf2a3A==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.8 + '@jridgewell/gen-mapping': 0.3.2 + jsesc: 2.5.2 + dev: true + + /@babel/helper-annotate-as-pure/7.18.6: + resolution: {integrity: sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.8 + dev: true + + /@babel/helper-compilation-targets/7.18.6_@babel+core@7.18.6: + resolution: {integrity: sha512-vFjbfhNCzqdeAtZflUFrG5YIFqGTqsctrtkZ1D/NB0mDW9TwW3GmmUepYY4G9wCET5rY5ugz4OGTcLd614IzQg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/compat-data': 7.18.8 + '@babel/core': 7.18.6 + '@babel/helper-validator-option': 7.18.6 + browserslist: 4.21.2 + semver: 6.3.0 + dev: true + + /@babel/helper-create-class-features-plugin/7.18.6_@babel+core@7.18.6: + resolution: {integrity: sha512-YfDzdnoxHGV8CzqHGyCbFvXg5QESPFkXlHtvdCkesLjjVMT2Adxe4FGUR5ChIb3DxSaXO12iIOCWoXdsUVwnqw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-annotate-as-pure': 7.18.6 + '@babel/helper-environment-visitor': 7.18.6 + '@babel/helper-function-name': 7.18.6 + '@babel/helper-member-expression-to-functions': 7.18.6 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/helper-replace-supers': 7.18.6 + '@babel/helper-split-export-declaration': 7.18.6 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-environment-visitor/7.18.6: + resolution: {integrity: sha512-8n6gSfn2baOY+qlp+VSzsosjCVGFqWKmDF0cCWOybh52Dw3SEyoWR1KrhMJASjLwIEkkAufZ0xvr+SxLHSpy2Q==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-function-name/7.18.6: + resolution: {integrity: sha512-0mWMxV1aC97dhjCah5U5Ua7668r5ZmSC2DLfH2EZnf9c3/dHZKiFa5pRLMH5tjSl471tY6496ZWk/kjNONBxhw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.18.6 + '@babel/types': 7.18.8 + dev: true + + /@babel/helper-hoist-variables/7.18.6: + resolution: {integrity: sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.8 + dev: true + + /@babel/helper-member-expression-to-functions/7.18.6: + resolution: {integrity: sha512-CeHxqwwipekotzPDUuJOfIMtcIHBuc7WAzLmTYWctVigqS5RktNMQ5bEwQSuGewzYnCtTWa3BARXeiLxDTv+Ng==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.8 + dev: true + + /@babel/helper-module-imports/7.16.0: + resolution: {integrity: sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.8 + dev: true + + /@babel/helper-module-imports/7.18.6: + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.8 + dev: true + + /@babel/helper-module-transforms/7.18.8: + resolution: {integrity: sha512-che3jvZwIcZxrwh63VfnFTUzcAM9v/lznYkkRxIBGMPt1SudOKHAEec0SIRCfiuIzTcF7VGj/CaTT6gY4eWxvA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.18.6 + '@babel/helper-module-imports': 7.18.6 + '@babel/helper-simple-access': 7.18.6 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/helper-validator-identifier': 7.18.6 + '@babel/template': 7.18.6 + '@babel/traverse': 7.18.8 + '@babel/types': 7.18.8 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-optimise-call-expression/7.18.6: + resolution: {integrity: sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.8 + dev: true + + /@babel/helper-plugin-utils/7.18.6: + resolution: {integrity: sha512-gvZnm1YAAxh13eJdkb9EWHBnF3eAub3XTLCZEehHT2kWxiKVRL64+ae5Y6Ivne0mVHmMYKT+xWgZO+gQhuLUBg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-replace-supers/7.18.6: + resolution: {integrity: sha512-fTf7zoXnUGl9gF25fXCWE26t7Tvtyn6H4hkLSYhATwJvw2uYxd3aoXplMSe0g9XbwK7bmxNes7+FGO0rB/xC0g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.18.6 + '@babel/helper-member-expression-to-functions': 7.18.6 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/traverse': 7.18.8 + '@babel/types': 7.18.8 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-simple-access/7.18.6: + resolution: {integrity: sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.8 + dev: true + + /@babel/helper-split-export-declaration/7.18.6: + resolution: {integrity: sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.18.8 + dev: true + + /@babel/helper-validator-identifier/7.18.6: + resolution: {integrity: sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-option/7.18.6: + resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helpers/7.18.6: + resolution: {integrity: sha512-vzSiiqbQOghPngUYt/zWGvK3LAsPhz55vc9XNN0xAl2gV4ieShI2OQli5duxWHD+72PZPTKAcfcZDE1Cwc5zsQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.18.6 + '@babel/traverse': 7.18.8 + '@babel/types': 7.18.8 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/highlight/7.18.6: + resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.18.6 + chalk: 2.4.2 + js-tokens: 4.0.0 + dev: true + + /@babel/parser/7.18.8: + resolution: {integrity: sha512-RSKRfYX20dyH+elbJK2uqAkVyucL+xXzhqlMD5/ZXx+dAAwpyB7HsvnHe/ZUGOF+xLr5Wx9/JoXVTj6BQE2/oA==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.18.8 + dev: true + + /@babel/plugin-syntax-jsx/7.18.6_@babel+core@7.18.6: + resolution: {integrity: sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.18.6 + dev: true + + /@babel/plugin-syntax-typescript/7.18.6_@babel+core@7.18.6: + resolution: {integrity: sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.18.6 + dev: true + + /@babel/plugin-transform-typescript/7.18.8_@babel+core@7.18.6: + resolution: {integrity: sha512-p2xM8HI83UObjsZGofMV/EdYjamsDm6MoN3hXPYIT0+gxIoopE+B7rPYKAxfrz9K9PK7JafTTjqYC6qipLExYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-create-class-features-plugin': 7.18.6_@babel+core@7.18.6 + '@babel/helper-plugin-utils': 7.18.6 + '@babel/plugin-syntax-typescript': 7.18.6_@babel+core@7.18.6 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/preset-typescript/7.18.6_@babel+core@7.18.6: + resolution: {integrity: sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.18.6 + '@babel/helper-validator-option': 7.18.6 + '@babel/plugin-transform-typescript': 7.18.8_@babel+core@7.18.6 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/template/7.18.6: + resolution: {integrity: sha512-JoDWzPe+wgBsTTgdnIma3iHNFC7YVJoPssVBDjiHfNlyt4YcunDtcDOUmfVDfCK5MfdsaIoX9PkijPhjH3nYUw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.18.6 + '@babel/parser': 7.18.8 + '@babel/types': 7.18.8 + dev: true + + /@babel/traverse/7.18.8: + resolution: {integrity: sha512-UNg/AcSySJYR/+mIcJQDCv00T+AqRO7j/ZEJLzpaYtgM48rMg5MnkJgyNqkzo88+p4tfRvZJCEiwwfG6h4jkRg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.18.6 + '@babel/generator': 7.18.7 + '@babel/helper-environment-visitor': 7.18.6 + '@babel/helper-function-name': 7.18.6 + '@babel/helper-hoist-variables': 7.18.6 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/parser': 7.18.8 + '@babel/types': 7.18.8 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/types/7.18.8: + resolution: {integrity: sha512-qwpdsmraq0aJ3osLJRApsc2ouSJCdnMeZwB0DhbtHAtRpZNZCdlbRnHIgcRKzdE1g0iOGg644fzjOBcdOz9cPw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.18.6 + to-fast-properties: 2.0.0 + dev: true + + /@babel/types/7.8.3: + resolution: {integrity: sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==} + dependencies: + esutils: 2.0.3 + lodash: 4.17.21 + to-fast-properties: 2.0.0 + dev: false + + /@emotion/cache/11.10.3: + resolution: {integrity: sha512-Psmp/7ovAa8appWh3g51goxu/z3iVms7JXOreq136D8Bbn6dYraPnmL6mdM8GThEx9vwSn92Fz+mGSjBzN8UPQ==} + dependencies: + '@emotion/memoize': 0.8.0 + '@emotion/sheet': 1.2.0 + '@emotion/utils': 1.2.0 + '@emotion/weak-memoize': 0.3.0 + stylis: 4.0.13 + dev: false + + /@emotion/hash/0.9.0: + resolution: {integrity: sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==} + dev: false + + /@emotion/is-prop-valid/1.2.0: + resolution: {integrity: sha512-3aDpDprjM0AwaxGE09bOPkNxHpBd+kA6jty3RnaEXdweX1DF1U3VQpPYb0g1IStAuK7SVQ1cy+bNBBKp4W3Fjg==} + dependencies: + '@emotion/memoize': 0.8.0 + dev: false + + /@emotion/memoize/0.8.0: + resolution: {integrity: sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA==} + dev: false + + /@emotion/serialize/1.1.0: + resolution: {integrity: sha512-F1ZZZW51T/fx+wKbVlwsfchr5q97iW8brAnXmsskz4d0hVB4O3M/SiA3SaeH06x02lSNzkkQv+n3AX3kCXKSFA==} + dependencies: + '@emotion/hash': 0.9.0 + '@emotion/memoize': 0.8.0 + '@emotion/unitless': 0.8.0 + '@emotion/utils': 1.2.0 + csstype: 3.1.0 + dev: false + + /@emotion/sheet/1.2.0: + resolution: {integrity: sha512-OiTkRgpxescko+M51tZsMq7Puu/KP55wMT8BgpcXVG2hqXc0Vo0mfymJ/Uj24Hp0i083ji/o0aLddh08UEjq8w==} + dev: false + + /@emotion/unitless/0.8.0: + resolution: {integrity: sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==} + dev: false + + /@emotion/utils/1.2.0: + resolution: {integrity: sha512-sn3WH53Kzpw8oQ5mgMmIzzyAaH2ZqFEbozVVBSYp538E06OSE6ytOp7pRAjNQR+Q/orwqdQYJSe2m3hCOeznkw==} + dev: false + + /@emotion/weak-memoize/0.3.0: + resolution: {integrity: sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==} + dev: false + + /@jridgewell/gen-mapping/0.1.1: + resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + + /@jridgewell/gen-mapping/0.3.2: + resolution: {integrity: sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + '@jridgewell/trace-mapping': 0.3.14 + dev: true + + /@jridgewell/resolve-uri/3.1.0: + resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/set-array/1.1.2: + resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/sourcemap-codec/1.4.14: + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + dev: true + + /@jridgewell/trace-mapping/0.3.14: + resolution: {integrity: sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + + /@solidjs/meta/0.28.0_solid-js@1.4.7: + resolution: {integrity: sha512-x52VdB9RZJ1apDB/pAmf58oeJsJ0lGKFXWjnm/TE/MlENIBPg3JaW8H5v2HnGpQC0WUbgiIsDFWpLWVNBo5t6g==} + peerDependencies: + solid-js: '>=1.4.0' + dependencies: + solid-js: 1.4.7 + dev: false + + /@solidjs/router/0.4.2_solid-js@1.4.7: + resolution: {integrity: sha512-RswymVhqnGVHMCo/01X9wE+u98pzYZQ/b23SgjO/PK+8oTgdY1f83OrsWR1A01LieTySkCSw7nunWTi+VOQyWA==} + peerDependencies: + solid-js: ^1.3.5 + dependencies: + solid-js: 1.4.7 + dev: false + + /ansi-styles/3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + dev: true + + /anymatch/3.1.2: + resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /asynckit/0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + dev: false + + /axios/0.27.2: + resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} + dependencies: + follow-redirects: 1.15.1 + form-data: 4.0.0 + transitivePeerDependencies: + - debug + dev: false + + /babel-plugin-jsx-dom-expressions/0.33.12_@babel+core@7.18.6: + resolution: {integrity: sha512-FQeNcBvC+PrPYGpeUztI7AiiAqJL2H8e7mL4L6qHZ7B4wZfbgyREsHZwKmmDqxAehlyAUolTdhDNk9xfyHdIZw==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-module-imports': 7.16.0 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.6 + '@babel/types': 7.18.8 + html-entities: 2.3.2 + dev: true + + /babel-plugin-syntax-jsx/6.18.0: + resolution: {integrity: sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==} + dev: false + + /babel-plugin-transform-rename-import/2.3.0: + resolution: {integrity: sha512-dPgJoT57XC0PqSnLgl2FwNvxFrWlspatX2dkk7yjKQj5HHGw071vAcOf+hqW8ClqcBDMvEbm6mevn5yHAD8mlQ==} + dev: false + + /babel-preset-solid/1.4.6_@babel+core@7.18.6: + resolution: {integrity: sha512-5n+nm1zgj7BK9cv0kYu0p+kbsXgGbrxLmA5bv5WT0V5WnqRgshWILInPWLJNZbvP5gBj+huDKwk3J4RhhbFlhA==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.18.6 + babel-plugin-jsx-dom-expressions: 0.33.12_@babel+core@7.18.6 + dev: true + + /big.js/5.2.2: + resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + dev: false + + /binary-extensions/2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /braces/3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /browserslist/4.21.2: + resolution: {integrity: sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001366 + electron-to-chromium: 1.4.189 + node-releases: 2.0.6 + update-browserslist-db: 1.0.4_browserslist@4.21.2 + dev: true + + /caniuse-lite/1.0.30001366: + resolution: {integrity: sha512-yy7XLWCubDobokgzudpkKux8e0UOOnLHE6mlNJBzT3lZJz6s5atSEzjoL+fsCPkI0G8MP5uVdDx1ur/fXEWkZA==} + dev: true + + /chalk/2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + dev: true + + /chokidar/3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /chroma-js/2.4.2: + resolution: {integrity: sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==} + dev: false + + /color-convert/1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + dev: true + + /color-name/1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + dev: true + + /combined-stream/1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + dev: false + + /convert-source-map/1.7.0: + resolution: {integrity: sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==} + dependencies: + safe-buffer: 5.1.2 + dev: false + + /convert-source-map/1.8.0: + resolution: {integrity: sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==} + dependencies: + safe-buffer: 5.1.2 + dev: true + + /csstype/3.1.0: + resolution: {integrity: sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==} + dev: false + + /debug/4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /delayed-stream/1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dev: false + + /electron-to-chromium/1.4.189: + resolution: {integrity: sha512-dQ6Zn4ll2NofGtxPXaDfY2laIa6NyCQdqXYHdwH90GJQW0LpJJib0ZU/ERtbb0XkBEmUD2eJtagbOie3pdMiPg==} + dev: true + + /emojis-list/2.1.0: + resolution: {integrity: sha512-knHEZMgs8BB+MInokmNTg/OyPlAddghe1YBgNwJBc5zsJi/uyIcXoSDsL/W9ymOsBoBGdPIHXYJ9+qKFwRwDng==} + engines: {node: '>= 0.10'} + dev: false + + /emotion-solid/1.1.1_solid-js@1.4.7: + resolution: {integrity: sha512-FSc2y9MZBqEZ+RyN6fWzfIpP40/D37dFOTEWb6O9eKcmTxmqgTClJ4A+wdmV4zuZSxxnmE4vup9+iHo7wTiPXg==} + peerDependencies: + solid-js: ^1.0.0 + dependencies: + '@emotion/cache': 11.10.3 + '@emotion/is-prop-valid': 1.2.0 + '@emotion/serialize': 1.1.0 + '@emotion/utils': 1.2.0 + solid-js: 1.4.7 + dev: false + + /esbuild-android-64/0.14.49: + resolution: {integrity: sha512-vYsdOTD+yi+kquhBiFWl3tyxnj2qZJsl4tAqwhT90ktUdnyTizgle7TjNx6Ar1bN7wcwWqZ9QInfdk2WVagSww==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-android-arm64/0.14.49: + resolution: {integrity: sha512-g2HGr/hjOXCgSsvQZ1nK4nW/ei8JUx04Li74qub9qWrStlysaVmadRyTVuW32FGIpLQyc5sUjjZopj49eGGM2g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-64/0.14.49: + resolution: {integrity: sha512-3rvqnBCtX9ywso5fCHixt2GBCUsogNp9DjGmvbBohh31Ces34BVzFltMSxJpacNki96+WIcX5s/vum+ckXiLYg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-arm64/0.14.49: + resolution: {integrity: sha512-XMaqDxO846srnGlUSJnwbijV29MTKUATmOLyQSfswbK/2X5Uv28M9tTLUJcKKxzoo9lnkYPsx2o8EJcTYwCs/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-64/0.14.49: + resolution: {integrity: sha512-NJ5Q6AjV879mOHFri+5lZLTp5XsO2hQ+KSJYLbfY9DgCu8s6/Zl2prWXVANYTeCDLlrIlNNYw8y34xqyLDKOmQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-arm64/0.14.49: + resolution: {integrity: sha512-lFLtgXnAc3eXYqj5koPlBZvEbBSOSUbWO3gyY/0+4lBdRqELyz4bAuamHvmvHW5swJYL7kngzIZw6kdu25KGOA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-32/0.14.49: + resolution: {integrity: sha512-zTTH4gr2Kb8u4QcOpTDVn7Z8q7QEIvFl/+vHrI3cF6XOJS7iEI1FWslTo3uofB2+mn6sIJEQD9PrNZKoAAMDiA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-64/0.14.49: + resolution: {integrity: sha512-hYmzRIDzFfLrB5c1SknkxzM8LdEUOusp6M2TnuQZJLRtxTgyPnZZVtyMeCLki0wKgYPXkFsAVhi8vzo2mBNeTg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm/0.14.49: + resolution: {integrity: sha512-iE3e+ZVv1Qz1Sy0gifIsarJMQ89Rpm9mtLSRtG3AH0FPgAzQ5Z5oU6vYzhc/3gSPi2UxdCOfRhw2onXuFw/0lg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm64/0.14.49: + resolution: {integrity: sha512-KLQ+WpeuY+7bxukxLz5VgkAAVQxUv67Ft4DmHIPIW+2w3ObBPQhqNoeQUHxopoW/aiOn3m99NSmSV+bs4BSsdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-mips64le/0.14.49: + resolution: {integrity: sha512-n+rGODfm8RSum5pFIqFQVQpYBw+AztL8s6o9kfx7tjfK0yIGF6tm5HlG6aRjodiiKkH2xAiIM+U4xtQVZYU4rA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-ppc64le/0.14.49: + resolution: {integrity: sha512-WP9zR4HX6iCBmMFH+XHHng2LmdoIeUmBpL4aL2TR8ruzXyT4dWrJ5BSbT8iNo6THN8lod6GOmYDLq/dgZLalGw==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-riscv64/0.14.49: + resolution: {integrity: sha512-h66ORBz+Dg+1KgLvzTVQEA1LX4XBd1SK0Fgbhhw4akpG/YkN8pS6OzYI/7SGENiN6ao5hETRDSkVcvU9NRtkMQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-s390x/0.14.49: + resolution: {integrity: sha512-DhrUoFVWD+XmKO1y7e4kNCqQHPs6twz6VV6Uezl/XHYGzM60rBewBF5jlZjG0nCk5W/Xy6y1xWeopkrhFFM0sQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-netbsd-64/0.14.49: + resolution: {integrity: sha512-BXaUwFOfCy2T+hABtiPUIpWjAeWK9P8O41gR4Pg73hpzoygVGnj0nI3YK4SJhe52ELgtdgWP/ckIkbn2XaTxjQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-openbsd-64/0.14.49: + resolution: {integrity: sha512-lP06UQeLDGmVPw9Rg437Btu6J9/BmyhdoefnQ4gDEJTtJvKtQaUcOQrhjTq455ouZN4EHFH1h28WOJVANK41kA==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-sunos-64/0.14.49: + resolution: {integrity: sha512-4c8Zowp+V3zIWje329BeLbGh6XI9c/rqARNaj5yPHdC61pHI9UNdDxT3rePPJeWcEZVKjkiAS6AP6kiITp7FSw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-32/0.14.49: + resolution: {integrity: sha512-q7Rb+J9yHTeKr9QTPDYkqfkEj8/kcKz9lOabDuvEXpXuIcosWCJgo5Z7h/L4r7rbtTH4a8U2FGKb6s1eeOHmJA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-64/0.14.49: + resolution: {integrity: sha512-+Cme7Ongv0UIUTniPqfTX6mJ8Deo7VXw9xN0yJEN1lQMHDppTNmKwAM3oGbD/Vqff+07K2gN0WfNkMohmG+dVw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-arm64/0.14.49: + resolution: {integrity: sha512-v+HYNAXzuANrCbbLFJ5nmO3m5y2PGZWLe3uloAkLt87aXiO2mZr3BTmacZdjwNkNEHuH3bNtN8cak+mzVjVPfA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild/0.14.49: + resolution: {integrity: sha512-/TlVHhOaq7Yz8N1OJrjqM3Auzo5wjvHFLk+T8pIue+fhnhIMpfAzsG6PLVMbFveVxqD2WOp3QHei+52IMUNmCw==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + esbuild-android-64: 0.14.49 + esbuild-android-arm64: 0.14.49 + esbuild-darwin-64: 0.14.49 + esbuild-darwin-arm64: 0.14.49 + esbuild-freebsd-64: 0.14.49 + esbuild-freebsd-arm64: 0.14.49 + esbuild-linux-32: 0.14.49 + esbuild-linux-64: 0.14.49 + esbuild-linux-arm: 0.14.49 + esbuild-linux-arm64: 0.14.49 + esbuild-linux-mips64le: 0.14.49 + esbuild-linux-ppc64le: 0.14.49 + esbuild-linux-riscv64: 0.14.49 + esbuild-linux-s390x: 0.14.49 + esbuild-netbsd-64: 0.14.49 + esbuild-openbsd-64: 0.14.49 + esbuild-sunos-64: 0.14.49 + esbuild-windows-32: 0.14.49 + esbuild-windows-64: 0.14.49 + esbuild-windows-arm64: 0.14.49 + dev: true + + /escalade/3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + dev: true + + /escape-string-regexp/1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + dev: true + + /esutils/2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: false + + /fast-deep-equal/3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: false + + /fill-range/7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /follow-redirects/1.15.1: + resolution: {integrity: sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + + /form-data/4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind/1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /gensync/1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + dev: true + + /glob-parent/5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /globals/11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + dev: true + + /goober/2.1.11_csstype@3.1.0: + resolution: {integrity: sha512-5SS2lmxbhqH0u9ABEWq7WPU69a4i2pYcHeCxqaNq6Cw3mnrF0ghWNM4tEGid4dKy8XNIAUbuThuozDHHKJVh3A==} + peerDependencies: + csstype: ^3.0.10 + dependencies: + csstype: 3.1.0 + dev: false + + /has-flag/3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + dev: true + + /has/1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /html-entities/2.3.2: + resolution: {integrity: sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==} + dev: true + + /immutable/4.1.0: + resolution: {integrity: sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==} + dev: true + + /is-binary-path/2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-core-module/2.9.0: + resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==} + dependencies: + has: 1.0.3 + dev: true + + /is-extglob/2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-glob/4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number/7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-what/4.1.7: + resolution: {integrity: sha512-DBVOQNiPKnGMxRMLIYSwERAS5MVY1B7xYiGnpgctsOFvVDz9f9PFXXxMcTOHuoqYp4NK9qFYQaIC1NRRxLMpBQ==} + engines: {node: '>=12.13'} + dev: true + + /js-tokens/4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: true + + /jsesc/2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /json5/1.0.1: + resolution: {integrity: sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==} + hasBin: true + dependencies: + minimist: 1.2.6 + dev: false + + /json5/2.2.1: + resolution: {integrity: sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==} + engines: {node: '>=6'} + hasBin: true + dev: true + + /jwt-decode/3.1.2: + resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==} + dev: false + + /loader-utils/1.2.3: + resolution: {integrity: sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==} + engines: {node: '>=4.0.0'} + dependencies: + big.js: 5.2.2 + emojis-list: 2.1.0 + json5: 1.0.1 + dev: false + + /lodash/4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + dev: false + + /merge-anything/5.0.2: + resolution: {integrity: sha512-POPQBWkBC0vxdgzRJ2Mkj4+2NTKbvkHo93ih+jGDhNMLzIw+rYKjO7949hOQM2X7DxMHH1uoUkwWFLIzImw7gA==} + engines: {node: '>=12.13'} + dependencies: + is-what: 4.1.7 + ts-toolbelt: 9.6.0 + dev: true + + /mime-db/1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types/2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + + /minimist/1.2.6: + resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + dev: false + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /nanoid/3.3.4: + resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /node-releases/2.0.6: + resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} + dev: true + + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /path-parse/1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /picocolors/1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch/2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /postcss/8.4.14: + resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.4 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /readdirp/3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /resolve/1.22.1: + resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} + hasBin: true + dependencies: + is-core-module: 2.9.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /rollup/2.76.0: + resolution: {integrity: sha512-9jwRIEY1jOzKLj3nsY/yot41r19ITdQrhs+q3ggNWhr9TQgduHqANvPpS32RNpzGklJu3G1AJfvlZLi/6wFgWA==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /safe-buffer/5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + /sass/1.54.3: + resolution: {integrity: sha512-fLodey5Qd41Pxp/Tk7Al97sViYwF/TazRc5t6E65O7JOk4XF8pzwIW7CvCxYVOfJFFI/1x5+elDyBIixrp+zrw==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + immutable: 4.1.0 + source-map-js: 1.0.2 + dev: true + + /semver/6.3.0: + resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} + hasBin: true + dev: true + + /solid-forms/0.4.5_solid-js@1.4.7: + resolution: {integrity: sha512-Beufywwygb85v9VChmbyb3GRKz1s6PstVPce7WG/4D4mrWkg/nv0seuoOa8Wr5ghMlitaG6ZOHccsuCcWXJ9Sg==} + peerDependencies: + solid-js: ^1.4.0 + dependencies: + fast-deep-equal: 3.1.3 + solid-js: 1.4.7 + dev: false + + /solid-icons/1.0.1_solid-js@1.4.7: + resolution: {integrity: sha512-9rxPeJ1UDGzWGlksjuXuyK2CdL1vg89inDyOl43koL3zoMgXLzY6LVLuMeuhslHbjq7Fp2h6fSSImj9ygfvleA==} + engines: {node: '>= 16'} + peerDependencies: + solid-js: '*' + dependencies: + solid-js: 1.4.7 + dev: false + + /solid-js/1.4.7: + resolution: {integrity: sha512-u3hoe5w3xseAc/8zLwYaQVGanWXknMMQkzryNz7lOPy2ygW6DhCtfMseun4kLflRNRzrUUpTV3W5p7j2SGcHCQ==} + + /solid-refresh/0.4.1_solid-js@1.4.7: + resolution: {integrity: sha512-v3tD/OXQcUyXLrWjPW1dXZyeWwP7/+GQNs8YTL09GBq+5FguA6IejJWUvJDrLIA4M0ho9/5zK2e9n+uy+4488g==} + peerDependencies: + solid-js: ^1.3 + dependencies: + '@babel/generator': 7.18.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/types': 7.18.8 + solid-js: 1.4.7 + dev: true + + /solid-styled-components/0.28.4_solid-js@1.4.7: + resolution: {integrity: sha512-SGbXv5tLIs1qErr3x7M+HWE4lu+37C4myV8gbce7WnZumjBmM5sifKv/NulVeSf3nMRa3uwkAM14q7QmLGC2gQ==} + peerDependencies: + solid-js: ^1.4.4 + dependencies: + csstype: 3.1.0 + goober: 2.1.11_csstype@3.1.0 + solid-js: 1.4.7 + dev: false + + /solid-styled-jsx/0.27.1_6vmtj257rx6jo5swpvcwaya7pe: + resolution: {integrity: sha512-s/BidA5ii4cGuWbk4cB3qajXkZuBfqaoQPhw0Exzbc8Y3fqy2g0qshmJyop0iuSZgJ8FpJ9UeYVLhRF0GZbVpA==} + peerDependencies: + solid-js: ^1.0.0 + styled-jsx: ^3.4.4 + dependencies: + babel-plugin-transform-rename-import: 2.3.0 + solid-js: 1.4.7 + styled-jsx: 3.4.7 + dev: false + + /solid-toast/0.3.4_solid-js@1.4.7: + resolution: {integrity: sha512-f4eXJPTlcPPNYW8hgN6NslvA8tqGF07TLX2smX0lDbVbsY7vrbwFIM2rUNbhPpFz5gzVOit0iDfFSYpXUZfJeg==} + peerDependencies: + solid-js: ^1.4.2 + dependencies: + solid-js: 1.4.7 + dev: false + + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + + /source-map/0.7.3: + resolution: {integrity: sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==} + engines: {node: '>= 8'} + dev: false + + /string-hash/1.1.3: + resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} + dev: false + + /styled-jsx/3.4.7: + resolution: {integrity: sha512-PkImcCsovR39byv4Tz83tAPsYs2CiTPOmDSplhe0lsIFVYJyd7rzJ7fbm41vSNsF/lnO+Ob5n/jgMookwY0pww==} + peerDependencies: + react: 15.x.x || 16.x.x || 17.x.x + dependencies: + '@babel/types': 7.8.3 + babel-plugin-syntax-jsx: 6.18.0 + convert-source-map: 1.7.0 + loader-utils: 1.2.3 + source-map: 0.7.3 + string-hash: 1.1.3 + stylis: 3.5.4 + stylis-rule-sheet: 0.0.10_stylis@3.5.4 + dev: false + + /stylis-rule-sheet/0.0.10_stylis@3.5.4: + resolution: {integrity: sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw==} + peerDependencies: + stylis: ^3.5.0 + dependencies: + stylis: 3.5.4 + dev: false + + /stylis/3.5.4: + resolution: {integrity: sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q==} + dev: false + + /stylis/4.0.13: + resolution: {integrity: sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==} + dev: false + + /supports-color/5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + dev: true + + /supports-preserve-symlinks-flag/1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /to-fast-properties/2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + /to-regex-range/5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /ts-toolbelt/9.6.0: + resolution: {integrity: sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==} + dev: true + + /typescript/4.7.4: + resolution: {integrity: sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /update-browserslist-db/1.0.4_browserslist@4.21.2: + resolution: {integrity: sha512-jnmO2BEGUjsMOe/Fg9u0oczOe/ppIDZPebzccl1yDWGLFP16Pa1/RM5wEoKYPG2zstNcDuAStejyxsOuKINdGA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.21.2 + escalade: 3.1.1 + picocolors: 1.0.0 + dev: true + + /vite-plugin-solid/2.3.0_solid-js@1.4.7+vite@3.0.0: + resolution: {integrity: sha512-N2sa54C3UZC2nN5vpj5o6YP+XdIAZW6n6xv8OasxNAcAJPFeZT7EOVvumL0V4c8hBz1yuYniMWdESY8807fVSg==} + peerDependencies: + solid-js: ^1.3.17 + vite: ^3.0.0 + dependencies: + '@babel/core': 7.18.6 + '@babel/preset-typescript': 7.18.6_@babel+core@7.18.6 + babel-preset-solid: 1.4.6_@babel+core@7.18.6 + merge-anything: 5.0.2 + solid-js: 1.4.7 + solid-refresh: 0.4.1_solid-js@1.4.7 + vite: 3.0.0_sass@1.54.3 + transitivePeerDependencies: + - supports-color + dev: true + + /vite/3.0.0_sass@1.54.3: + resolution: {integrity: sha512-M7phQhY3+fRZa0H+1WzI6N+/onruwPTBTMvaj7TzgZ0v2TE+N2sdLKxJOfOv9CckDWt5C4HmyQP81xB4dwRKzA==} + engines: {node: '>=14.18.0'} + hasBin: true + peerDependencies: + less: '*' + sass: '*' + stylus: '*' + terser: ^5.4.0 + peerDependenciesMeta: + less: + optional: true + sass: + optional: true + stylus: + optional: true + terser: + optional: true + dependencies: + esbuild: 0.14.49 + postcss: 8.4.14 + resolve: 1.22.1 + rollup: 2.76.0 + sass: 1.54.3 + optionalDependencies: + fsevents: 2.3.2 + dev: true diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css new file mode 100644 index 0000000..48308b2 --- /dev/null +++ b/frontend/src/App.module.css @@ -0,0 +1,33 @@ +.App { + text-align: center; +} + +.logo { + animation: logo-spin infinite 20s linear; + height: 40vmin; + pointer-events: none; +} + +.header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.link { + color: #b318f0; +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..9023b19 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,65 @@ +import { Component, createEffect, createSignal } from "solid-js"; + +import logo from "./logo.svg"; +import styles from "./App.module.css"; +import { MetaProvider } from "@solidjs/meta"; +import Layout from "./components/Layout"; +import { Route, Routes } from "@solidjs/router"; +import Test from "./components/test"; +import Home from "./components/Home"; +import Routing from "./components/Routing"; +import { AuthProvider } from "./context/auth.context.jsx"; +import { LoginPopUpProvider } from "./context/loginPopUp.context.jsx"; +import LoginPopup from "./components/LoginPopup"; +import { Toaster } from "solid-toast"; +import { NotificationProvider } from "./context/notification.context.jsx"; +import { NavigateProvider } from "./context/navigate.context.jsx"; +const App: Component = () => { + const [count, setCount] = createSignal(0); + createEffect(() => { + setInterval(() => setCount((c) => c + 1), 1000); + }); + const [popup, setPopup] = createSignal({ active: false, next: () => {} }); + return ( + + + + void) => { + setPopup({ active: true, next: next }); + }} + active={popup().active} + next={() => { + popup().next(); + setPopup({ active: false, next: () => {} }); + }} + > + + + + { + setPopup({ active: false, next: () => {} }); + }} + /> + + + + {" "} + + + + ); +}; + +type countModel = { + count: number; +}; + +const Counter: Component = (props: countModel) => { + var c = props.count; + return

{props.count}

; +}; + +export default App; diff --git a/frontend/src/apis/auth.instance.js b/frontend/src/apis/auth.instance.js new file mode 100644 index 0000000..476738d --- /dev/null +++ b/frontend/src/apis/auth.instance.js @@ -0,0 +1,12 @@ +import axios from "axios"; +import { csrftoken, isBrowser } from "../utils/utils.js"; + +export const authInstance = axios.create({ + baseURL: `http://localhost:8002/`, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "Access-Control-Allow-Origin": "*", + "X-CSRFToken": csrftoken != undefined ? csrftoken : "", + }, +}); diff --git a/frontend/src/apis/exoInstance.instance.js b/frontend/src/apis/exoInstance.instance.js new file mode 100644 index 0000000..1ae73c4 --- /dev/null +++ b/frontend/src/apis/exoInstance.instance.js @@ -0,0 +1,27 @@ +import axios from "axios"; +import { csrftoken, isBrowser } from "../utils/utils.js"; + + +export const exoInstance = axios.create({ + paramsSerializer: (params)=> { + const searchParams = new URLSearchParams(); + for (const key of Object.keys(params)) { + const param = params[key]; + if (Array.isArray(param)) { + for (const p of param) { + searchParams.append(key, p); + } + } else { + searchParams.append(key, param); + } + } + return searchParams.toString(); + }, + baseURL: `http://localhost:8002/`, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "Access-Control-Allow-Origin": "*", + "X-CSRFToken": csrftoken != undefined ? csrftoken : "", + }, +}); diff --git a/frontend/src/assets/favicon.ico b/frontend/src/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b836b2bccac650e0e7d90514083add91d2c027ff GIT binary patch literal 15086 zcmeI32Y6Lgw#RQo0Y@EtmA&@a z>%VX~JRB7qO`13ut2?&Tb~rp84oCIs=KCidj&pqWB%^#k=3$2;oFCNVH(cTvg2?Ck zua+_V>;M1%EHI;OjS4~SYCbiyeXWK_$5|a}^+(18cdR{rcBk6ILps&z9{OsXW^?@N zKE>$q&tL)YJ|5J*W^?dPL^gr^&;nW zso#2j*ZLKIX+474)p`W1FCqa*I(V-kh2T{d-Vlwqj>6Uoyeh17Jq2bpw@W<*ZZ)rK zeKo&ZgYOr-)?mfL?hT*&C3FDe6G#^%3dz;>)tKujjTg*<7aMELcjK+EBDytDU`84$ zaI4534b`I8Ux-`UtI^M<12}(;^hFY9cdY$)ZDX;o5fe@u3tlH?u`eQcBQ)LyPP~yl zUJ%S`F|wp*BekSgBek+ml_F zEBZ86EBiJP?CMW{Y^t`5XpxlqPCIt7^8XRL&my5nuB^NJiU}RZcgKWfW3lgYC*H;y z@2Y-a?$=bU2KNVViZoN(N4LDS_bs2{hx4lZ7+D;|B-4-ACs`$5jmh746Q^6e_c8~Jzjt+UELmaWVXW}bbc8_nP zwxZ8E<`;Y3Z%kNtEgM^SHw^bu88bM;zBTwFl;P%MKN*`fwI zd{q=j##+FaI*!CPS_ga}I;5aO+Kg8U zgS;0VGG})&eyepTHIBYvJ-f9Z$eKZi@#t{dilaD}IP%r&OxByMy%f_AVn?xO2DtZ! zbWsQ9bkkS`vtU0m|8=d$!SL7gciIP9hhlN0*Mc*SF0!6$xU-H;{J)7F<(+n4aU?nr zKN3f2#B&BZNNfl`tpj*19qc}k@9v5t=UV8XV|3rtcAw+#-@1$6)S(MLIEN1k>^Zf} zIAVTTlM+J`9}*{m^AI{n3|T%XJ&t58*z(c4?LT0?a-B7!M|f3;9_d{Z9}J2a(`sYv zxYlQhqucT93sz2*d|g%?i4F(QL1NzWfu+OUam1Rj=cB-m*T{3e<*|M7Yw*Ft_@FBI z2ggtGJu7>pi0n0@gU(0A;z-uQ1H@4QF*zUnj}*sJ-uFo}{U1-9;Wrogdm?d?NWM!X z2km1YkbOsTx6ViAS|}-w#OBU8vgIT3ku4wnNG@1G991bV*5cPC2l-V@3i6wW+)fVi zSIO8mMTC5=M{wF^AUa#-yD*CBBE#Bkp(?JJ+h$JyrT=u! z``;b}J2)Q!?`FX)c+-LnyiUxrmx}+9{gM-r17V1~4(+D*FrACguP7g}CrCbuU{8>J z0Rc18OC4F*OMzQ~Tja*k$TyCj{BX$3?AZ67Dvus%v%6G9&brvy#H^nM?3l5w*ij4n zmgF3rk*BLuI3`KT*qb!^caT0ULUSADs5q&gox_0EMofdS>gPh7l&d|zZ^!_Hz~ zZRfHuo3=eHcDCU)Y>b@^8#5YsowyhGQJ=3Ktp0P`WOZTBbYvP5m~|m8@I`lZz{XAY z&*^&285iI^V6*YT;&v_*vokK#QPal97WGlb!278wCsqznXQIZd?6_&_;@%nRLj3!7 z9oA;Y23B%M2WY#;Wr%;7*u{1Zn&i z1%JHL2YK0Xfj!*8pE29t0~<%W#B!N7wy!^1Tv#?X@oL;l!M$pLI<;Y(#(gOG!DFp1Z)unyl-)G`4*#~##d&zO&WklyD?WT~3};gkS3*_)GTz__OeVTWk4B+T7n}$Ir|-v-vIc z*`Edzv;CZTPUCmR2Wt^~|8-eEbz((-b@IcZ#Pv8Zzo#w;=G3_=CvBd}VYK6S(+Bu1 zU>)Mt`8X#ftVd2t_>D_RVJbU5Sbe)QP<_4SZFMSY5-~hMo!U5&n3$}-**;aB+s(S$ z8v@38>Pq@TbtMByUjX*`PW;@yN( z*?R)Zb*2#=xQ7Qm2-iL!9>l&^#pZ(lP>~M|``dJ|`ykCNANgUFxD2e;S@j^Kl#;C~=qpg4YcPu?4vlREFFtjW@SAoefj16j*@9hd5Z zo2-LO_Jd*U2jx1umxQfA#7c@>*%<-V_j|0lP1 zas2Y0IUggJPsX0-@Ihh8>%jCurY#p4J}Asdi})M*r?Qp;utj6=yH#r|36r~@F{cOk z>s-lP6p(vhNd|fJDr@;B;~kw_4=gRjH@C^9dFXfvUmYcHt>K!{Iq4Bi!T*@MbN*bv z2j>&uZUFucjD5gAh`nF{pLYj;EAZF4k{MZr{p_Ki3&X$e`^#ED=|6|#?lHZBQhZ)0 z-yzo>j#)CMI~+bT0#n8U#%hdwt;a0JD!fm(jg`#z6&U4vYxFR$vqrgTKHtkI_qC6} z(C<|+{Y-o>c=VWM+)r@HJTmIf`JH1H?`8Bc$7<$CWl4W-jqHkYokPYfMwlx-vdhan zG*B6%d@lgyIvM4@0#xxI07WkuMSsNv+FmNluwyB9mOicOh%e&o(|VFO|A+qqU#adP zy*>2D)S%a-CE`!-MpybEeCR2tO;5q2Kec+(eQS6GwXgLwb9O@(G3S1IMlLdM9`pXl zytkP5A7TFWF2k?ygd%&D@3bUxb~D zc-^ghjxB2>AtZ<@@miJz$Sv&*8{vmtG&U z&&Q=-#@TyfarX3^z2Jul_c)Yb zQ^GI*un}2}w_bbZ=TZVZ4ZtGCb^bK&uAJP}GoPHYJza;%Y zyms2OFFIPi6VmG;Js!x%W}k<_H5**M@U(HlD+=HGmQOqw$~=?7&-aKO)9MCXjEaUI z-3s>g^e|Ywym0f<4`bKSKA-eDSe*7Iu8o6N6)xG}-F@@HFt98e>>lY|8Gd@R@L9r5 z3lFrHd+dQriG~;20`DvL7jE6+{|!#s;{3hTrco`_jtQ;RKIYZjukeL%ktXMSlzTo? zCWcYoE#RbbW5*R?kZUatClMYe9*%hzyt(klTTL!`2Y6$IyM-T-8oh9A!g*@mfop_A z!4G%fg~paO|IT4v55en22)7!?{iRPrxFq5I1%vRZX`07_7lj89E?9U0iwBlEp45J& zrmlGcxMSfji~P`bJ-U^7zJ=ed35L_sYZ6C)hV%$X?~3NKu&dlp_!G^A%G}In@o!SM zms+UQ&NYV)ALHVOciMeamj2dvaD#pE!(p)gp!*ceI2JofKZn7K8(gO5V&N1l3^E?% zy?n0yAiSUOKLH(XW5Y6I(3)RHVzCO`WJ`Fxu=vTo*CZA-KaU@De}U--&Eui3@Q|Xv z#k-W?hXgRVwI3wU>RdT7$S(|DE=bO#cjQ zKqt9-fACVHkROFhE8=*FZHaFi4<&ZCczstt7<>t}*~9P(CeiN%$mL2gdwxM(`R(biLiISugBeNG&q=%QYjmXU7KAycYs^ zm3m07^jTQ^l?$h$xe0^+xvTbU<9w|8t<><(Y=V1;4G_L$CG|bJ`Ah$U6RCsb=s9hi zh{ZP= zc>(*JMs3~F(OILW#(SLluAX-{^IA1?C+DI$qr`jFIFBvv+XepV2jQqRpH_lh?Q=?9 zS-7dwQ4>^F?E6ODTWa@Guhcbfi*wL6kUH!5vTVRL6~Ec`_S!H0Jd&FAo0>~<*0ZIK zz0_96-Y{OB-4%$gA!hB=wf0_$4Zg+(FO-ELCpEk&HN}e;5<=AZ*qQ3vUDK(T2Qt?* z{r*BiD0OD)rPQrNq;BV|pC`{T_zR1>uyIH5Mg`PrlbOF!S@X+x)IoyfzPhF?wamjS zjd~`vO{q;ujm@P7uItA|^>2$mvDx4Pwbp6Oe_u5;>Q;Y1rxT(db(o@>wytAa^HT?= zc0}E!EF1hc^~R5=mo~l_KfTO-u((aAOAo>ZH$_+KCng5;cd4aY^+WN2)X*&(gnow& z)=~3pjSb4xw}A~RbI&kraN|C0kdMtyf;*gRU!v|;u6gZtK%Yv?-<3Gnh7Gc)Y3ET# z%Etyb`RqEjIZqs<@|y^3)djpybB$Yew$E4EYv!rQyw#~?c{6sTj@6DhY|iIZslolK zwCny^KR4<#{6DLTQdAlWsB&CKvM4nK8!{`MiU;DuXCl=oT~y2GH6;m=St67xsvwn2 zslaC}eShOjdx~>c5dQ|^acd6y$hq{XP`^5pI4_>$ ztXLpt@A=*8s|ED>%fJ1Qa}(#KTbzp~*sqh<%X&6?h5M&*#w(C>y7Ym|*;W3%g`8RC zY_*y*&PM~9scoZMo=TkJ>(XCHA9?>xLp}4>(o--0F2FjA$(cxcs^vT+J;_@+tL)`G zkTBUdY{yVfXZ**FZuu+DSexaUb`Q_AF_Vm*R5{y-NPlV^&-(h@z_T9Dn-_WhcgfE@ zn|t$Yo-5Dg(zC03nxwyybB8?t$n&>656ZJ4eOm>b9eUgCuxq$i1)goY^IW@~XWD$} zv6JUx>CLpBVddFXdU$!(|C;aX+OL<_nR8$BpzkUud4}KZ6wU&vW{;}OE6-md+@la# z#WUD*XEu&;+CZ+S-{yJb8+o2HdOxh*8l$Ji>YZVK%|E?i>=*3QF8$hE^DKQLr>tia zqkqMC&XfIFIQ;)uGm<^>U9-nyS~UBdOV8P7s|HO!zG6V3)njpdxxq)Dj+)4`63-5@ zXG*^UeG2TA`8+SV#M7mu&}Z4}k7cv}id;-&zfUFa%l;$$HG8kzgZ<_XeHusD>$-~W z_CNCDo8+v2Xnqi`meW?dF!{OjbMF<{P`UD{3UW|Gq{d_=bk0*6(lD|z7KWYY7R#*)(qxJUL+mK WLaxim%Fn{v*G = (props) => { + var initial = true + const [condition, setCondition] = createSignal(props.when) + createEffect(() => { + console.log('chaneg') + var delay = !!props.when ? props.mountDelay : props.unMountDelay; + if (!initial) { + console.log('change') + setTimeout(() => { + setCondition(props.when) + }, delay * 1000) + } + initial = false + }) + return {props.children} +} \ No newline at end of file diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx new file mode 100644 index 0000000..8499ffa --- /dev/null +++ b/frontend/src/components/Home.tsx @@ -0,0 +1,60 @@ +import { Component, createSignal } from "solid-js"; +import toast, { Toast } from "solid-toast"; +import Layout from "./Layout"; +import { useNotification } from "../context/notification.context.jsx"; +import { Select } from "./forms/select"; +import { useNavigate, useParams, useSearchParams } from "@solidjs/router"; +const Home: Component = () => { + const notification = useNotification(); + const [params, setParams] = useSearchParams() + let input; + const [count, setCount] = createSignal(""); + const [options, setOptions] = createSignal([ + { + label: "test1", + value: "1", + color: "#ff0000", + }, + { + label: "test2", + value: "2", + color: "#00ff00", + }, + { + label: "test3", + value: "3", + + color: "#ff0000", + }, + { + label: "test4", + value: "4", + + color: "#0099ff", + }, + { + label: "test5", + value: "5", + color: "#0099ff", + }, + ]); + const [selectedOptions, setSelectedOptions] = createSignal([ + { + label: "test1", + value: "1", + color: "#ff0000", + }, + ]); + const navigate = useNavigate() + return ( + + + + ); +}; + +export default Home; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..e0197b3 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,21 @@ +import { Title } from "@solidjs/meta"; +import type { Signal, Accessor, Setter, JSX } from "solid-js"; +import { Component, createEffect, createSignal } from "solid-js"; +import NavBar from "./NavBar"; + +type pageModel = { + page: string; + children: JSX.Element +}; + +const Layout: Component = (props: pageModel) => { + return ( + <> + {props.page} + +
{props.children}
+ + ); +}; + +export default Layout; diff --git a/frontend/src/components/LoginPopup.tsx b/frontend/src/components/LoginPopup.tsx new file mode 100644 index 0000000..49be485 --- /dev/null +++ b/frontend/src/components/LoginPopup.tsx @@ -0,0 +1,17 @@ +import { Component } from "solid-js"; +import { LoginForm } from "./auth/LoginForm"; +import { Modal } from "./Modal"; +import styles from '../styles/auth/loginPopup.module.scss'; +const LoginPopup: Component<{ active: boolean; close: Function }> = (props) => { + return ( + +
+

Session expirée

+

Vous devez vous reconnecter

+ +
+
+ ); +}; + +export default LoginPopup; diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx new file mode 100644 index 0000000..e0e0348 --- /dev/null +++ b/frontend/src/components/Modal.tsx @@ -0,0 +1,27 @@ +import { Component, JSX, Show } from "solid-js"; +import styles from "../styles/modal.module.scss"; +export const Modal: Component<{ + children: JSX.Element; + active: boolean; + close: Function; +}> = (props) => { + return ( + <> +
+
+ {props.children} +
+
+
{ + props.close(); + }} + >
+ + ); +}; diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx new file mode 100644 index 0000000..27920f4 --- /dev/null +++ b/frontend/src/components/NavBar.tsx @@ -0,0 +1,33 @@ +import { Link, NavLink } from "@solidjs/router"; +import { Component } from "solid-js"; +import styles from "../styles/NavBar.module.scss"; +import {useAuth} from '../context/auth.context.jsx'; +const NavBar: Component = () => { + const auth = useAuth() + return ( + + ); +}; + +export default NavBar; diff --git a/frontend/src/components/Notification.tsx b/frontend/src/components/Notification.tsx new file mode 100644 index 0000000..e813f03 --- /dev/null +++ b/frontend/src/components/Notification.tsx @@ -0,0 +1,51 @@ +import { Component } from "solid-js"; +import toast, { Toast } from "solid-toast"; +import styles from "../styles/notification.module.scss"; +import {NotificationType} from '../context/notification.context.jsx' +import { AiFillCheckCircle } from "solid-icons/ai"; +import { BiSolidErrorCircle } from "solid-icons/bi"; +import { AiFillInfoCircle } from "solid-icons/ai"; +import { AiFillWarning } from "solid-icons/ai"; + +export const Notification: Component<{ + t: Toast, + title: string, + msg: string, + type: string +}> = (props) => { + const notifTypeClass = { + [NotificationType.Success]: styles["notif-success"], + [NotificationType.Error]: styles["notif-danger"], + [NotificationType.Info]: styles["notif-info"], + [NotificationType.Warning]: styles["notif-warning"], + }; + const notifTypeIcon = { + [NotificationType.Success]: , + [NotificationType.Error]: ( + + ), + [NotificationType.Info]: , + [NotificationType.Warning]: , + }; + + + return ( +
{ + toast.dismiss(props.t.id); + }} + > +
+
+ {notifTypeIcon[props.type]} +
+
+
{props.title}
+
{props.msg}
+
{" "} +
+
+ ); +}; diff --git a/frontend/src/components/Routing.tsx b/frontend/src/components/Routing.tsx new file mode 100644 index 0000000..ae60d08 --- /dev/null +++ b/frontend/src/components/Routing.tsx @@ -0,0 +1,21 @@ +import { Route, Routes } from "@solidjs/router"; +import { Component, lazy } from "solid-js"; + +const Home = lazy(()=> import('./Home')) +const ExercicesPage = lazy(() => import('../pages/ExercicePage')) +const Login = lazy(() => import("../pages/Login")); +const Dashboard = lazy(() => import("../pages/Dashboard")); +const Register = lazy(() => import("../pages/Register")); +const Routing: Component = () => { + return ( + + + + + + + + ); +} + +export default Routing \ No newline at end of file diff --git a/frontend/src/components/auth/ChangePasswordForm.tsx b/frontend/src/components/auth/ChangePasswordForm.tsx new file mode 100644 index 0000000..8f7dc39 --- /dev/null +++ b/frontend/src/components/auth/ChangePasswordForm.tsx @@ -0,0 +1,73 @@ +import { Component, createSignal } from "solid-js"; +import Section from "./section"; +import { FaSolidLock } from "solid-icons/fa"; +import TextField from "../forms/TextField"; +import { createStore } from "solid-js/store"; +import TextField2 from "../forms/TextField2"; +import styles from "../../styles/auth/changePassword.module.scss"; +import { Modal } from "../Modal"; +import { DeleteConfirm } from "./DeleteConfirm"; +import { update_password } from "../../requests/auth.requests.js"; +import { useLoginPopup } from "../../context/loginPopUp.context.jsx"; +import { useForm } from "../../hooks/useForm.js"; +export const ChangePasswordForm: Component = () => { + const [fields, setFields] = createStore({ + password: "", + password_confirm: "", + }); + const { form, setFieldsErrors, getFieldController } = useForm({ + password: "", + password_confirm: "", + }); + const loginPopup = useLoginPopup(); + return ( +
+
}> +
+ + + +
+ +
+ ); +}; diff --git a/frontend/src/components/auth/DeleteConfirm.tsx b/frontend/src/components/auth/DeleteConfirm.tsx new file mode 100644 index 0000000..848fc37 --- /dev/null +++ b/frontend/src/components/auth/DeleteConfirm.tsx @@ -0,0 +1,53 @@ +import { Component, createSignal } from "solid-js"; +import styles from "../../styles/auth/deleteConfirm.module.scss"; +import TextField2 from "../forms/TextField2"; +import jwtDecode from "jwt-decode"; +import { + delete_user, revoke_all +} from "../../requests/auth.requests.js"; +import { useNavigate } from "@solidjs/router"; +export const DeleteConfirm: Component = () => { + const [password, setPassword] = createSignal(""); + const [error, setError] = createSignal(""); + + const navigate = useNavigate() + return ( +
+

Confirmer la suppression

+

Vous êtes sur le point de supprimer votre compte.

+

Toutes vos salles seront supprimées.

+

Cette action est irréversible !

+ { + setPassword(v); + }, + }} + placeholder="Entrez votre mot de passe pour confirmer" + type="password" + /> + +
+ ); +}; diff --git a/frontend/src/components/auth/EditUserForm.tsx b/frontend/src/components/auth/EditUserForm.tsx new file mode 100644 index 0000000..db413ff --- /dev/null +++ b/frontend/src/components/auth/EditUserForm.tsx @@ -0,0 +1,100 @@ +import { + withControl, + createFormGroup, + createFormControl, + IFormGroup, + IFormControl, +} from "solid-forms"; +import TextField from "../forms/TextField"; +import styles from "../../styles/auth/editUser.module.scss"; +import { useAuth } from "../../context/auth.context.jsx"; +import { useNavigate } from "@solidjs/router"; +import TextField2 from "../forms/TextField2"; +import { Component, createEffect } from "solid-js"; +import Section from "./section"; + +import { FaSolidUser } from "solid-icons/fa"; +import { createStore } from "solid-js/store"; +import { update_user, revoke_all } from "../../requests/auth.requests.js"; +import { useForm } from "../../hooks/useForm.js"; +import { User } from "../../types/auth.type"; + + +const EditUserForm: Component<{ user: User | undefined }> = (props) => { + const { form, setFieldsErrors, getFieldController, setFieldValue } = useForm({ + email: "", + username: "", + firstname: "", + name: "", + }); + + createEffect(() => { + setFieldValue("username", props.user?.username); + setFieldValue("email", props.user?.email); + setFieldValue("firstname", props.user?.firstname); + setFieldValue("name", props.user?.name); + }); + return ( +
+
+
}> +
+ + + + + +
+
+ +
+ ); +}; + +export default EditUserForm; diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..fde330f --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,50 @@ +import { withControl, createFormGroup, createFormControl } from "solid-forms"; +import TextField from "../forms/TextField"; +import styles from "../../styles/auth/login.module.scss"; +import { dispatchAuth } from "../../context/auth.context.jsx"; +import { useNavigate } from "@solidjs/router"; +import { Component } from "solid-js"; +import { createStore } from "solid-js/store"; +import { useLoginPopup } from "../../context/loginPopUp.context.jsx"; +import { useForm } from "../../hooks/useForm.js"; +import { login_request } from "../../requests/auth.requests.js"; +import { useAuth } from "../../context/auth.context.jsx"; + +export const LoginForm: Component = () => { + const auth = useAuth(); + const { form, setFieldsErrors, getFieldController } = useForm({ + username: "", + password: "", + }); + return ( +
{ + e.preventDefault(); + var data = new URLSearchParams({ + username: form.username, + password: form.password, + }); + auth.login(form.username, form.password) + .catch((err) => { + var errs = err.response.data.detail; + setFieldsErrors(errs); + }); + }} + > + + + + + ); +}; diff --git a/frontend/src/components/auth/registerForm.tsx b/frontend/src/components/auth/registerForm.tsx new file mode 100644 index 0000000..fb8639b --- /dev/null +++ b/frontend/src/components/auth/registerForm.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from "@solidjs/router"; +import { Component } from "solid-js"; +import { useForm } from "../../hooks/useForm.js"; +import { register_request } from "../../requests/auth.requests.js"; +import TextField from "../forms/TextField"; +import styles from '../../styles/auth/login.module.scss'; +import { useAuth } from "../../context/auth.context.jsx"; + +export const RegisterForm: Component = () => { + const { form, setFieldValue, setFieldsErrors, getFieldController } = useForm({ + username: "", + password: "", + password_confirm: "", + }); + const navigate = useNavigate(); + const auth = useAuth() + return ( +
{ + e.preventDefault(); + var data = new URLSearchParams({ + username: form.username, + password: form.password, + password_confirm: form.password_confirm, + }); + auth.signup(form.username, form.password, form.password_confirm) + .catch((err) => { + var errs = err.response.data.detail; + if (err.response.status == 422) { + setFieldsErrors(errs); + } + }); + }} + > + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/auth/section.tsx b/frontend/src/components/auth/section.tsx new file mode 100644 index 0000000..d0a57c6 --- /dev/null +++ b/frontend/src/components/auth/section.tsx @@ -0,0 +1,11 @@ +import { Component, JSX } from "solid-js"; +import styles from '../../styles/auth/section.module.scss'; +const Section: Component<{name:string, icon: JSX.Element, children: JSX.Element}> = (props) => { + return
+

{props.icon}{' '}{props.name}

+
{props.children}
+ {/* {validate && validateMsg && } */} +
+} + +export default Section \ No newline at end of file diff --git a/frontend/src/components/exercices/Card.tsx b/frontend/src/components/exercices/Card.tsx new file mode 100644 index 0000000..7bef060 --- /dev/null +++ b/frontend/src/components/exercices/Card.tsx @@ -0,0 +1,217 @@ +import { useNavigate, useSearchParams } from "@solidjs/router"; +import { Component, createEffect, createSignal, For, Show } from "solid-js"; +import styles from "../../styles/exercices/Card.module.scss"; +import { parseClassName } from "../../utils/utils.js"; +import { ExerciceShort, Tag } from "../../types/exo.type"; +import { Select, TagComponent } from "../forms/select"; +import { DelayedShow } from "../DelayedShow"; +import { setTags, removeTag, cloneExo } from "../../requests/exo.requests.js"; +import { useNotification } from "../../context/notification.context.jsx"; +import { getColorCode } from "../../utils/utils.js"; +import { useAuth } from "../../context/auth.context.jsx"; +import { HiOutlineDuplicate } from "solid-icons/hi"; + +type OptionType = { + label: string; + value: string; + color: string; + isDisabled: boolean; +}; +const Card: Component<{ + exercice: ExerciceShort; + deleted: boolean; + tags: Array | undefined; + updateExo: Function; + updateTags: Function; + isAuthor: boolean; +}> = (props) => { + const { isAuthenticated } = useAuth(); + const [tagEditingMode, setTagEditingMode] = createSignal(false); + const [params, setParams] = useSearchParams(); + const [tagsOptions, setTagsOptions] = createSignal( + props.tags && + props.tags.map((t) => { + return { + color: t.color, + value: t.id_code, + label: t.label, + isDisabled: props.exercice.tags + .map((tt) => tt.id_code) + .includes(t.id_code), + }; + }) + ); + const notif = useNotification(); + const [selectedOptions, selectOption] = createSignal>([]); + createEffect(() => { + setTagsOptions( + props.tags && + props.tags.map((t) => { + return { + color: t.color, + value: t.id_code, + label: t.label, + isDisabled: props.exercice.tags + .map((tt) => tt.id_code) + .includes(t.id_code), + }; + }) + ); + }); + return ( +
+
{ + setParams({ e: props.exercice.id_code }); + }} + /> + +
+
{ + setParams({ e: props.exercice.id_code }); + }} + class={styles["card__title"]} + > + {props.exercice.name} +
+
+
+ Exemples indisponibles

+ } + > +

+ Exemples ({props.exercice.examples.type}) +

+

{props.exercice.consigne}

+ + {(i) => { + return

{i.calcul}

; + }} +
+

...

+
+
+
+
+ +
+ +
+ Aucun tag
} + > + {(t) => { + return ( + { + removeTag(props.exercice.id_code, t.id_code) + .then((res) => { + props.updateExo(res); + notif.success( + "Tag", + `Tag ${t.label} retiré avec succès de ${props.exercice.name}` + ); + }) + .catch((e) => { + notif.error( + "Erreur", + `Erreur lors de la supprésion de ${t.label} à ${props.exercice.name}` + ); + }); + }} + /> + ); + }} + +
+
{ + setTagEditingMode(true); + }} + class={styles["card__add_tag"]} + > +

+

+
+ + } + > + { + setFieldValue("private", e.target.checked); + }} + /> +
+
+
+ { + let text = await e.target.files[0].text().then((res) => res); + var filename = e.target.files[0].name; + var splitting = filename.split("."); + if (splitting[splitting.length - 1] == "py") { + setFieldValue("exo_source", { + filename: e.target.files[0].name, + data: text, + }); + } + }} + /> + +
+ + {form.exo_source.filename} + +
+
+

{errors().exo_source_error}

+
+
+ + +
+ + ); +}; diff --git a/frontend/src/components/exercices/ExerciceEditForm.tsx b/frontend/src/components/exercices/ExerciceEditForm.tsx new file mode 100644 index 0000000..0806a21 --- /dev/null +++ b/frontend/src/components/exercices/ExerciceEditForm.tsx @@ -0,0 +1,142 @@ +import { Component, onCleanup, Show } from "solid-js"; +import { useForm } from "../../hooks/useForm.js"; +import TextField from "../forms/TextField.jsx"; +import TextField2 from "../forms/TextField2.jsx"; +import { editExo, getExoSourceFile } from "../../requests/exo.requests.js"; +import styles from "../../styles/exercices/exoForm.module.scss"; +import { isEmpty } from "../../utils/utils.js"; +import { Exercice } from '../../types/exo.type' +import { FaSolidDownload } from "solid-icons/fa"; +export const ExoEditForm: Component<{ + cancel: Function; + validate: Function; + exercice: Exercice +}> = (props) => { + const { + form, + setFieldValue, + setFieldsErrors, + getFieldController, + errors, + reset, + } = useForm({ + name: props.exercice.name, + consigne: props.exercice.consigne, + private: props.exercice.private, + exo_source: { data: null, filename: props.exercice.exo_source_name }, + }); + + onCleanup(() => { + reset(); + }); + return ( +
{ + e.preventDefault(); + + var data = new FormData(); + data.append("name", form.name); + data.append("consigne", form.consigne); + data.append("private", form.private); + var file; + if (form.exo_source.data != null) { + var blob = new Blob([form.exo_source.data], { + type: "text/x-python", + }); + file = new File( + [blob], + form.exo_source.filename ? form.exo_source.filename : "main.py", + { + type: "text/x-python", + } + ); + data.append("file", file); + } + + + editExo(props.exercice.id_code, data) + .then(() => { + props.cancel(); + }) + .catch((err) => { + var errs = err.response.data.detail; + if (err.response.status == 422) { + setFieldsErrors(errs); + } + }); + }} + > +

Modification

+ + + +
+ + { + setFieldValue("private", e.target.ariaChecked); + }} + /> +
+
+
+ { + let text = await e.target.files[0].text().then((res) => res); + var filename = e.target.files[0].name; + var splitting = filename.split("."); + if (splitting[splitting.length - 1] == "py") { + setFieldValue("exo_source", { + filename: e.target.files[0].name, + data: text, + }); + } + }} + /> + +
+ + {form.exo_source.filename} + { + console.log('test') + getExoSourceFile(props.exercice.id_code, props.exercice.exo_source_name) + }}/> + +
+
+

{errors().exo_source_error}

+
+
+ + +
+ + ); +}; diff --git a/frontend/src/components/exercices/ModalCard.tsx b/frontend/src/components/exercices/ModalCard.tsx new file mode 100644 index 0000000..79951b8 --- /dev/null +++ b/frontend/src/components/exercices/ModalCard.tsx @@ -0,0 +1,345 @@ +import { useParams, useSearchParams } from "@solidjs/router"; +import { + Component, + createEffect, + createResource, + createSignal, + For, + Show, + Suspense, +} from "solid-js"; +import { getColorCode } from "../../utils/utils.js"; +import { useAuth } from "../../context/auth.context.jsx"; + +import { + getExo, + deleteExo, + removeTag, + setTags, + cloneExo, +} from "../../requests/exo.requests.js"; +import TextField from "../forms/TextField.jsx"; +import styles from "../../styles/exercices/ModalCard.module.scss"; +import { generateCsv } from "../../requests/exo.requests.js"; +import { BiSolidEditAlt, BiSolidTrash } from "solid-icons/bi"; +import { HiOutlineDuplicate } from "solid-icons/hi"; +import { useNotification } from "../../context/notification.context.jsx"; +import { ExoEditForm } from "./ExerciceEditForm.jsx"; +import { Exercice, Tag } from "../../types/exo.type"; +import { Select, TagComponent } from "../forms/select.jsx"; +type OptionType = { + label: string; + value: string; + color: string; + isDisabled: boolean; +}; +export const ModalCard: Component<{ + cancel: Function; + updateExo: Function; + tags: Array | undefined; + updateTags: Function; +}> = (props) => { + const [params, setParams] = useSearchParams(); + const notif = useNotification(); + const { isAuthenticated } = useAuth(); + const fetchExo = (id) => { + console.log("testeaea", id); + return getExo(id).catch((r) => { + notif.error("Erreur", "Erreur lors de la récupération de l'exercice !"); + props.cancel(); + }); + }; + const [exercice, { mutate, refetch }] = createResource( + () => params.e, + fetchExo + ); + + const [editing, setEditing] = createSignal(false); + const [filename, setFilename] = createSignal(""); + const [tagEditing, setTagEditing] = createSignal(false); + const [tagsOptions, setTagsOptions] = createSignal( + props.tags && + props.tags.map((t) => { + return { + color: t.color, + value: t.id_code, + label: t.label, + isDisabled: exercice() + ? exercice() + .tags.map((tt) => tt.id_code) + .includes(t.id_code) + : false, + }; + }) + ); + const [selectedOptions, selectOption] = createSignal>([]); + createEffect(() => { + setTagsOptions( + props.tags && + props.tags.map((t) => { + return { + color: t.color, + value: t.id_code, + label: t.label, + isDisabled: exercice() + ?.tags.map((tt) => tt.id_code) + .includes(t.id_code), + }; + }) + ); + }); + return ( +
+ }> + +
+

{exercice()?.name}

+
+ {exercice()?.author.username}

+ } + > +

{ + setParams({ e: exercice()?.origin?.id_code }); + }} + class={styles.original} + > + Exercice original +

+
+
+
+ +
+ { + setFilename(v); + }, + }} + label={"Nom du fichier"} + required={false} + type="text" + /> +
+ Exemples indisponibles} + > +
+

Exemples ({exercice()?.examples.type})

+ +

{exercice()?.consigne}

+ + + {(i) => { + return

{i.calcul}

; + }} +
+

...

+
+
+
+ +
+ +
+ +
+ Aucun tag
} + > + {(t) => { + return ( + { + removeTag(exercice()?.id_code, t.id_code) + .then((res) => { + props.updateExo(res); + mutate((o) => { + return { ...o, tags: res.tags }; + }); + notif.success( + "Tag", + `Tag ${t.label} retiré avec succès de ${ + exercice()?.name + }` + ); + }) + .catch((e) => { + notif.error( + "Erreur", + `Erreur lors de la supprésion de ${ + t.label + } à ${exercice()?.name}` + ); + }); + }} + /> + ); + }} + +
+
{ + setTagEditing(true); + }} + class={styles["ex_card--add-tag"]} + > +

+

+
+ + } + > + { + props.control.setValue(e.currentTarget.value); + }} + placeholder="" + type={type()} + minLength={props.min ?? 1} + maxLength={props.max ?? undefined} + /> + {props.type == "password" && + (type() != "password" ? ( + { + setType("password"); + }} + class={styles["password-toggler"]} + /> + ) : ( + { + setType("text"); + }} + class={styles["password-toggler"]} + /> + ))} + + + + {" "} +

{props.control.error}

{" "} +
+ {" "} + + ); +}; + +export default TextField; diff --git a/frontend/src/components/forms/TextField2.tsx b/frontend/src/components/forms/TextField2.tsx new file mode 100644 index 0000000..5b610d2 --- /dev/null +++ b/frontend/src/components/forms/TextField2.tsx @@ -0,0 +1,50 @@ +import { IFormControl } from "solid-forms"; +import { Component, createSignal } from "solid-js"; +import styles from "../../styles/form/input2.module.scss"; +import { parseClassName } from "../../utils/utils.js"; +import { AiFillEye, AiFillEyeInvisible } from "solid-icons/ai"; + +const TextField2: Component<{ + control: {value: string, setValue: Function, error: string |null}, + label?: string , + placeholder?: string , + type: string +}> = (props) => { + const [type, setType] = createSignal(props.type) + return ( +
+ + + { + props.control.setValue(e.currentTarget.value); + }} + value={props.control.value} + id={props.label} + placeholder={props.placeholder} + /> + {props.type == "password" && + (type() != "password" ? ( + { + setType("password"); + }} + class={styles["password-toggler"]} + /> + ) : ( + { + setType("text"); + }} + class={styles["password-toggler"]} + /> + ))} + + {props.control.error} +
+ ); +}; + +export default TextField2; diff --git a/frontend/src/components/forms/select.tsx b/frontend/src/components/forms/select.tsx new file mode 100644 index 0000000..ddc57eb --- /dev/null +++ b/frontend/src/components/forms/select.tsx @@ -0,0 +1,262 @@ +import { Component, createSignal, For, JSX, Show } from "solid-js"; +import styles from "../../styles/form/select.module.scss"; +import { css } from "solid-styled-components"; +import chroma from "chroma-js"; +import { ImCross } from "solid-icons/im"; +import { RiSystemArrowDownSLine } from "solid-icons/ri"; +import { DropdownIndicator } from "../icons/dropdown_indicator"; +import { ClearIndicator } from "../icons/ClearIndicator"; +type OptionType = { + label: string, + value: string, + color: string, + isDisabled: boolean +}; + +const Option: Component<{ + innerProps: Object; + children: JSX.Element; + data: OptionType; +}> = (props) => { + const color = chroma(props.data.color); + return ( +
+ {props.children} +
+ ); +}; + +export const TagComponent: Component<{ tag: OptionType; onDelete?: Function }> = ( + props +) => { + const colorChrome = chroma(props.tag.color); + return ( +
+
{props.tag.label}
+ + +
{ + props.onDelete && props.onDelete(); + }} + > + +
+
+
+ ); +}; + +export const Select: Component<{ + options: Array; + selectedOptions: Array; + onChange: Function; + placeholder?: string; + onCreate?: Function; +}> = (props) => { + let input; + let menu; + const [focus, setFocus] = createSignal(false); + const [value, setValue] = createSignal(""); + return ( + <> +
{ + input.focus(); + console.log(props.options, 'optiosn') + }} + > +
+
+ + {(o) => { + return ( + { + props.onChange( + props.selectedOptions.filter((opt) => { + return opt.value != o.value; + }) + ); + }} + /> + ); + }} + + + +
+ {props.placeholder || "Selectionner..."} +
+
+ +
+ { + setFocus(true) + setValue(e.target.value); + }} + role="combobox" + onfocus={() => { + setFocus(true); + }} + onblur={() => { + setTimeout(() => { + if (menu.contains(document.activeElement)) { + input.focus(); + return; + } + setFocus(false); + //setValue(""); + }, 1); + }} + tabIndex={"0"} + /> +
+
+
+ + { + props.onChange([]); + }} + /> + +
+ { + setFocus((f) => !f); + }} + /> +
+
+ + +
+
{ + e.stopPropagation(); + e.preventDefault(); + input.focus(); + }} + ref={menu} + tabindex={"0"} + > + { + return o.label == value(); + }).length == 0 + } + > +
{ + props.onCreate && props.onCreate(value()); + setValue(""); + setFocus(false); + }} + > + Créer "{value()}" +
+
+ { + return !props.selectedOptions + .map((s) => s.value) + .includes(o.value); + }) + .filter((o) => { + return o.label.startsWith(value()); + })} + fallback={ + !props.onCreate || + props.selectedOptions.filter((s) => { + return s.label == value(); + }).length != 0 || + (value() == "" && ( +
Aucune option trouvée
+ )) + } + > + {(o) => { + return ( + + ); + }} +
+
+
+
+
+ + ); +}; diff --git a/frontend/src/components/icons/ClearIndicator.tsx b/frontend/src/components/icons/ClearIndicator.tsx new file mode 100644 index 0000000..171db95 --- /dev/null +++ b/frontend/src/components/icons/ClearIndicator.tsx @@ -0,0 +1,15 @@ +import { Component } from "solid-js"; + +export const ClearIndicator: Component = (props) => { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/icons/dropdown_indicator.tsx b/frontend/src/components/icons/dropdown_indicator.tsx new file mode 100644 index 0000000..8aa2ced --- /dev/null +++ b/frontend/src/components/icons/dropdown_indicator.tsx @@ -0,0 +1,15 @@ +import { Component } from "solid-js"; + +export const DropdownIndicator: Component = (props) => { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/test.tsx b/frontend/src/components/test.tsx new file mode 100644 index 0000000..80d0c6e --- /dev/null +++ b/frontend/src/components/test.tsx @@ -0,0 +1,12 @@ +import { useParams } from "@solidjs/router"; +import { Component } from "solid-js"; +import Layout from "./Layout"; + +const Test: Component = () => { + const params = useParams(); + return +

TEST {params.id}

+
+} + +export default Test \ No newline at end of file diff --git a/frontend/src/context/auth.context.jsx b/frontend/src/context/auth.context.jsx new file mode 100644 index 0000000..5e64a32 --- /dev/null +++ b/frontend/src/context/auth.context.jsx @@ -0,0 +1,147 @@ +import { useNavigate } from "@solidjs/router"; +import jwtDecode from "jwt-decode"; +import { useContext } from "solid-js"; +import { createSignal } from "solid-js"; +import { Show } from "solid-js"; +import { createEffect } from "solid-js"; +import { createContext } from "solid-js"; +import { createStore } from "solid-js/store"; +import toast from "solid-toast"; +import { setSourceMapRange } from "typescript"; +import { + check_access, + get_user, + login_request, + refresh_request, + register_request, + revoke_access, + revoke_refresh, +} from "../requests/auth.requests.js"; +import { useLoginPopup } from "./loginPopUp.context.jsx"; +import { AiFillAndroid } from 'solid-icons/ai' +import { useNotification } from "./notification.context.jsx"; +const AuthContext = createContext(); + + +const jwt_expire_check = (token) => { + var { exp } = jwtDecode(token); + return Date.now() >= exp * 1000; +}; +export function AuthProvider(props) { + const [username, setUsername] = createSignal(null); + const [isAuthenticated, setAuthenticated] = createSignal(false); + const [loading, setLoading] = createSignal(false); + const [initialLoading, setInitialLoading] = createSignal(true); + + const navigate = useNavigate(); + const notification = useNotification() + const popup = useLoginPopup(); + + createEffect(() => { + var token = localStorage.getItem("token"); + var refresh = localStorage.getItem("refresh_token"); + if (token != null) { + var is_expired = jwt_expire_check(token); + if (is_expired && refresh != null) { + refresh_request(refresh) + .then((r) => { + localStorage.setItem("token", r.access_token); + return r.access_token; + }) + .then((t) => { + setAuthenticated(true); + var { sub } = jwtDecode(t); + setUsername(sub); + }).catch((err) => { + if ( !!err.response.status && err.reponse.status == 422) { + localStorage.removeItem("refresh_token"); + localStorage.removeItem("token"); + } + }); + } else { + check_access(token) + .then(() => { + setAuthenticated(true); + var { sub } = jwtDecode(token); + setUsername(sub); + }) + .catch((err) => { + if (err.reponse.status == 422) { + localStorage.removeItem("refresh_token"); + localStorage.removeItem("token"); + } + }); + } + } + setInitialLoading(false); + }); + + const login = (username, password) => { + var data = new URLSearchParams({ username, password }); + return login_request(data).then((r) => { + localStorage.setItem("token", r.access_token); + localStorage.setItem("refresh_token", r.refresh_token); + + var decoded = jwtDecode(r.access_token); + setAuthenticated(true); + setUsername(decoded.sub); + + if (popup.active == true) { + popup.next(); + } else { + navigate("/dashboard"); + } + }); + }; + + const signup = (username, password, password2) => { + var data = new URLSearchParams({ + username: username, + password: password, + password_confirm: password2, + }); + register_request(data).then((r) => { + localStorage.setItem("token", r.access_token); + localStorage.setItem("refresh_token", r.refresh_token); + + var decoded = jwtDecode(r.access_token); + setAuthenticated(true); + setUsername(decoded.sub); + + navigate("/dashboard"); + }); + }; + + const logout = () => { + var token = localStorage.getItem("token"); + var refresh = localStorage.getItem("refresh_token"); + revoke_access(token) + .then(() => { + return revoke_refresh(refresh); + }) + .then(() => { + localStorage.removeItem("token"); + localStorage.removeItem("refresh_token"); + setUsername(null) + setAuthenticated(false) + navigate("/login"); + notification.success('Déconnexion', "Vous n'êtes plus connecté !") + }); + }; + return ( + + {props.children} + + ); +} + +export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/context/loginPopUp.context.jsx b/frontend/src/context/loginPopUp.context.jsx new file mode 100644 index 0000000..e87a3a8 --- /dev/null +++ b/frontend/src/context/loginPopUp.context.jsx @@ -0,0 +1,14 @@ +import { useContext } from "solid-js"; +import { createSignal } from "solid-js"; +import { createContext } from "solid-js"; + +const LogInPopUpContext = createContext({ toggle: ()=>{} }); + +export const LoginPopUpProvider = (props) => { + return ( + {props.children} + ); +}; + + +export const useLoginPopup = ()=> useContext(LogInPopUpContext) \ No newline at end of file diff --git a/frontend/src/context/navigate.context.jsx b/frontend/src/context/navigate.context.jsx new file mode 100644 index 0000000..cafd25e --- /dev/null +++ b/frontend/src/context/navigate.context.jsx @@ -0,0 +1,15 @@ +import { useNavigate } from "@solidjs/router" +import { createContext, useContext } from "solid-js" +const NavigateContext = createContext() + +export const NavigateProvider = (props) => { + + const navigateCtx = useNavigate() + const navigate = (url, params, options) => { + const parsedParams = new URLSearchParams(params) + navigateCtx(`${url}?${parsedParams.toString()}`, options) + } + return {props.children} +} + +export const useNavigateCustom =()=> useContext(NavigateContext) \ No newline at end of file diff --git a/frontend/src/context/notification.context.jsx b/frontend/src/context/notification.context.jsx new file mode 100644 index 0000000..b4b669d --- /dev/null +++ b/frontend/src/context/notification.context.jsx @@ -0,0 +1,91 @@ +import { useContext } from "solid-js"; +import { createContext } from "solid-js"; +import toast from "solid-toast"; +import { Notification } from "../components/Notification.tsx"; + +const NotificationContext = createContext(); +export const NotificationType = { + Success: "Success", + Error: "Error", + Info: "Info", + Warning: "Warning", +}; + +export function NotificationProvider(props) { + function success(title, message) { + toast.custom( + (t) => { + return ( + + ); + }, + { + unmountDelay: 300, + duration: 3500, + } + ); + } + function error(title, message) { + toast.custom( + (t) => { + return ( + + ); + }, + { + unmountDelay: 300, + duration: 3500, + } + ); + } + function warning(title, message) { + toast.custom( + (t) => { + return ( + + ); + }, + { + unmountDelay: 300, + duration: 3500, + } + ); + } + function info(title, message) { + toast.custom( + (t) => { + return ( + + ); + }, + { + unmountDelay: 300, + duration: 3500, + } + ); + } + + return ( + + {props.children} + + ); +} + + +export const useNotification = ()=>useContext(NotificationContext) \ No newline at end of file diff --git a/frontend/src/hooks/useForm.js b/frontend/src/hooks/useForm.js new file mode 100644 index 0000000..ba0df1f --- /dev/null +++ b/frontend/src/hooks/useForm.js @@ -0,0 +1,57 @@ +import { createStore } from "solid-js/store"; +import { createSignal } from "solid-js"; + +export const useForm = (initialValues) => { + var init = initialValues; + var init_error = {} + console.log(init) + Object.keys(init).map((k) => { + init_error[`${k}_error`] = ""; + }); + const [form, setForm] = createStore(initialValues); + const [errors, setErrors] = createSignal(init_error); + + const setFieldValueEvent = (fieldname) => (e) => { + const inputElement = e.currentTarget; + if (inputElement.type === "checkbox") { + setForm({ + [fieldname]: !!inputElement.checked, + }); + } else { + setForm({ + [fieldname]: inputElement.value, + }); + } + }; + + const setFieldValue = (fieldname,e) => { + setForm({ + [fieldname]: e, + }); + }; + + const getFieldController = (fieldname) => { + return { + value: form[fieldname], + error: errors()[fieldname + '_error'], + setValue: (v) => { + setForm({ + [fieldname]: v, + }); + }, + }; + }; + + const setFieldsErrors = (err) => { + + /* const errs = Object.fromEntries( + Object.entries(err).map(([k, v]) => [`${k}_error`, v]) + ); */ + console.log({ ...init_error, ...err }, init_error, "err", err); + setErrors({ ...init_error, ...err }); + }; + const reset = () => { + setForm(()=>initialValues) + } + return { form, setFieldValue, setFieldsErrors, getFieldController, errors, reset }; +}; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..ec2585e --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 0000000..ccddb7a --- /dev/null +++ b/frontend/src/index.tsx @@ -0,0 +1,72 @@ +/* @refresh reload */ +import { render } from "solid-js/web"; + +import "./index.css"; +import "./styles/index.scss"; +import App from "./App"; +import { Router } from "@solidjs/router"; +import { exoInstance } from "./apis/exoInstance.instance.js"; +import { refresh_request } from "./requests/auth.requests.js"; +import jwtDecode from "jwt-decode"; +const jwt_expire_check = (token) => { + var { exp } = jwtDecode(token); + return Date.now() >= exp * 1000; +}; /* +exoInstance.interceptors.request.use( + (config) => { + + if ("Authorization" in config.headers) { + var token = localStorage.getItem("token"); + var refresh = localStorage.getItem("refresh_token"); + var originalRequest = config; + if (token != null && refresh != null) { + if (jwt_expire_check(token)) { + refresh_request(refresh).then((r) => { + localStorage.setItem("token", r.access_token); + + originalRequest.headers.Authorization = "Bearer " + r.access_token; + return Promise.resolve(originalRequest); + }); + } + } + } + return config; + }, + (err) => { + return Promise.reject(err); + } +); */ +exoInstance.interceptors.response.use( + (response) => response, + (error) => { + console.log(error, "errrrrrrrrrrrrrrr") + const status = error.response ? error.response.status : null; + console.log(status) + if (error.response.data.detail === "Signature has expired") { + var token = localStorage.getItem("token"); + var refresh = localStorage.getItem("refresh_token"); + console.log("testtetetet", token, refresh ); + if (token != null && refresh != null) { + console.log('tets') + refresh_request(refresh).then((r) => { + error.config.headers["Authorization"] = "Bearer " + r.access_token; + localStorage.setItem('token', r.access_token) + error.config.baseURL = undefined; + + return exoInstance.request(error.config); + }); + } + } + + return Promise.reject(error); + } +); + +render( + () => ( + + + + ), + document.getElementById("root") as HTMLElement +); diff --git a/frontend/src/logo.svg b/frontend/src/logo.svg new file mode 100644 index 0000000..025aa30 --- /dev/null +++ b/frontend/src/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..2f2ccb0 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,55 @@ +import { Component, createEffect, createResource, createSignal, Suspense } from "solid-js"; +import Section from "../components/auth/section"; +import Layout from "../components/Layout"; +import { get_user } from "../requests/auth.requests.js"; +import EditUserForm from "../components/auth/EditUserForm"; +import styles from "../styles/auth/dashboard.module.scss"; +import { RiUserGroupFill } from "solid-icons/ri"; +import { ChangePasswordForm } from "../components/auth/ChangePasswordForm"; +import { AiFillDelete } from "solid-icons/ai"; +import { Modal } from "../components/Modal"; +import { DeleteConfirm } from "../components/auth/DeleteConfirm"; +import { useNavigate } from "@solidjs/router"; +import jwtDecode from "jwt-decode"; +import { refresh_request } from "../requests/auth.requests.js"; +import { User } from '../types/auth.type'; +import {useAuth} from '../context/auth.context.jsx' + +const Dashboard: Component = () => { + const auth = useAuth(); + const navigate = useNavigate(); + + createEffect(() => { + if (!auth.isAuthenticated()) { + navigate('/login') + } + }) + const [data, { mutate, refetch }] = createResource(get_user, { initialValue: { email: '', name: "", firstname: "", username:'', rooms: []}}); + const [active, setActive] = createSignal(false) + + return ( + + + +

Mon compte

+ +
}> +

salles

+
+ + + +
+

Danger zone

+ +
+
+ + { setActive(false) }}> + + +
+ ); +}; + +export default Dashboard; diff --git a/frontend/src/pages/ExercicePage.tsx b/frontend/src/pages/ExercicePage.tsx new file mode 100644 index 0000000..37797bc --- /dev/null +++ b/frontend/src/pages/ExercicePage.tsx @@ -0,0 +1,357 @@ +import { + Component, + createEffect, + createResource, + createSignal, + For, + onMount, + Show, + Suspense, +} from "solid-js"; +import Layout from "../components/Layout"; +import { + getExos, + getTags, + getUserExos, + getPublicExos, +} from "../requests/exo.requests.js"; +import Card from "../components/exercices/Card"; +import styles from "../styles/exercices/ExercicesPage.module.scss"; +import { useAuth } from "../context/auth.context.jsx"; +import { FaSolidLock } from "solid-icons/fa"; +import { Modal } from "../components/Modal"; +import { ExoCreateForm } from "../components/exercices/ExerciceCreateForm"; +import { + useLocation, + useNavigate, + useParams, + useSearchParams, +} from "@solidjs/router"; +import { ModalCard } from "../components/exercices/ModalCard"; +import { Select } from "../components/forms/select"; +import { ExerciceShort, Tag } from "../types/exo.type"; +import { DelayedShow } from "../components/DelayedShow"; +import { Pagination } from "../components/exercices/Pagination"; +import { useNavigateCustom } from "../context/navigate.context.jsx"; +type OptionType = { + label: string; + value: string; + color: string; + isDisabled: boolean; +}; + +type QueryOption = { + s: string; + tags: Array; + isAuthenticated: boolean; + show: "user" | "public"; + page: number | string; +}; + +const ExercicesPage: Component = () => { + const [searchParams, setParams] = useSearchParams(); + const { isAuthenticated } = useAuth(); + + const params = useParams(); + const [page, setPage] = createSignal( + !!searchParams.page + ? parseInt(searchParams.page) == 0 + ? 1 + : searchParams.page + : 1 + ); + const [search, setSearch] = createSignal(""); + const [tmpSearch, setTmpSearch] = createSignal(""); + + const [exoShow, setExoShow] = createSignal<"user" | "public">( + !!params.id_code + ? params.id_code == "user" + ? isAuthenticated() + ? "user" + : "public" + : "public" + : isAuthenticated() + ? "user" + : "public" + ); + + createEffect((prev: number | undefined) => { + if (prev) { + clearTimeout(prev); + } + + tmpSearch(); // Sinon la fonction n'est pas appelée à chaque changement de [tmpSearch] + var timer = setTimeout(() => { + setSearch(tmpSearch()); + }, 500); + return timer; + }); + + const [selectedOptions, selectOption] = createSignal>([]); + + const fetchExos = (query: QueryOption) => { + if (query.isAuthenticated && query.show == "user") { + return getUserExos( + query.s, + query.tags, + query.page == 0 ? 1 : query.page + ).then((res) => { + if (page() > Math.ceil(res.total / res.size)) { + setPage( + Math.ceil(res.total / res.size) == 0 + ? 1 + : Math.ceil(res.total / res.size) + ); + } else { + setParams({ page: res.page }, { scroll: true }); + } + return res; + }); + } else if (query.show == "public") { + return getPublicExos( + query.s, + query.tags, + query.page == 0 ? 1 : query.page + ).then((res) => { + if (page() > Math.ceil(res.total / res.size)) { + setPage( + Math.ceil(res.total / res.size) == 0 + ? 1 + : Math.ceil(res.total / res.size) + ); + } else { + setParams({ page: res.page }, { scroll: true }); + } + return res; + }); + } + }; + const [exo_data, { mutate, refetch }] = createResource< + { + items: Array; + total: number; + page: number; + size: number; + }, + QueryOption + >(() => { + return { + s: search(), + tags: selectedOptions().map((t) => t.value), + isAuthenticated: isAuthenticated(), + show: exoShow(), + page: page(), + }; + }, fetchExos); + + const fetchTags = (q: { isAuth: boolean }) => { + console.log("fetcg"); + if (q.isAuth) { + return getTags(); + } + return []; + }; + const [tags_data, { mutate: mutate_tag, refetch: refetch_tag }] = + createResource, { isAuth: boolean }>(() => { + return { isAuth: isAuthenticated() }; + }, fetchTags); + + const [create, setCreate] = createSignal(false); + const [active, setActive] = createSignal(false); + const navigate = useNavigateCustom(); + onMount(() => { + var oldParams = { ...searchParams }; + var e = searchParams.e; + if (isAuthenticated() && exoShow() == "user") { + navigate(`/exercices/user`, oldParams); + } else if (exoShow() == "public") { + navigate(`/exercices/public`, oldParams); + } + setParams(oldParams); + }); + + createEffect(() => { + if (searchParams.e !== undefined) { + setActive(true); + } + }); + + return ( + +
+
+
+ +
+ +
+ { + setTmpSearch(e.currentTarget.value); + }} + /> + + { + setExoShow(e.currentTarget.value); + setPage(1); + }} + > + + + + +
+
+
+
+ +

+ Tous les exercices +

+

+ Vous retrouverez ici tous les exercices crées par les autres + utilisateurs +

+ + } + > +

+ Tous vos exercices +

+

+ Vous retrouverez ici tous les exercices que vous avez créé ou + copié depuis les exercices publics +

+
+
+ + +
+
+
+ + } + > + { + return selectedOptions().length == 0 + ? true + : selectedOptions().every((s) => { + return e.tags.map((t) => t.id_code).includes(s.value); + }); + })} + fallback={

Aucun résultat :(

} + > + {(exo) => { + return ( + ) => mutate_tag(new_tags)} + updateExo={(new_exo: ExerciceShort) => { + var items = exo_data()?.items; + mutate((o) => { + return { + ...o, + items: o?.items.map((e) => { + if (e.id_code == exo.id_code) { + return new_exo; + } else return e; + }), + }; + }); + }} + /> + ); + }} +
+ +
+ 1}> + + +
+ { + setCreate(false); + setActive(false); + setParams({ e: undefined }); + }} + > +
+ + setCreate(false)} validate={refetch} /> + + + { + setActive(false); + setParams({ e: undefined }); + refetch(); + }} + updateExo={(new_exo: ExerciceShort) => { + var items = exo_data()?.items; + mutate((o) => { + return { + ...o, + items: o?.items.map((e) => { + if (e.id_code == searchParams.e) { + return new_exo; + } else return e; + }), + }; + }); + }} + tags={tags_data() && tags_data()} + updateTags={mutate_tag} + /> + +
+
+ + ); +}; + +export default ExercicesPage; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..47197c9 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,30 @@ +import { Component, createEffect } from "solid-js"; +import { LoginForm } from "../components/auth/LoginForm"; +import Layout from "../components/Layout"; +import styles from "../styles/auth/loginPage.module.scss"; + +import jwtDecode from "jwt-decode"; +import { refresh_request } from "../requests/auth.requests.js"; +import { useNavigate } from "@solidjs/router"; +import { useAuth } from "../context/auth.context.jsx"; +const Login: Component = () => { + const navigate = useNavigate(); + const auth = useAuth(); + createEffect(() => { + if (auth.isAuthenticated()) { + navigate("/dashboard"); + } + }); + return ( + +
+

Se connecter

+
+ +
+
+
+ ); +}; + +export default Login; diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx new file mode 100644 index 0000000..bd0c323 --- /dev/null +++ b/frontend/src/pages/Register.tsx @@ -0,0 +1,35 @@ +import { useNavigate } from "@solidjs/router"; +import { Component, createEffect } from "solid-js"; +import { createStore } from "solid-js/store"; +import TextField from "../components/forms/TextField"; +import Layout from "../components/Layout"; +import { register_request } from "../requests/auth.requests.js"; +import { useForm } from "../hooks/useForm.js"; +import { RegisterForm } from "../components/auth/registerForm"; +import styles from "../styles/auth/loginPage.module.scss"; +import jwtDecode from "jwt-decode"; +import { refresh_request } from '../requests/auth.requests.js'; +import { useAuth } from "../context/auth.context.jsx"; + +const Register: Component = () => { + const navigate = useNavigate(); + + const auth = useAuth(); + createEffect(() => { + if (auth.isAuthenticated()) { + navigate("/dashboard"); + } + }); + return ( + +
+

S'inscrire

+
+ +
+
+
+ ); +}; + +export default Register; diff --git a/frontend/src/requests/auth.requests.js b/frontend/src/requests/auth.requests.js new file mode 100644 index 0000000..181cdee --- /dev/null +++ b/frontend/src/requests/auth.requests.js @@ -0,0 +1,126 @@ +import { authInstance } from "../apis/auth.instance.js" + +export const login_request= (data)=> { + return authInstance + .request({ + url: "/login", + method: "post", + data: data, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }) + .then((r) => r.data); +} +export const register_request= (data)=> { + return authInstance + .request({ + url: "/register", + method: "post", + data: data, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }) + .then((r) => r.data).catch((err)=> {throw err}); +} +export const refresh_request= (token)=> { + return authInstance + .request({ + url: "/refresh", + method: "post", + headers: { "Authorization": "Bearer " + token }, + }) + .then((r) => r.data); +} +export const revoke_access = (token)=> { + return authInstance + .request({ + url: "/access-revoke", + method: "delete", + headers: { "Authorization": "Bearer " + token }, + }) + .then((r) => r.data); +} + +export const check_access = (token)=> { + return authInstance + .request({ + url: "/check-access", + method: "post", + headers: { "Authorization": "Bearer " + token }, + }) + .then((r) => r.data); +} + +export const revoke_refresh = (token)=> { + return authInstance + .request({ + url: "/refresh-revoke", + method: "delete", + headers: { "Authorization": "Bearer " + token }, + }) + .then((r) => r.data); +} + +export const revoke_all = () => { + revoke_access(localStorage.getItem('token')) + revoke_refresh(localStorage.getItem('refresh_token')) +} + +export const get_user = () => { + return authInstance + .request({ + url: "/user", + method: "get", + headers: { + ...(localStorage.getItem('token') != null && {Authorization: "Bearer " + localStorage.getItem('token')}), + }, + }) + .then((r) => r.data); +} +export const update_user = (data) => { + return authInstance + .request({ + url: "/user", + method: "put", + data: data, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((r) => r.data); +} + + + +export const update_password = (data) => { + return authInstance + .request({ + url: "/user/password", + method: "put", + data: data, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((r) => r.data); +} + +export const delete_user = (data) => { + return authInstance + .request({ + url: "/user/", + method: "delete", + data: data, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((r) => r.data); +} diff --git a/frontend/src/requests/exo.requests.js b/frontend/src/requests/exo.requests.js new file mode 100644 index 0000000..288d39c --- /dev/null +++ b/frontend/src/requests/exo.requests.js @@ -0,0 +1,214 @@ +import { exoInstance } from "../apis/exoInstance.instance.js"; +import { csvExtParse } from '../utils/utils.js' + +export const getExos = async (search, tags) => { + return exoInstance + .request({ + method: "get", + url: "/exercices", + params: { search: search, tags: tags }, + headers: { + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((r) => { + return r.data; + }); +}; + +export const getUserExos = async (search, tags, page) => { + return exoInstance + .request({ + method: "get", + url: "/exercices/user", + params: { search: search, tags: tags, page: page, size: 22 }, + headers: { + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((r) => { + return r.data; + }); +}; + +export const cloneExo = async (id_code) => { + return exoInstance + .request({ + method: "post", + url: `/exercices/${id_code}/clone`, + headers: { + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((r) => { + return r.data; + }); +}; + + + +export const getPublicExos = async (search, tags, page) => { + return exoInstance + .request({ + method: "get", + url: "/exercices/public", + params: { search: search, tags: tags, page, size: 22 }, + headers: { + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((r) => { + console.log(r.data); + return r.data; + }); +}; +export const getTags = async () => { + return exoInstance + .request({ + method: "get", + url: "/tags", + headers: { + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((r) => { + console.log(r.data); + return r.data; + }); +}; + +export const createExo = async (data) => { + return exoInstance + .request({ + url: "/exercices", + method: "post", + data: data, + headers: { + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((r) => r.data); +}; +export const editExo = async (id_code, data) => { + return exoInstance + .request({ + url: "/exercice/" + id_code, + method: "put", + data: data, + headers: { + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((r) => r.data); +}; + +export const getExo = async (id_code) => { + return exoInstance + .request({ + method: "get", + url: "/exercice/" + id_code, + headers: {...(localStorage.getItem('token') != null && {Authorization: "Bearer " + localStorage.getItem('token')})} + }) + .then((r) => { + console.log(r.data); + return r.data; + }); +}; +export const deleteExo = async (id_code) => { + return exoInstance + .request({ + method: "delete", + url: "/exercice/" + id_code, + headers: {...(localStorage.getItem('token') != null && {Authorization: "Bearer " + localStorage.getItem('token')})} + }) + .then((r) => { + console.log(r.data); + return r.data; + }); +}; + +export const setTags = async (id_code, data) => { + return exoInstance + .request({ + method: "post", + url: "/tags/" + id_code, + data: data, + headers: {...(localStorage.getItem('token') != null && {Authorization: "Bearer " + localStorage.getItem('token')})} + }) + .then((r) => { + return r.data; + }); +}; +export const removeTag = async (id_code, tag_id) => { + return exoInstance + .request({ + method: "delete", + url: "/tags/" + id_code + '/' + tag_id, + //data: {tag_id}, + headers: {...(localStorage.getItem('token') != null && {Authorization: "Bearer " + localStorage.getItem('token')})} + }) + .then((r) => { + return r.data; + }); +}; + +export const generateCsv = async (id_code, filename) => { + return exoInstance + .request({ + method: "get", + url: "/generator/csv/" + id_code, + params: { filename }, + }) + .then((res) => { + let blob = new Blob([res.data], { + type: "text/csv", + }); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + link.setAttribute("download", csvExtParse(filename)); + document.body.appendChild(link); + link.click(); + link.remove(); + }); +}; +export const getExoSourceFile = async (id_code, filename) => { + return exoInstance + .request({ + method: "get", + url: `/exercices/${id_code}/exo_source`, + headers: { + ...(localStorage.getItem("token") != null && { + Authorization: "Bearer " + localStorage.getItem("token"), + }), + }, + }) + .then((res) => { + let blob = new Blob([res.data], { + type: "text/x-python", + }); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + link.setAttribute("download", filename); + document.body.appendChild(link); + link.click(); + link.remove(); + }); +}; + + diff --git a/frontend/src/styles/NavBar.module.scss b/frontend/src/styles/NavBar.module.scss new file mode 100644 index 0000000..8e0302f --- /dev/null +++ b/frontend/src/styles/NavBar.module.scss @@ -0,0 +1,219 @@ +@import "./variables"; +@import "./mixins"; + +.link { + cursor: pointer; + margin: 0 10px; + color: $primary-dark; + text-decoration: none; + position: relative; + font-weight: 600; + transition: color 0.3s; + font-size: 16px; + &::before { + content: ""; + position: absolute; + width: 100%; + height: 2px; + background: currentColor; + top: 100%; + left: 0; + pointer-events: none; + transform-origin: 100% 50%; + transform: scale3d(0, 1, 1); + transition: transform 0.3s; + } + &:hover { + color: $primary; + transform: scale(1.05); + &::before { + transform-origin: 0% 50%; + transform: scale3d(1, 1, 1); + } + } +} +.selected { + font-weight: bolder; + color: $primary; + transform: scale(1.05); + &::before { + content: none; + } +} +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding: 30px 0; + //margin-left: 30px; + height: 60px; + border-bottom: 1px $border solid; + width: 100%; + gap: 10px; + height: 30px; +} +.home { + border-right: 2px solid $border; + padding-right: 16px; + margin-right: 16px; + & svg { + width: 20px; + height: 20px; + fill: $primary-dark; + transition: 0.3s; + &:hover { + fill: $primary; + } + } +} +.links { + display: flex; + align-items: center; + gap: 14px; + overflow: hidden; + flex-wrap: wrap; + height: 30px; + & li { + height: 30px; + display: flex; + align-items: center; + white-space: nowrap; + } +} +.session-links { + display: flex; + gap: 12px; + & li { + display: flex; + height: 30px; + align-items: center; + white-space: nowrap; + } +} +.auth-links { + @include down(750px) { + display: none !important; + } + & .logout{ + margin-left: 10px; + } +} +.dashboard { + gap: 2px; + display: flex; + align-items: center; +} +.logout { + height: 30px; + display: flex; + align-items: center; + cursor: pointer; + & svg { + width: 20px; + height: 20px; + fill: $primary-dark; + transition: 0.3s; + &:hover { + fill: $primary; + } + } +} +.burger { + background: 0 0; + @include up(750px) { + display: none; + } + border: none; + width: 20px; + height: 20px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + color: $primary-dark; + &:hover { + color: $primary; + } + & span { + font-size: 0; + transition: 0.2s ease-in-out; + width: 12px; + height: 2px; + background-color: currentColor; + display: block; + position: relative; + transition: 0.2s ease-in-out; + &::before, + &::after { + transition: 0.2s ease-in-out; + content: ""; + display: block; + width: 12px; + height: 2px; + background-color: currentColor; + position: relative; + } + &::after { + top: -6px; + } + &::before { + bottom: -4px; + } + } +} +.isOpen { + @include down(980px) { + & .links { + & li:nth-child(1) { + display: none; + } + display: grid; + grid-template-columns: 1fr; + align-content: center; + justify-items: center; + height: 100%; + transition: .2s; + transition: all .2s, height 0s; + background: rgba($background-dark, 0.8); + position: fixed; + top: 0; + bottom: 0; + right: 0; + left: 0; + margin: 0; + z-index: 3; + gap: 40px; + grid-template-rows: repeat(auto, min-content); + & li { + width: 200px; + display: block; + } + } + & .session-links { + display: flex; + & .auth-links { + display: flex !important; + } + z-index: 4; + position: fixed; + top: 0; + right: 20px; + gap: 5px; + & .burger { + display: block; + & span::before { + bottom: 0; + transform: rotate(-90deg); + } + & span::after { + transform: rotate(0); + top: -2px; + } + & span { + transform: rotate(135deg); + } + } + } + } +} diff --git a/frontend/src/styles/auth/changePassword.module.scss b/frontend/src/styles/auth/changePassword.module.scss new file mode 100644 index 0000000..bdaae0e --- /dev/null +++ b/frontend/src/styles/auth/changePassword.module.scss @@ -0,0 +1,20 @@ +.form{ + display: grid; + grid-template-columns: repeat(2,1fr); + grid-gap:10px; +} + +.submit{ + float: right; + margin: 0; + margin-top: 10px; + width: 200px; + padding: 0; + align-self: end; +} + +.test{ + width: 200px; + height: 200px; + background-color: red; +} \ No newline at end of file diff --git a/frontend/src/styles/auth/dashboard.module.scss b/frontend/src/styles/auth/dashboard.module.scss new file mode 100644 index 0000000..760548d --- /dev/null +++ b/frontend/src/styles/auth/dashboard.module.scss @@ -0,0 +1,12 @@ +@import '../variables'; +.danger{ + color: $secondary; +} + +.delete{ + background-color: $secondary; + color: #f8f8f8; + &:hover{ + background-color: lighten($color: $secondary, $amount: 6); + } +} \ No newline at end of file diff --git a/frontend/src/styles/auth/deleteConfirm.module.scss b/frontend/src/styles/auth/deleteConfirm.module.scss new file mode 100644 index 0000000..f354bf6 --- /dev/null +++ b/frontend/src/styles/auth/deleteConfirm.module.scss @@ -0,0 +1,34 @@ +@import '../variables'; + +.main{ + background-color:rgba( $background, 0.7); + width: 500px; + padding: 25px; + border: 3px solid $border; + border-radius: 5px; + & p { + margin: 10px 0; + opacity: .8; + } +} + +.title{ + margin-top: 0; +} + +.delete{ + width: 100%; + background-color: $secondary; + color: #f8f8f8; + transition: .3s; + font-size: 1.03em; + padding: 10px ; + height: auto; + font-weight: 800; + margin: 10px 0; + cursor: pointer; + &:hover{ + background-color: lighten($color: $secondary, $amount: 7); + } +} + diff --git a/frontend/src/styles/auth/editUser.module.scss b/frontend/src/styles/auth/editUser.module.scss new file mode 100644 index 0000000..f621cd8 --- /dev/null +++ b/frontend/src/styles/auth/editUser.module.scss @@ -0,0 +1,27 @@ +@import '../mixins'; + +.form{ + display: grid; + display: grid; + grid-template-columns: repeat(2,1fr); + gap: 8px; + @include down(840px){ + display: flex; + flex-direction: column; + } +} + +.submit{ + float: right; + margin: 0; + margin-top: 10px; + width: 200px; + padding: 0; + align-self: end; +} + +.main{ + display: flex; + flex-direction: column; +} + diff --git a/frontend/src/styles/auth/login.module.scss b/frontend/src/styles/auth/login.module.scss new file mode 100644 index 0000000..66ecf04 --- /dev/null +++ b/frontend/src/styles/auth/login.module.scss @@ -0,0 +1,10 @@ +.submit{ + width: 100%; + margin: 10px 0; +} + +.main{ + display: flex; + flex-direction: column; + gap: 10px; +} \ No newline at end of file diff --git a/frontend/src/styles/auth/loginPage.module.scss b/frontend/src/styles/auth/loginPage.module.scss new file mode 100644 index 0000000..beccc64 --- /dev/null +++ b/frontend/src/styles/auth/loginPage.module.scss @@ -0,0 +1,13 @@ +.form{ + display: flex; + flex-direction: column; +} + +.main{ + max-width: 400px; + margin: 0 auto; + & h1{ + text-align: center; + font-size: 4em; + } +} \ No newline at end of file diff --git a/frontend/src/styles/auth/loginPopup.module.scss b/frontend/src/styles/auth/loginPopup.module.scss new file mode 100644 index 0000000..2b04ffe --- /dev/null +++ b/frontend/src/styles/auth/loginPopup.module.scss @@ -0,0 +1,14 @@ +@import '../variables'; + +.main{ + background-color: rgba($background, 0.7); + padding: 25px; + border: 3px solid $border; + border-radius: 5px; + width: 600px; +} + +.title{ + margin: 0; + text-align: center; +} \ No newline at end of file diff --git a/frontend/src/styles/auth/section.module.scss b/frontend/src/styles/auth/section.module.scss new file mode 100644 index 0000000..d9d7e46 --- /dev/null +++ b/frontend/src/styles/auth/section.module.scss @@ -0,0 +1,22 @@ +@import "../variables"; +.child { + background-color: rgba($background, 0.7); + padding: 10px; + border: 2px solid $border; +} +.title { + & svg { + width: 20px; + height: 20px; + } +} +/* .main{ + margin-bottom: 50px; +} */ +.btn{ + float: right; + margin-top: 10px; + width: 200px; + + padding: 0; +} diff --git a/frontend/src/styles/exercices/Card.module.scss b/frontend/src/styles/exercices/Card.module.scss new file mode 100644 index 0000000..e7832b3 --- /dev/null +++ b/frontend/src/styles/exercices/Card.module.scss @@ -0,0 +1,265 @@ +@import "../variables"; +@import "../mixins"; + +.card { + z-index: 1; + min-height: 250px; + background-color: $background; + display: flex; + flex-direction: column; + border-radius: 2px; + cursor: pointer; + position: relative; + box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.75); + transform: translate(0, 0); + transition: 0.4s; + &:not(.card-deleted):not(.no_hover):hover { + box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.75); + @include up(800px) { + transform: translate(5px, -8px); + } + transition: transform 0.4s; + } + &:hover .card_icons { + opacity: 1; + transition: 0.4s; + } +} + + +.card-hover { + &:not(.no_hover):hover { + @extend %card-hover-effect; + & ~ .card__footer { + border: 1px solid $primary; + border-top: none; + transition: 0.4s; + } + } + border: 1px solid $border; + width: 100%; + height: 100%; + z-index: 10; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + transition: 0.4s; + &.no_hover span { + position: absolute; + top: 50%; + right: 50%; + width: 30px; + height: 30px; + transform: translate(-50%, -50%); + animation: rotation 1s infinite linear; + } +} + + +.card__body { + display: grid; + grid-template-columns: 1fr; + padding: 8%; + grid-gap: calc(1 * 8px); //gap = 2 space = 8px + margin-bottom: 10%; +} + + +.card__title { + font-size: 1.3em; + margin: 5%; + font-weight: 900; + cursor: pointer; + margin: 0; + max-width: 90%; + & p, + a { + position: relative; + margin: 0; + z-index: 15; + &:not(.no_hover):hover { + color: $primary; + transition: 0.4s; + } + } +} + +%card-hover-effect { + border: 1px solid $primary; + transition: 0.4s; +} +.card__content { + color: grey; + font-size: 0.95em; + // margin: 1% 5%; + margin-top: 0; + z-index: 3; + // padding: 1% 5%; + &:not(.no_hover):hover ~ .card-hover { + @extend %card-hover-effect; + } +} +.exemple { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 80%; +} +@keyframes rotation { + 0% { + transform: translate(0%, -50%) rotate(0deg); + } + 100% { + transform: translate(0%, -50%) rotate(360deg); + } +} +.card__footer_not_tag { + display: grid; + grid-template-columns: 85% auto; + grid-gap: 8px; +} + +.card__footer { + left: 0; + background-color: darken($color: $background, $amount: 5); + min-height: 0; + position: absolute; + bottom: 0; + width: 100%; + transition: 0.8s; + border: 1px solid darken($color: $background, $amount: 5); + margin: 0; + z-index: 100; + border-top: none; + transition: 0.4s; + transition: min-height 0.7s border 0.4s; + +} +.footer-size { + opacity: 0; + width: 100%; +} +.tag_card { + position: absolute; + min-height: 100%; + transition: 0.7s; + bottom: 0; + z-index: 102; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + & button{ + margin: 0; + } + &:hover{ + transform: none!important; + } + //animation: tagMode 10s forwards; +} +.tag--container { + display: flex; + overflow: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: $contrast $background-light; + @include color-scroll($background-light, $contrast); + padding: 8px 12px; +} + +.no_tag{ + font-style: italic; +} +.card__add_tag { + display: flex; + align-items: center; + z-index: 4; + & hr { + width: 2%; + height: 70%; + border: none; + background-color: black; + } + & p { + margin: auto; + font-size: 1.2em; + font-weight: 900; + width: 100%; + text-align: center; + transition: 0.5s; + &:hover { + transform: rotate(180deg); + } + } +} +.card-progress { + margin-top: auto; +} +.card-deleted { + opacity: 0.5; +} +.icon-container { + position: absolute; + right: 0; + top: 0; + margin: 5%; + z-index: 100; + & svg { + opacity: 0.5; + transition: 0.3s; + &:hover { + opacity: 1; + } + } +} +.registered svg { + opacity: 1; + fill: red; +} + + + +.examples{ + display: flex; + flex-direction: column; + gap: 10px; + & * { + margin: 0; + } + & p { + margin-left: 10px; + } +} + +.example_title{ + font-weight: 700; + margin-left: 0!important; +} + +.consigne{ + font-weight: 500; + text-decoration: underline; + margin-left: 5px!important; +} + + +.card__icons{ + position: absolute; + right: 0; + top: 0; + margin: 10px; + transition: .2s; + cursor: pointer; + z-index: 100; + &:hover{ + transform: scale(1.1); + } +} + +.no_hover:hover { + border: none; + transform: none; + //box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.75); +} \ No newline at end of file diff --git a/frontend/src/styles/exercices/ExercicesPage.module.scss b/frontend/src/styles/exercices/ExercicesPage.module.scss new file mode 100644 index 0000000..52bfd4d --- /dev/null +++ b/frontend/src/styles/exercices/ExercicesPage.module.scss @@ -0,0 +1,174 @@ +@import "../variables"; +@import "../mixins"; + +.exoPage__list { + width: 98%; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + grid-auto-flow: dense; + grid-gap: calc(4 * 8px); + margin: 0 auto; + & h1 { + grid-column: 1/2; + } +} + +.exoPage__list__title { + & h1 { + font-size: 3.5rem; + font-weight: bolder; + margin: 0; + width: 100%; + } + & p { + font-size: 1.1em; + width: 100%; + } + grid-column: 1/-1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + @include up(840) { + grid-column: 1 / 3; + text-align: left; + /* &.is-paginated { + grid-column: -3 / -1; + } */ + } +} + +.exoPage__heading { + width: 100%; + display: flex; + justify-content: space-between; + height: max-content; + margin: 15px 0; + @include down(840) { + flex-direction: column; + & > * { + width: 100% !important; + } + } +} + +.exoPage__heading__createExo { + display: flex; + align-items: center; + width: 50%; + + @include down(840) { + & > button { + width: 100%; + } + } + & button { + background-color: transparent; + border: 1px solid $primary; + color: $primary; + font-size: 0.9em; + width: 200px; + cursor: pointer; + &:hover { + color: $background-dark; + background-color: $primary; + } + &:disabled { + cursor: not-allowed; + display: flex; + align-items: center; + gap: 10px; + opacity: 0.7; + color: grey; + border: grey 1px solid; + justify-content: center; + &:hover { + background-color: transparent; + color: grey; + opacity: 0.7; + } + } + } +} + +.exoPage__heading__filtering { + display: flex; + flex-direction: column; + align-items: flex-end; + width: 600px; + & select { + border: none; + border-bottom-color: currentcolor; + border-bottom-style: none; + border-bottom-width: medium; + background: transparent; + color: white; + width: 100%; + float: right; + border-bottom: $border 1px solid; + padding: 10px; + transition: 0.3s ease; + font-weight: 500; + width: max-content; + &:focus { + border-bottom: $contrast 1px solid; + } + } +} + +.modal { + background-color: $background-light; + padding: 50px; + min-width: 820px; + & h1 { + font-size: 1.6em; + } + & h2 { + font-size: 1em; + margin: 0; + } +} + +.no_results { + font-style: italic; + display: flex; + align-items: center; + font-size: 1.1em; +} + +.card__squeletton { + background-color: $background; + height: 250px; + position: relative; + transform-origin: 0 60%; + overflow: hidden; + &:after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + animation: waves 1.6s linear infinite; + + background: linear-gradient(90deg, transparent, $background, $border, transparent); + transform: translateX(-100%); + } + &::before { + content: none; + opacity: 0; + } +} + +@keyframes waves { + 0% { + transform: translateX(-100%); + } + 60% { + transform: translateX(100%); + } + 100% { + transform: translateX(100%); + } +} diff --git a/frontend/src/styles/exercices/ModalCard.module.scss b/frontend/src/styles/exercices/ModalCard.module.scss new file mode 100644 index 0000000..1dc94c8 --- /dev/null +++ b/frontend/src/styles/exercices/ModalCard.module.scss @@ -0,0 +1,146 @@ +@import '../variables'; +@import '../mixins.scss'; +.main { + display: flex; + flex-direction: column; + gap: 20px; +} + +.examples { + gap: 5px; + display: flex; + flex-direction: column; + & p { + margin: 0; + opacity: 0.7; + margin-left: 20px; + } +} + +.btn{ + width: 100%; + text-align: center; +} + +.consigne{ + font-weight: 600; + text-decoration: underline; + margin-left: 10px!important; +} + +.icon_container{ + display: flex; + align-items: center; + position: absolute; + right: 0; + top: 0; + margin: 20px; + gap: 5px; + & svg{ + transition: .2s; + opacity: .4; + cursor: pointer; + &:hover{ + transform: scale(1.15); + opacity: 1; + } + } +} + +.footer{ + left: 0; + background-color: darken($color: $background, $amount: 5); + //min-width: 0; + min-height: 0; + position: absolute; + bottom: 0; + width: 100%; + transition: 0.8s; + border: 1px solid darken($color: $background, $amount: 5); + margin: 0; + z-index: 100; + border-top: none; + transition: 0.4s; + transition: min-height 0.7s border 0.4s; +} +.footer_not_tag { + display: grid; + grid-template-columns: 85% auto; + grid-gap: 8px; +} + +.tag_card{ + position: absolute; + min-height: 100%; + transition: 0.7s; + bottom: 0; + z-index: 100; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + & button{ + margin: 0; + } + z-index: 10; +} +.no_tag{ + font-style: italic; +} +.tag--container { + display: flex; + overflow: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: $contrast $background-light; + @include color-scroll($background-light, $contrast); + padding: 8px 12px; +} + + +.ex_card--add-tag { + display: flex; + align-items: center; + z-index: 4; + & hr { + width: 2%; + height: 70%; + border: none; + background-color: black; + } + & p { + margin: auto; + font-size: 1.2em; + font-weight: 900; + width: 100%; + text-align: center; + transition: 0.5s; + &:hover { + transform: rotate(180deg); + } + } +} + + +.title{ + display: flex; + align-items: flex-end; + gap: 10px; +} + +.original{ + cursor: pointer; + transition: .2s; + font-weight: 300; + color: rgb(120, 120, 120); + font-size: .8rem; + &:hover{ + color: $contrast; + font-weight: 500; + text-decoration: underline; + } +} + +.author{ + font-style: italic; +} \ No newline at end of file diff --git a/frontend/src/styles/exercices/Pagination.module.scss b/frontend/src/styles/exercices/Pagination.module.scss new file mode 100644 index 0000000..2fce31d --- /dev/null +++ b/frontend/src/styles/exercices/Pagination.module.scss @@ -0,0 +1,17 @@ +@import '../variables'; +.pagination{ + display: flex; + justify-content: center; + margin-bottom: 10%; + margin-top: 5%; +} +.pagination-page{ + border: 1px solid $border; + padding: 7px; + margin: 7px; + cursor: pointer; +} +.pagination-page-active{ + background-color: $primary; + color: $on-primary; +} \ No newline at end of file diff --git a/frontend/src/styles/exercices/exoForm.module.scss b/frontend/src/styles/exercices/exoForm.module.scss new file mode 100644 index 0000000..6d272df --- /dev/null +++ b/frontend/src/styles/exercices/exoForm.module.scss @@ -0,0 +1,101 @@ +@import '../variables.scss'; + +.main{ + background-color: $background-light; + padding: 50px; + min-width: 820px; + & h1{ + font-size: 1.6em; + } +} + +.form{ + display: flex; + flex-direction: column; + gap: 20px; + & h1{ + font-size: 1.4em; + } +} + + + +.inputfile { + width: 0.1px; + height: 0.1px; + opacity: 0; + overflow: hidden; + position: absolute; + z-index: -1; + & + label { + font-size: 1; + font-weight: 700; + color: black; + background-color: $primary; + display: inline-block; + padding: 10px; + border-radius: 5px; + white-space: nowrap; + transition:.3s; + &:hover { + background-color: $primary-dark; + } + } +} +.fileinput-container { + display: flex; + border: $primary 1px solid; + border-radius: 5px; + align-items: center; + width: max-content; +} +.filename { + max-width: 200px; + text-align: center; + min-width: 100px; + color: $primary; + font-weight: 700; + text-overflow: ellipsis; + overflow: hidden; + padding: 10px; + white-space: nowrap; + cursor: default; + display: flex; + align-items: center; + justify-content: center; + gap: 5%; + & svg { + cursor: pointer; + } +} +.inputfile + label { + cursor: pointer; /* "hand" cursor */ +} +/* .inputfile:focus + label, +.inputfile + label:hover { + background-color: $primary-dark; +} + */ +.inputfile + label svg { + width: 1em; + height: 1em; + vertical-align: middle; + fill: currentColor; + margin-top: -0.25em; + /* 4px */ + margin-right: 0.25em; + /* 4px */ +} + +.btncontainer{ + width: max-content; + & button { + width: 200px; + } +} + +.error{ + color: $red; + font-weight: 700; + margin-top: 2px; +} \ No newline at end of file diff --git a/frontend/src/styles/form/input.module.scss b/frontend/src/styles/form/input.module.scss new file mode 100644 index 0000000..f4bd3b5 --- /dev/null +++ b/frontend/src/styles/form/input.module.scss @@ -0,0 +1,114 @@ +@import "../variables"; +/* .input { + //padding: 0.4em 0.25em; + width: 100%; + background: transparent; + color: #afb5bb; + //font-size: 1.55em; + &:focus + label span { + transform: translate3d(0, -90%, 0); + } + & + label { + position: absolute; + width: 100%; + text-align: left; + pointer-events: none; + left: 0; + & span{ + transition: transform .3s; + } + } +} */ +.input-container { + position: relative; + margin-top: 10px; + width: 100%; + display: flex; + flex-direction: column; + margin: 0; +} +.input { + background-color: transparent; + border: none; + padding: 10px 10px 10px 5px; + border-bottom: 1px solid $input-border; + width: 100%; + font-size: 0.9em; + font-weight: 500; + color: white; + & ~ label { + font-size: 1em; + font-weight: normal; + position: absolute; + pointer-events: none; + left: 5px; + top: 10px; + transition: 0.3s ease all; + font-weight: 400; + color: #8e8e8e; + opacity: 0.4; + } + &:focus { + outline: none; + } + &:focus ~ label, + &:not(:placeholder-shown):valid ~ label { + top: -0.8em; + font-size: 12px; + color: $contrast; + opacity: 1; + font-weight: 600; + } + &:disabled ~ label { + color: grey; + } + &:focus ~ .bar:before { + width: 100%; + } +} + +.bar { + position: relative; + display: block; + width: 100%; + &:before { + content: ""; + height: 2px; + width: 0; + bottom: 0px; + position: absolute; + background: $contrast; + transition: 0.3s ease all; + left: 0%; + } +} +.error { + color: $red; + & input { + color: $red; + border-bottom: 1px solid $red; + &:focus ~ label, + &:not(:placeholder-shown):valid ~ label { + color: $red; + } + &:focus ~ .bar::before { + background-color: $red; + } + } +} +.password-toggler { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + right: 0; + width: 20px; + height: 20px; + cursor: pointer; +} + + +.error_msg{ + color: $red; + font-weight: 800; + margin-top:5px ; +} \ No newline at end of file diff --git a/frontend/src/styles/form/input2.module.scss b/frontend/src/styles/form/input2.module.scss new file mode 100644 index 0000000..c662986 --- /dev/null +++ b/frontend/src/styles/form/input2.module.scss @@ -0,0 +1,40 @@ +@import '../variables'; +.input{ + background-color: lighten($color: $background-dark, $amount: 5); + color: #d4dcff; + border: 1px solid $border; + padding: 12px 8px; + width: 100%; + &:focus{ + outline: none; + border: $contrast 1px solid; + box-shadow: 0 0 0 3px $background-light; + } + } + .password-toggler { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + right: 0; + width: 20px; + height: 20px; + cursor: pointer; +} +.container{ + position: relative; +} + +.main label{ + display: block; + text-transform: uppercase; + font-size: .85em; + font-weight: 400; + margin-bottom: 5px; +} + +.error_msg{ + color: $red; + font-weight: 800; + margin-top:10px ; + +} \ No newline at end of file diff --git a/frontend/src/styles/form/select.module.scss b/frontend/src/styles/form/select.module.scss new file mode 100644 index 0000000..1608cef --- /dev/null +++ b/frontend/src/styles/form/select.module.scss @@ -0,0 +1,177 @@ +@import '../variables'; + +.input { + grid-area: 1/2; + background: rgba(0, 0, 0, 0) none repeat 0px center; + border: none; + outline: currentColor none 0px; + color: white; + width: 100%; + margin: 0; + padding: 0; + font: inherit; +} + +.search_container{ + position: relative; + width: 100%; +} + + +.search_value { + display: grid; + +} + +.placeholder_container { + grid-area: 1/1/2/3; +} + +.input_container { + grid-area: 1/1/2/3; + display: inline-grid; + grid-template-columns: 0px min-content; + + &::after { + content: attr(data-value) " "; + grid-area: 1/2; + font: inherit; + white-space: pre; + min-width: 2px; + border: none; + outline: currentColor none 0px; + margin: 0; + padding: 0; + visibility: hidden; + } +} + + +.dropdown{ + background-color: $background-light; + border-radius: 5px; + padding: 5px 0; + margin-top: 5px; + position: relative; + overflow-y: auto; + max-height: 300px; +} + +.no_options{ + opacity: .5; + font-weight: thin; + width: 100%; + text-align: center; + margin: 10px 0; +} + +.search_control{ + background-color: transparent; + border-bottom: $border 1px solid; + transition: .2s; + padding: 10px; + &.is_focused{ + border-bottom: $contrast 1px solid; + } +} + + +.placeholder_container{ + color: rgb(128,128,128); +} + +.search_has_value{ + display: flex; +align-items: center; +flex-wrap: wrap; +padding: 2px 8px; +flex: 1 1 0%; +position: relative; +overflow: hidden; +gap: 2px; + row-gap: 2px; + column-gap: 2px; +gap: 5px 2px; +} + + + +.tag { + // width: max-content; + //padding: 1% 5%; + border-radius: 5px; + font-size: 0.9em; + margin: 0 5px; + display: inline-block; + height: max-content; + z-index: 4; + // background-color: $background-dark; +} +.tag-remove { + align-items: center; + border-radius: 2px; + display: flex; + padding-left: 4px; + padding-right: 4px; + box-sizing: border-box; + & svg { + display: inline-block; + fill: currentcolor; + line-height: 1; + stroke: currentcolor; + stroke-width: 0px; + } +} +.tag-label{ + border-radius: 2px; + font-size: 85%; + overflow: hidden; + padding: 3px 3px 3px 6px; + text-overflow: ellipsis; + white-space: nowrap; + box-sizing: border-box; +} + +.create_option{ + color: rgb(128,128,128); + padding: 10px; + cursor: pointer; + &:hover{ + background-color: rgba($color: rgb(128,128,128), $alpha: .1); + } + +} + +.search_control{ + display: flex; + justify-content: space-between; + overflow: hidden; +} + +.controller{ + display: flex; + align-items: center; + gap: 3px; + & hr{ + height: 100%; + } + & svg{ + color: #808080; + fill: #808080; + cursor: pointer; + transition: .3s; + &:hover{ + color: white; + fill: white; + } + } +} + +.menu_placer{ + position: absolute; + top: 100%; + width: 100%; + z-index: 10; + box-shadow: rgba(0,0,0,0.1) 0px 0px 0px 1px rgba(0,0,0,0.1) 0px 4px 11px; + +} \ No newline at end of file diff --git a/frontend/src/styles/functions.scss b/frontend/src/styles/functions.scss new file mode 100644 index 0000000..8b7767d --- /dev/null +++ b/frontend/src/styles/functions.scss @@ -0,0 +1,6 @@ +@function strip-unit($number) { + @if type-of($number) == 'number' and not unitless($number) { + @return $number / ($number * 0 + 1); + } + @return $number; +} \ No newline at end of file diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss new file mode 100644 index 0000000..518d73f --- /dev/null +++ b/frontend/src/styles/global.scss @@ -0,0 +1,295 @@ +@import './variables'; +html, +body { + padding: 0; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, + Helvetica Neue, sans-serif; + overflow: hidden; + background-color: $background; + color: darken($color: white, $amount: 15); + background: linear-gradient(to bottom left, $background-dark 30%, $background-light); + width: 100vw; + height: 100vh; +} +:root { + --container-padding: 20px; + --container-width: calc(100vw - var(--container-padding) * 2); + --navbar-height: 0px; +} +@media only screen and (min-width: 900px) { + :root { + --container-padding: 32px; + } +} +@media only screen and (min-width: 1370px) { + :root { + --container-padding: 20px; + --container-width: 1330px; + } +} +*{ + scrollbar-width: thin!important; + scrollbar-color: $contrast transparent; +} +h1{ + margin: 0; +} +%container, +.container { + box-sizing: border-box; + width: 100%; + padding-left: calc(50% - var(--container-width) / 2); + padding-right: calc(50% - var(--container-width) / 2); + height: calc(100vh - var(--navbar-height) - 10px); // - navbar margin + overflow: auto; +} +%container-margin { + margin-left: auto; + margin-right: auto; + width: var(--container-width); +} +*::-webkit-scrollbar{ + z-index: 100000; +} +a { + color: inherit; + text-decoration: none; +} +* { + box-sizing: border-box; +} +.small-text { + color: grey; + font-size: 0.95rem; + font-weight: normal; +} +@for $i from 0 through 100 { + .margin-#{$i}{ + margin: 1% * $i !important; + } + .marginl-p#{$i} { + margin-left: 1% * $i !important; + } + .marginr-p#{$i} { + margin-right: 1% * $i !important; + } + .marginb-p#{$i} { + margin-bottom: 1% * $i !important; + } + .margint-p#{$i} { + margin-top: 1% * $i !important; + } + .vh-#{$i} { + height: 1vh * $i !important; + } + .vw-#{$i} { + width: 1vw * $i !important; + } + + .width-percent-#{$i}{ + width: 1% * $i; + } + .height-percent-#{$i}{ + height: 1% * $i; + } + .border-radius-px-#{$i}{ + border-radius: 1px * $i; + } +} +@for $f from 0 through 9 { + .fontw-#{$f * 100} { + font-weight: 100 * $f; + } +} +.primary { + color: $primary; +} +.secondary { + color: $secondary; +} +p { + margin-bottom: 1%; +} +.overlay { + position: absolute; + top: 50%; + z-index: 1000; + left: 50%; + transform: translate(-50%, -50%); + height: 100vh; + width: 100vw; + background-color: black; + opacity: 0.8; + transition: opacity 0.4s; + z-index: 10; +} +.invisible { + opacity: 0 !important; + z-index: -1 !important; + transition: 0.5s; +} +.icon { + width: 20px; + height: 20px; + transition: 0.3s; + &:hover { + transform: scale(1.1); + } + color: blue; +} +.loader { + width: 30px; + height:30px; + border: 3px solid grey; + border-bottom-color: transparent; + border-radius: 50%; + animation: rotation 1s infinite linear; + display: inline-block; + box-sizing: border-box; + +} +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} +.pointer{ + cursor: pointer; +} +.loader-big{ + border: 14px solid $background-light; /* Light grey */ + border-top: 16px solid $contrast; /* Blue */ + border-radius: 50%; + width: 120px; + height: 120px; + animation: spin 1.5s linear infinite; + position: absolute; + top: 50%; + right: 50%; + transform: translate(50%, -50%); + z-index: 20; +} +@keyframes spin { + 0% { transform:translate(50%, -50%) rotate(0deg); } + 100% { transform:translate(50%, -50%) rotate(360deg); } +} +.error-msg{ + color: $red; + font-weight: 800; + font-size: .9em; + width: 100%; + &:hover{ + color:$red!important; + } +} +.exo-input-error{ + border-bottom: 1px solid $red; + margin-bottom: 1.5%; + &:focus { + border-bottom: 1px solid $red !important; + } +} +.flex-column{ + display: flex; + flex-direction: column; +} +ul{ + list-style: none; +} + +.flex-row{ + display: flex; +} + +.background{ + background-color: $background; +} + +.pointer{ + cursor: pointer; +} + +.relative{ + position: relative; +} + +.absolute{ + position: absolute; +} + +fieldset{ + border: none; +} + + +.btn { + border: none; + border-radius: 5px; + height: 38px; + font-weight: 700; + transition: 0.3s; + padding: 8px 14px; + cursor: pointer; +} + +.primary-btn{ + @extend .btn; + background-color: $primary; + &:hover{ + background-color: darken($color: $primary, $amount: 30); + } +} + +.cancel-btn { + border: none; + border-radius: 5px; + //width: 25%; + height: 38px; + font-weight: 700; + background-color: $red; + transition: 0.3s; + margin-bottom: 10px; + margin-right: 7px; + padding: 0 5%; + &:hover { + background-color: darken($color: $red, $amount: 25); + } + &:disabled:hover{ + background-color: $primary; + } +} + + +.exo-input { + background-color: inherit; + color: inherit; + //height: 30px; + padding: 5px 10px; + width: 100%; + border-radius: 5px; + font-size: 16px; + font-weight: 450; + border: none; + margin: 10px 0; + float: left; + transition: 0.3s; + border-bottom: 1px solid $border; + border-radius: 0; + &:focus { + border: none; + outline: none; + border-radius: 0; + border-bottom: 1.5px solid $contrast; + transition: 0.3s; + //box-shadow: 0 0 0 3px lighten($color: $border, $amount: 20); + } + &::placeholder { + color: lighten($color: $border, $amount: 20); + font-size: 14px; + } + +} diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss new file mode 100644 index 0000000..94d2066 --- /dev/null +++ b/frontend/src/styles/index.scss @@ -0,0 +1,5 @@ +@import './global'; + +*{ + margin: 0; +} \ No newline at end of file diff --git a/frontend/src/styles/mixins.scss b/frontend/src/styles/mixins.scss new file mode 100644 index 0000000..6498d9a --- /dev/null +++ b/frontend/src/styles/mixins.scss @@ -0,0 +1,54 @@ +@import 'functions'; +// Responsive +// ================== +@mixin up($size) { + $size: strip-unit($size); + @media (min-width: calc($size * 1px)) { + @content; + } +} +@mixin down($size) { + $size: strip-unit($size); + @media (max-width: calc($size * 1px)) { + @content; + } +} +@mixin between($down, $up) { + $down: strip-unit($down); + $up: strip-unit($up); + @media (min-width: calc($down * 1px)) and (max-width: calc($up * 1px)) { + @content; + } +} +// Espacement +// ================== +@mixin container($width, $padding) { + width: 100%; + padding-left: $padding; + padding-right: $padding; + @include up($width + 2 * $padding) { + padding-left: calc(50vw - #{$width / 2}); + padding-right: calc(50vw - #{$width / 2}); + } +} +@mixin no-scroll { + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} +@mixin color-scroll($back, $up) { + scrollbar-color: $up $back; + &::-webkit-scrollbar { + height: 5px; + + // background-color: $back; + } + &::-webkit-scrollbar-track { + background-color: $back; + } + &::-webkit-scrollbar-thumb { + background-color: $up; + } +} + diff --git a/frontend/src/styles/modal.module.scss b/frontend/src/styles/modal.module.scss new file mode 100644 index 0000000..9d3c203 --- /dev/null +++ b/frontend/src/styles/modal.module.scss @@ -0,0 +1,54 @@ +@import './mixins'; + +.modal { + z-index: 2000; + position: fixed; + visibility: hidden; + @include down(840) { + top: 0; + width: 100%; + height: 100%; + right: 0; + left: 0; + bottom: 0; + } + @include up(840) { + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); + } +} +.md-effect .md-content { + min-height: 1px; + transform: scale(0.7); + opacity: 0; + transition: all 0.1s; +} + +.visible { + visibility: visible; + + & ~ .overlay { + opacity: 1; + visibility: visible; + } + + & .md-content{ + opacity: 1; + transform: scale(1); + } +} + + + +.overlay{ + background-color: rgba($color: #000000, $alpha: .8); + transition: .5s; + position: fixed; + width: 100%; + height: 100%; top: 0; + left: 0; + opacity: 0; + visibility: hidden; + z-index: 10; +} diff --git a/frontend/src/styles/notification.module.scss b/frontend/src/styles/notification.module.scss new file mode 100644 index 0000000..cd49575 --- /dev/null +++ b/frontend/src/styles/notification.module.scss @@ -0,0 +1,102 @@ +@import './variables.scss'; + + + +.notif{ + width: 375px; + animation: pop .3s forwards; + margin: 10px 0; + min-height: 50px; + display: flex; + flex-direction: column; + background-color: $background!important; + position: relative; + cursor: pointer; + opacity: 1; + transition: opacity .3s; + &:hover{ + opacity: 0.8!important; + } + &::after{ + content: ''; + width: 2%; + height: 3px; + background-color: blue; + position: absolute; + bottom: 0; + right: 0; + animation: progress 3.5s forwards; + } + &::before{ + content: ''; + width: 100%; + height: 3px; + background-color: $background-light; + position: absolute; + bottom: 0; + right: 0; + } +} +.notif-content{ + display: flex; + padding: 10px; +} + +.notif-text{ + flex-grow: 10; +} +.notif-title{ + display: flex; + align-items: center; + font-weight: bold; + font-size: 1.05em; +} + +.icon_container{ + display: flex; + align-items: center; + margin: 10px; + margin-right: 20px; //Parce que à gauche il y a 10px de padding en plus +} + + +@keyframes progress { + from{width: 100%;} + to{width: 0;} +} +.notif-msg{ + margin-left: 0px; + font-size: .9em; + opacity: .8; + & svg{ + margin-inline-end: 8px; + opacity: 0; + } +} +.notif-success::after{ + + background-color: $green; + +} +.notif-error::after{ + + background-color: $red; + +} +.notif-warning::after{ + + background-color: $primary; + +} +.notif-info::after{ + + background-color: $contrast; + +} + + +.dissmissed{ + opacity: 0; + transform: translateX(110px); + transition: .3s; +} diff --git a/frontend/src/styles/variables.scss b/frontend/src/styles/variables.scss new file mode 100644 index 0000000..218f289 --- /dev/null +++ b/frontend/src/styles/variables.scss @@ -0,0 +1,22 @@ +$primary: #FCBF49; +$primary-dark: #f08336; +$on-primary: #080808; +$secondary: #DF2935; +$secondary-dark: #c40e21; +$on-secondary: #080808; +$contrast: #5396e7; +$input-border: #64619f; +$border: #181553; + +$background: #1d1a5a; +$background-light: #1a0f7a; +$background-dark: #0D0221; +$red: rgb(255, 79, 100); +$green: #41cf7c; +$rouge: #a6333f; +$vert: #41cf7c; +$bleu: #045aff; +$blanc: white; +$orange: orange; +$marron: brown; +$dark-green: #00712c; \ No newline at end of file diff --git a/frontend/src/types/auth.type.ts b/frontend/src/types/auth.type.ts new file mode 100644 index 0000000..ab6becf --- /dev/null +++ b/frontend/src/types/auth.type.ts @@ -0,0 +1,12 @@ +export type Room = { + name: string; + id_code: string; + owner: boolean; +}; +export type User = { + email: string; + username: string; + firstname: string; + name: string; + rooms: Array; +}; \ No newline at end of file diff --git a/frontend/src/types/exo.type.ts b/frontend/src/types/exo.type.ts new file mode 100644 index 0000000..b15d7b9 --- /dev/null +++ b/frontend/src/types/exo.type.ts @@ -0,0 +1,37 @@ +export type Tag = { + label: string; + color: string; + id_code: string; +}; + +export type ExerciceShort = { + id_code: string; + name: string; + consigne: string; + pdfSupport: boolean; + csvSupport: boolean; + webSupport: boolean; + tags: Array; + + examples: { type: string; data: Array<{ calcul: string }> }; +}; + +export type Author = { + username: string +} + +export type Exercice = { + id_code: string; + name: string; + consigne: string; + pdfSupport: boolean; + csvSupport: boolean; + webSupport: boolean; + tags: Array; + is_author: boolean; + examples: { type: string; data: Array<{ calcul: string }> }; + private: boolean, + exo_source_name: string + author: Author, + origin: {id_code: string} | null +}; diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js new file mode 100644 index 0000000..d16e7a9 --- /dev/null +++ b/frontend/src/utils/utils.js @@ -0,0 +1,130 @@ + export const parseClassName = (classNames) => { + // Ajoute des espaces quand plusieurs classes + return classNames.join(" "); + }; +export const isEmpty = (value) => { + return ( + value === undefined || + value === null || + (typeof value === "object" && Object.keys(value).length === 0) || + (typeof value === "string" && value.trim().length === 0) + ); +}; + +export const isBrowser = typeof window !== "undefined"; + +export const parseTimer = (s) => { + var min = parseInt(s / 60); + var sec = s - min * 60; + sec = (String(sec).length == 1 ? "0" : "") + sec; + min = (String(min).length == 1 ? "0" : "") + min; + return `${min} : ${sec}`; +}; +export const colors = { + primary: "#FCBF49", + secondary: "#DF2935", + red: "rgb(255, 79, 100)", +}; +function getCookie(name) { + if (isBrowser) { + let cookieValue = null; + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) === name + "=") { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } +} +export const csrftoken = getCookie("csrftoken"); +export const color = [ + // "blanc", + "marron", + "violet", + "jaune", + "orange", + "bleu", + "vert", + "noir", + "rouge", + "rose", +]; +export const colorCode = { + //blanc: "rgb(255,255,255)", + bleu: "rgb(51,123,255)", + vert: "rgb(0,204,0)", + rouge: "rgb(255,0,0)", + marron: "rgb(153,76,0)", + violet: "rgb(204,0,204)", + jaune: "rgb(255,255,0)", + orange: "rgb(255,128,0)", + noir: "rgb(10,10,10)", + rose: "rgb(255,102,255)", + blanc: "rgb(240,240,240)", + blanche: "rgb(240,240,240)", +}; +export const csvExtParse = (name) => { + var nameSplited = name.split("."); + if (nameSplited.length - 1 != 0) { + var ext = nameSplited[nameSplited.length - 1]; + + if (ext == "csv") { + return name; + } else + return nameSplited.slice(0, nameSplited.length - 1).join(".") + ".csv"; + } else return name + ".csv"; +}; +export const filterInAnotherArray = (arr1, arr2) => { + const filtered = arr1.filter((el) => { + return arr2.indexOf(el) !== -1; + }); + return filtered; +}; +export const filterNotInAnotherArray = (arr1, arr2) => { + const filtered = arr1.filter((el) => { + return arr2.indexOf(el) === -1; + }); + return filtered; +}; +export function countOccurences(tab, value) { + var result = 0; + tab.forEach(function (elem) { + if (elem == value) { + result++; + } + }); + return result; +} +export const parseDate = (date) => { + return `${(String(date.getDate()).length == 1 ? "0" : "") + date.getDate()}/${ + (String(date.getMonth()).length == 1 ? "0" : "") + date.getMonth() + }/${date.getFullYear()} à ${ + (String(date.getHours()).length == 1 ? "0" : "") + date.getHours() + }:${(String(date.getMinutes()).length == 1 ? "0" : "") + date.getMinutes()}`; +}; + + +export const getColorCode = (colorStr) => { + var ifColor = color.find((e) => colorStr.toLowerCase().includes(e)); + if (ifColor != undefined) { + var colorCodeGot = ""; + var title_list = colorStr.split(" "); + var colorInTitle = title_list.map((t) => { + if (color.find((e) => t.toLowerCase().includes(e)) != undefined) { + colorCodeGot = + colorCode[color.find((e) => t.toLowerCase().includes(e))]; + return t; + } else return t; + }); + return colorCodeGot; + } else { + const random = Math.floor(Math.random() * color.length); + return colorCode[color[random]]; + } +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..ba2a0c1 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "plugins": [{ "name": "typescript-plugin-css-modules" }], + "noImplicitAny": false + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9ff59a1 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + plugins: [solidPlugin()], + server: { + port: 3000, + }, + build: { + target: 'esnext', + }, +});