diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e47202f0..213f3c74 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: Lint +name: Code Style on: push: @@ -43,4 +43,62 @@ jobs: - name: Run Backend linter run: autopep8 -rd --exit-code backend + + pytest: + name: Unit Testing + runs-on: self-hosted + env: + DATABASE_URI: "${{ secrets.POSTGRES_CONNECTION }}" + FRONTEND_URL: "https://localhost:8080" + CAS_SERVER_URL: "https://login.ugent.be" + SECRET_KEY: "test" + ALGORITHM: "HS256" + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: Install dependencies + working-directory: backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Initialize Database + working-directory: backend + run: | + alembic upgrade head + - name: Test with pytest + working-directory: backend + run: | + pip install pytest pytest-cov pytest-html pytest-sugar pytest-json-report + py.test -v --cov --html=../reports/pytest/report.html + - name: Archive pytest coverage results + uses: actions/upload-artifact@v1 + with: + name: pytest-coverage-report + path: reports/pytest/ + - name: clean up alembic + working-directory: backend + if: always() + run: alembic downgrade base + pyright: + name: Pyright + runs-on: self-hosted + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: Install dependencies + working-directory: backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyright + - name: run pyright + working-directory: backend + run: | + pyright diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d3994fff --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.coverage +**/reports/ diff --git a/backend/.flake8 b/backend/.flake8 index 4be9676a..f88c7c94 100644 --- a/backend/.flake8 +++ b/backend/.flake8 @@ -2,3 +2,4 @@ extend-ignore = E203 exclude = .git,__pycache__,venv indent-size = 4 +max-line-length = 88 diff --git a/backend/.gitignore b/backend/.gitignore index 6995457d..92bdbae7 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,7 @@ __pycache__ *venv* + +*.db +config.yml +.env +.coverage diff --git a/backend/README.md b/backend/README.md index 46181679..3f603b19 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,23 +1,26 @@ -## Run the backend api +# Backend API -### Setup +## Running the API -#### In this directy execute the following command to create a python environment: +### Setup ```sh +# Create a python virtual environment python -m venv venv -``` - -#### Activate the environment: - -```sh +# Activate the environment source venv/bin/activate +# Install dependencies +pip install -r requirements.txt ``` -#### Install the dependencies: +#### Create a `.env` file with following content -```sh -pip install -r requirements.txt +```yml +FRONTEND_URL="https://localhost:8080" +CAS_SERVER_URL="https://login.ugent.be" +DATABASE_URI="database connection string: postgresql://..., see discord..." +SECRET_KEY="" # e.g. generate with `openssl rand -hex 32` +ALGORITHM="HS256" # algorithm used to sign JWT tokens ``` ### Usage @@ -34,10 +37,36 @@ source venv/bin/activate ./run.sh ``` -It will start a local development server on port `8000` +This will start a local development server on port `5173` + +## The API + +## Login + +Authentication happens with the use of CAS. The client can ask where it can find +the CAS server with the `/api/authority` endpoint. A ticket then can be obtained +via `?service=`. The CAS server will redirect to +`?ticket=` after authentication. Once the client is +authenticated, further authorization happens with [JWT](https://jwt.io/). To +obtain this token, a `POST` request has to be made to `/api/token/`, with the +CAS ticket `` and the ``. The redirect url is needed to +verify the ticket. If the ticket is valid, a webtoken will be returned. To +authorize each request, add the token in the `Authorization` header. + +## Developing #### To format the python code in place to conform to PEP 8 style: ```sh autopep8 -ri . ``` + +## Testing + +You can add tests by creating `test_*` files and `test_*` functions under `tests` directory. + +### Run the tests (in the virtual environment): + +```sh +pytest -v +``` diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 00000000..ba9066c1 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,113 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 00000000..ce130e8f --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,102 @@ +from src.user.models import Base as UserBase +from src.subject.models import Base as SubjectBase +from src.project.models import Base as ProjectBase +from src.group.models import Base as GroupBase +from src.submission.models import Base as SubmissionBase +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from sqlalchemy import MetaData + +from alembic import context +from src import config as c + + +# Calculate the path based on the location of the env.py file +d = os.path.dirname +parent_dir = d(d(os.path.abspath(__file__))) +sys.path.append(parent_dir) + +# Import the Base from each of your model submodules + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option('sqlalchemy.url', c.CONFIG.database_uri) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +combined_metadata = MetaData() +for base in [ProjectBase, SubjectBase, UserBase, GroupBase, SubmissionBase]: + for table in base.metadata.tables.values(): + combined_metadata._add_table(table.name, table.schema, table) + +target_metadata = combined_metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 00000000..fbc4b07d --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/433f14a588c0_init.py b/backend/alembic/versions/433f14a588c0_init.py new file mode 100644 index 00000000..843f7db5 --- /dev/null +++ b/backend/alembic/versions/433f14a588c0_init.py @@ -0,0 +1,89 @@ +"""init + +Revision ID: 433f14a588c0 +Revises: +Create Date: 2024-03-14 13:27:44.612732 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '433f14a588c0' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('subject', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('website_user', + sa.Column('uid', sa.String(), nullable=False), + sa.Column('given_name', sa.String(), nullable=False), + sa.Column('mail', sa.String(), nullable=False), + sa.Column('is_admin', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('uid') + ) + op.create_table('project', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('deadline', sa.DateTime( + timezone=True), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('subject_id', sa.Integer(), nullable=True), + sa.Column('description', sa.String(), nullable=True), + sa.Column('enroll_deadline', sa.DateTime( + timezone=True), nullable=True), + sa.CheckConstraint('deadline >= CURRENT_DATE', + name='deadline_check'), + sa.ForeignKeyConstraint( + ['subject_id'], ['subject.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('student_subject', + sa.Column('uid', sa.String(), nullable=True), + sa.Column('subject_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['subject_id'], ['subject.id'], ), + sa.ForeignKeyConstraint(['uid'], ['website_user.uid'], ) + ) + op.create_table('teacher_subject', + sa.Column('uid', sa.String(), nullable=True), + sa.Column('subject_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['subject_id'], ['subject.id'], ), + sa.ForeignKeyConstraint(['uid'], ['website_user.uid'], ) + ) + op.create_table('team', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('team_name', sa.String(), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ['project_id'], ['project.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('student_group', + sa.Column('uid', sa.String(), nullable=True), + sa.Column('team_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['team_id'], ['team.id'], ), + sa.ForeignKeyConstraint(['uid'], ['website_user.uid'], ) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('student_group') + op.drop_table('team') + op.drop_table('teacher_subject') + op.drop_table('student_subject') + op.drop_table('project') + op.drop_table('website_user') + op.drop_table('subject') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/76859289ea2d_add_submission.py b/backend/alembic/versions/76859289ea2d_add_submission.py new file mode 100644 index 00000000..d0f2b6d5 --- /dev/null +++ b/backend/alembic/versions/76859289ea2d_add_submission.py @@ -0,0 +1,42 @@ +"""add submission + +Revision ID: 76859289ea2d +Revises: c93f08ebb01e +Create Date: 2024-03-14 20:42:17.075566 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '76859289ea2d' +down_revision: Union[str, None] = 'c93f08ebb01e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('submission', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('date', sa.DateTime(), nullable=False), + sa.Column('status', sa.Enum('InProgress', 'Accepted', + 'Denied', name='status'), nullable=False), + sa.Column('group_id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ['group_id'], ['team.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint( + ['project_id'], ['project.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('submission') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/c93f08ebb01e_add_users.py b/backend/alembic/versions/c93f08ebb01e_add_users.py new file mode 100644 index 00000000..99e76079 --- /dev/null +++ b/backend/alembic/versions/c93f08ebb01e_add_users.py @@ -0,0 +1,30 @@ +"""add users + +Revision ID: c93f08ebb01e +Revises: 433f14a588c0 +Create Date: 2024-03-14 15:34:33.928340 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c93f08ebb01e' +down_revision: Union[str, None] = '433f14a588c0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app.py b/backend/app.py deleted file mode 100644 index 39c569ab..00000000 --- a/backend/app.py +++ /dev/null @@ -1,8 +0,0 @@ -from fastapi import FastAPI - -app = FastAPI() - - -@app.get("/api") -async def root(): - return {"message": "Hello World"} diff --git a/backend/pyrightconfig.json b/backend/pyrightconfig.json new file mode 100644 index 00000000..0d317f79 --- /dev/null +++ b/backend/pyrightconfig.json @@ -0,0 +1,6 @@ +{ + "include": [ + "src", + "tests" + ] +} diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 00000000..c24fe5bb --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +filterwarnings = + ignore::DeprecationWarning diff --git a/backend/requirements.txt b/backend/requirements.txt index d9c14f29..11a0d389 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,20 +1,56 @@ +alembic==1.13.1 annotated-types==0.6.0 anyio==4.3.0 +async-timeout==4.0.3 +asyncpg==0.29.0 +attrs==23.1.0 autopep8==2.0.4 +cattrs==23.1.2 +certifi==2024.2.2 +cffi==1.16.0 +charset-normalizer==3.3.2 click==8.1.7 +cryptography==42.0.5 +exceptiongroup==1.1.2 fastapi==0.109.2 +greenlet==3.0.3 h11==0.14.0 +httpcore==1.0.4 httptools==0.6.1 +httpx==0.27.0 idna==3.6 +iniconfig==2.0.0 +itsdangerous==2.1.2 +lsprotocol==2023.0.0a2 +lxml==5.1.0 +Mako==1.3.2 +MarkupSafe==2.1.5 +nodeenv==1.8.0 +packaging==24.0 +pluggy==1.4.0 +psycopg-binary==3.1.18 +psycopg-pool==3.2.1 +psycopg2-binary==2.9.9 pycodestyle==2.11.1 +pycparser==2.21 pydantic==2.6.1 pydantic_core==2.16.2 +pygls==1.0.1 +PyJWT==2.8.0 +pyright==1.1.352 +pytest==8.1.1 +pytest-asyncio==0.23.5.post1 +python-cas==1.6.0 python-dotenv==1.0.1 PyYAML==6.0.1 +requests==2.31.0 +six==1.16.0 sniffio==1.3.0 +SQLAlchemy==2.0.27 starlette==0.36.3 -typing_extensions==4.9.0 +typeguard==2.13.3 +typing_extensions==4.10.0 +urllib3==2.2.1 uvicorn==0.27.1 -uvloop==0.19.0 watchfiles==0.21.0 websockets==12.0 diff --git a/backend/run.sh b/backend/run.sh index 91f53fc7..3798c619 100755 --- a/backend/run.sh +++ b/backend/run.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -uvicorn app:app --reload +uvicorn src.main:app --reload --port 5173 diff --git a/backend/selab_script.sql b/backend/selab_script.sql index e1cda129..59cc5663 100644 --- a/backend/selab_script.sql +++ b/backend/selab_script.sql @@ -1,16 +1,18 @@ DROP TRIGGER IF EXISTS trg_check_submission_before_deadline ON submission; +DROP TRIGGER IF EXISTS trg_check_team_size_before_joining ON student_group; + DROP FUNCTION IF EXISTS check_submission_before_deadline(); +DROP FUNCTION IF EXISTS check_team_size_before_joining(); DROP TABLE IF EXISTS submission CASCADE; DROP TABLE IF EXISTS status CASCADE; DROP TABLE IF EXISTS project CASCADE; -DROP TABLE IF EXISTS student_group CASCADE; -DROP TABLE IF EXISTS team CASCADE; -DROP TABLE IF EXISTS student_subject CASCADE; +DROP TABLE IF EXISTS student_team CASCADE; +DROP TABLE IF EXISTS file CASCADE; DROP TABLE IF EXISTS teacher_subject CASCADE; +DROP TABLE IF EXISTS student_subject CASCADE; DROP TABLE IF EXISTS subject CASCADE; DROP TABLE IF EXISTS website_user CASCADE; -DROP TABLE IF EXISTS file CASCADE; CREATE TABLE website_user ( uid TEXT PRIMARY KEY, @@ -46,10 +48,14 @@ CREATE TABLE project ( name TEXT NOT NULL, subject_id BIGSERIAL NOT NULL, description TEXT, - FOREIGN KEY (subject_id) REFERENCES subject(id) ON DELETE SET NULL + enroll_deadline DATE, + FOREIGN KEY (subject_id) REFERENCES subject(id) ON DELETE SET NULL, + max_team_size INT NOT NULL DEFAULT 4, -- Added column for maximum team size + FOREIGN KEY (subject_id) REFERENCES subject(id) ON DELETE SET NULL, + CONSTRAINT deadline_check CHECK (deadline >= CURRENT_DATE) ); -CREATE TABLE team ( +CREATE TABLE group ( id BIGSERIAL PRIMARY KEY, team_name TEXT NOT NULL, score BIGINT NOT NULL, @@ -58,12 +64,12 @@ CREATE TABLE team ( CONSTRAINT score_check CHECK (score BETWEEN 0 AND 20) ); -CREATE TABLE student_group ( +CREATE TABLE student_team ( uid TEXT NOT NULL, team_id BIGINT NOT NULL, PRIMARY KEY (uid, team_id), FOREIGN KEY (uid) REFERENCES website_user(uid) ON DELETE CASCADE, - FOREIGN KEY (team_id) REFERENCES team(id) ON DELETE CASCADE + FOREIGN KEY (group_id) REFERENCES group(id) ON DELETE CASCADE ); CREATE TABLE status ( @@ -78,10 +84,10 @@ INSERT INTO status (status_name) VALUES ('Denied'); CREATE TABLE submission ( id BIGSERIAL PRIMARY KEY, date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - team_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, project_id BIGINT NOT NULL, status_id BIGINT NOT NULL DEFAULT 1, -- Default to 'InProgress' - FOREIGN KEY (team_id) REFERENCES team(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES group(id) ON DELETE CASCADE, FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE, FOREIGN KEY (status_id) REFERENCES status(id) ON DELETE RESTRICT ); @@ -94,10 +100,7 @@ CREATE TABLE file ( FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE SET NULL ); --- Constraints and checks -ALTER TABLE project ADD CONSTRAINT deadline_check CHECK (deadline >= CURRENT_DATE); - --- Trigger to prevent submissions after the project deadline +-- Function to check submission deadline CREATE OR REPLACE FUNCTION check_submission_before_deadline() RETURNS TRIGGER AS $$ DECLARE @@ -117,3 +120,26 @@ CREATE TRIGGER trg_check_submission_before_deadline BEFORE INSERT ON submission FOR EACH ROW EXECUTE FUNCTION check_submission_before_deadline(); + +-- Function to check team size before adding a new member +CREATE OR REPLACE FUNCTION check_team_size_before_joining() +RETURNS TRIGGER AS $$ +DECLARE + current_team_size INT; + max_size INT; +BEGIN + SELECT COUNT(*) INTO current_team_size FROM student_group WHERE team_id = NEW.team_id; + SELECT max_team_size INTO max_size FROM project JOIN team ON team.project_id = project.id WHERE team.id = NEW.team_id; + + IF current_team_size >= max_size THEN + RAISE EXCEPTION 'The team is already at its maximum capacity of % members.', max_size; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_check_team_size_before_joining +BEFORE INSERT ON student_group +FOR EACH ROW +EXECUTE FUNCTION check_team_size_before_joining(); diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/auth/dependencies.py b/backend/src/auth/dependencies.py new file mode 100644 index 00000000..5f4f4adc --- /dev/null +++ b/backend/src/auth/dependencies.py @@ -0,0 +1,50 @@ +import jwt +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession +from src.config import CONFIG +from src.dependencies import get_async_db +import src.user.service as user_service + +from .exceptions import UnAuthenticated + + +def jwt_token_validation( + credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), +) -> str: + """ + Verify the JWT token and return the user_id + Args: + credentials (HTTPAuthorizationCredentials): The credentials from the request + Returns: + str: The user_id + Raises: + UnAuthenticated: If the token is invalid or expired + """ + try: + payload = jwt.decode( + credentials.credentials, + CONFIG.secret_key, + algorithms=[CONFIG.algorithm], + options={"require": ["exp", "sub"], "verify_signature": True}, + ) + user_id = payload["sub"] + return user_id + except (jwt.ExpiredSignatureError, jwt.MissingRequiredClaimError): + # Token is expired or no expiration time is set + raise UnAuthenticated() + except jwt.InvalidTokenError: + # Token is invalid + raise UnAuthenticated() + + +async def authentication_validation( + user_id: str = Depends(jwt_token_validation), + db: AsyncSession = Depends(get_async_db), +): + """ + Verify if the user is authenticated + """ + user = await user_service.get_by_id(db, user_id) + if not user: + raise UnAuthenticated() diff --git a/backend/src/auth/exceptions.py b/backend/src/auth/exceptions.py new file mode 100644 index 00000000..a28090d5 --- /dev/null +++ b/backend/src/auth/exceptions.py @@ -0,0 +1,13 @@ +from fastapi import HTTPException, status + + +class NotAuthorized(HTTPException): + def __init__(self, detail: str = "Not authorized"): + """Raised when user is not privileged enough to do this action""" + super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + + +class UnAuthenticated(HTTPException): + def __init__(self, detail: str = "Login required"): + """Raised when user not logged in""" + super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py new file mode 100644 index 00000000..09132922 --- /dev/null +++ b/backend/src/auth/router.py @@ -0,0 +1,73 @@ +from sqlalchemy.ext.asyncio import AsyncSession +import src.user.service as user_service +from cas import CASClient +from fastapi import APIRouter, Depends, Request +from fastapi.responses import RedirectResponse +from src import config +from src.auth.schemas import Authority, Token, TokenRequest +from src.dependencies import get_async_db +from src.user.schemas import UserCreate + +from .exceptions import UnAuthenticated +from .utils import create_jwt_token + +router = APIRouter( + prefix="/api/auth", tags=["auth"], responses={404: {"description": "Not Found"}} +) + +cas_client = CASClient( + version=2, + server_url=f"{config.CONFIG.cas_server_url}", +) + + +@router.get("/authority") +def authority() -> Authority: + """ + Get CAS authority + """ + return Authority(method="cas", authority=config.CONFIG.cas_server_url) + + +@router.post("/token") +async def token( + request: Request, + token_request: TokenRequest, + db: AsyncSession = Depends(get_async_db), +) -> Token: + """ + Get JWT token from CAS ticket + """ + # No ticket provided + if not token_request.ticket: + raise UnAuthenticated(detail="No ticket provided") + + cas_client.service_url = f"{request.headers.get('origin')}{token_request.returnUrl}" + user, attributes, _ = cas_client.verify_ticket(token_request.ticket) + + # Invalid ticket + if not user or not attributes: + raise UnAuthenticated(detail="Invalid CAS ticket") + # Create user if not exists + if not await user_service.get_by_id(db, attributes["uid"]): + await user_service.create_user( + db, + UserCreate( + given_name=attributes["givenname"], + uid=attributes["uid"], + mail=attributes["mail"], + ), + ) + + # Create JWT token + jwt_token = create_jwt_token(attributes["uid"]) + return jwt_token + + +@router.get("/logout") +async def logout() -> RedirectResponse: + """ + Logout from CAS + """ + cas_logout_url = cas_client.get_logout_url() + return RedirectResponse(cas_logout_url) diff --git a/backend/src/auth/schemas.py b/backend/src/auth/schemas.py new file mode 100644 index 00000000..73f29f07 --- /dev/null +++ b/backend/src/auth/schemas.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class Token(BaseModel): + token: str + token_type: str + + +class TokenRequest(BaseModel): + ticket: str + returnUrl: str + + +class Authority(BaseModel): + method: str + authority: str diff --git a/backend/src/auth/utils.py b/backend/src/auth/utils.py new file mode 100644 index 00000000..dcc11da8 --- /dev/null +++ b/backend/src/auth/utils.py @@ -0,0 +1,18 @@ +import jwt +from src import config +from src.auth.schemas import Token +from datetime import datetime, timedelta, timezone + + +def create_jwt_token(user_id: str) -> Token: + now = datetime.now(timezone.utc) + payload = { + "sub": user_id, + "iat": now, + # TODO: don't hardcode this + "exp": now + timedelta(weeks=1), + } + token = jwt.encode( + payload, config.CONFIG.secret_key, algorithm=config.CONFIG.algorithm + ) + return Token(token=token, token_type="bearer") diff --git a/backend/src/config.py b/backend/src/config.py new file mode 100644 index 00000000..050bf48c --- /dev/null +++ b/backend/src/config.py @@ -0,0 +1,36 @@ +import os +from dataclasses import dataclass + +from dotenv import load_dotenv + +load_dotenv() + + +@dataclass +class Config: + frontend_url: str + cas_server_url: str + database_uri: str + secret_key: str + algorithm: str + + +env = { + "frontend_url": os.getenv("FRONTEND_URL", ""), + "cas_server_url": os.getenv("CAS_SERVER_URL", ""), + "database_uri": os.getenv("DATABASE_URI", ""), + "secret_key": os.getenv("SECRET_KEY", ""), + "algorithm": os.getenv("ALGORITHM", ""), +} + +for key, value in env.items(): + if value == "": + raise ValueError(f"Environment variable {key} is not set") + +CONFIG = Config( + frontend_url=env["frontend_url"], + cas_server_url=env["cas_server_url"], + database_uri=env["database_uri"], + secret_key=env["secret_key"], + algorithm=env["algorithm"], +) diff --git a/backend/src/database.py b/backend/src/database.py new file mode 100644 index 00000000..ebb658ff --- /dev/null +++ b/backend/src/database.py @@ -0,0 +1,18 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.orm import declarative_base + +from src import config + +SQLALCHEMY_DATABASE_URL = config.CONFIG.database_uri + +engine = create_engine(SQLALCHEMY_DATABASE_URL) +async_engine = create_async_engine( + SQLALCHEMY_DATABASE_URL[: len("postgresql")] + + "+asyncpg" + + SQLALCHEMY_DATABASE_URL[len("postgresql"):] +) + +AsyncSessionLocal = async_sessionmaker(async_engine, autoflush=False) + +Base = declarative_base() diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py new file mode 100644 index 00000000..38d07fdd --- /dev/null +++ b/backend/src/dependencies.py @@ -0,0 +1,14 @@ +from typing import Any, AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession + +from .database import AsyncSessionLocal + + +async def get_async_db() -> AsyncGenerator[AsyncSession, Any]: + """Creates new async database session per request, which is closed afterwards""" + db = AsyncSessionLocal() + try: + yield db + finally: + await db.close() diff --git a/backend/src/group/dependencies.py b/backend/src/group/dependencies.py new file mode 100644 index 00000000..6a381641 --- /dev/null +++ b/backend/src/group/dependencies.py @@ -0,0 +1,54 @@ +from typing import Sequence + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from src.dependencies import get_async_db +from src.group.schemas import Group, GroupList +from src.user.dependencies import get_authenticated_user +from src.user.schemas import User + +from . import service +from .exceptions import AlreadyInGroup, GroupNotFound + + +async def retrieve_group( + group_id: int, db: AsyncSession = Depends(get_async_db) +) -> Group: + group = await service.get_group_by_id(db, group_id) + if not group: + raise GroupNotFound() + return group + + +async def retrieve_groups_by_user( + user: User, db: AsyncSession = Depends(get_async_db) +) -> Sequence[Group]: + return await service.get_groups_by_user(db, user.uid) + + +async def retrieve_groups_by_project( + project_id: int, db: AsyncSession = Depends(get_async_db) +) -> GroupList: + groups = await service.get_groups_by_project(db, project_id) + return GroupList(groups=groups) + + +async def is_authorized_to_leave( + group_id: int, + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +): + groups = await service.get_groups_by_user(db, user.uid) + if not any(group.id == group_id for group in groups): + raise GroupNotFound() + + +# TODO: take enroll_date into consideration +async def is_authorized_to_join( + group_id: int, + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +): + groups = await service.get_groups_by_user(db, user.uid) + if any(group.id == group_id for group in groups): + raise AlreadyInGroup() diff --git a/backend/src/group/exceptions.py b/backend/src/group/exceptions.py new file mode 100644 index 00000000..f746f437 --- /dev/null +++ b/backend/src/group/exceptions.py @@ -0,0 +1,13 @@ +from fastapi import HTTPException + + +class GroupNotFound(HTTPException): + def __init__(self): + """Raised when group is not found in database""" + super().__init__(status_code=404, detail="Group not found") + + +class AlreadyInGroup(HTTPException): + def __init__(self): + """Raised when person is already in group""" + super().__init__(status_code=403, detail="Already in Group") diff --git a/backend/src/group/models.py b/backend/src/group/models.py new file mode 100644 index 00000000..e4191ccf --- /dev/null +++ b/backend/src/group/models.py @@ -0,0 +1,22 @@ +from sqlalchemy import Column, ForeignKey, Table +from sqlalchemy.orm import Mapped, mapped_column +from src.database import Base + +# TODO: set right primary keys +StudentGroup = Table( + "student_group", + Base.metadata, + Column("uid", ForeignKey("website_user.uid")), + Column("team_id", ForeignKey("team.id")), +) + + +class Group(Base): + __tablename__ = "team" + + id: Mapped[int] = mapped_column(primary_key=True) + team_name: Mapped[str] = mapped_column(nullable=False) + score: Mapped[int] = mapped_column(nullable=False) + project_id: Mapped[int] = mapped_column( + ForeignKey("project.id", ondelete="CASCADE"), nullable=False + ) diff --git a/backend/src/group/router.py b/backend/src/group/router.py new file mode 100644 index 00000000..69f1cc0f --- /dev/null +++ b/backend/src/group/router.py @@ -0,0 +1,74 @@ +from typing import Sequence +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from src.auth.dependencies import authentication_validation +from src.dependencies import get_async_db +from src.group.dependencies import ( + is_authorized_to_join, + is_authorized_to_leave, + retrieve_group, + retrieve_groups_by_project, +) +from src.group.schemas import Group, GroupCreate +from src.submission.dependencies import group_id_validation +from src.submission.schemas import Submission +from src.submission.service import get_submissions_by_group +from src.user.dependencies import get_authenticated_user +from src.user.schemas import User + +from . import service + +router = APIRouter( + prefix="/api/groups", + tags=["groups"], + responses={404: {"description": "Not found"}}, + dependencies=[Depends(authentication_validation)], +) + + +@router.get("/") +async def get_groups(groups: list[Group] = Depends(retrieve_groups_by_project)): + return groups + + +@router.post("/", status_code=201) # CHECK IF AUTHORIZED +async def create_group(group: GroupCreate, db: AsyncSession = Depends(get_async_db)): + return await service.create_group(db, group) + + +@router.get("/{group_id}") +async def get_group(group: Group = Depends(retrieve_group)): + return group + + +@router.delete( + "/{group_id}", dependencies=[Depends(is_authorized_to_leave)], status_code=200 +) +async def leave_group( + group_id: int, + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +): + await service.leave_group(db, group_id, user.uid) + return "Successfully deleted" + + +@router.post( + "/{group_id}", + dependencies=[Depends(is_authorized_to_join), Depends(retrieve_group)], + status_code=201, +) +async def join_group( + group_id: int, + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +): + await service.join_group(db, group_id, user.uid) + return "Successfully joined" + + +@router.get("/{group_id}/submissions", dependencies=[Depends(group_id_validation)]) +async def list_submissions(group_id: int, + db: AsyncSession = Depends(get_async_db) + ) -> Sequence[Submission]: + return await get_submissions_by_group(db, group_id) diff --git a/backend/src/group/schemas.py b/backend/src/group/schemas.py new file mode 100644 index 00000000..b27ba931 --- /dev/null +++ b/backend/src/group/schemas.py @@ -0,0 +1,30 @@ +from typing import Sequence + +from pydantic import BaseModel, ConfigDict, Field +from src.user.schemas import User + + +class Groupbase(BaseModel): + model_config = ConfigDict(from_attributes=True) + project_id: int + score: int = 0 + team_name: str = Field(min_length=1) + + +class GroupCreate(Groupbase): + pass + + +class Group(Groupbase): + id: int + + +class GroupPreview(Group): + memberlist: Sequence[User] + + class Config: + from_attributes = True + + +class GroupList(BaseModel): + groups: Sequence[Group] diff --git a/backend/src/group/service.py b/backend/src/group/service.py new file mode 100644 index 00000000..ecce8bbc --- /dev/null +++ b/backend/src/group/service.py @@ -0,0 +1,80 @@ +from typing import Sequence + +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from src.user.models import User + +from src.project import models as projectModels +from src.subject import models as subjectModels +from . import schemas +from .models import Group, StudentGroup + + +async def get_group_by_id(db: AsyncSession, group_id: int) -> Group | None: + return (await db.execute(select(Group).filter_by(id=group_id))).scalar_one_or_none() + + +async def get_groups_by_project(db: AsyncSession, project_id: int) -> Sequence[Group]: + result = await db.execute(select(Group).where(Group.project_id == project_id)) + return result.scalars().all() + + +async def get_groups_by_user(db: AsyncSession, user_id: str) -> Sequence[Group]: + return ( + ( + await db.execute( + select(Group).join(StudentGroup).filter_by(uid=user_id) + ) + ) + .scalars() + .all() + ) + + +async def get_users_by_group(db: AsyncSession, group_id: int) -> Sequence[User]: + return ( + ( + await db.execute( + select(User).join(StudentGroup).filter_by(team_id=group_id) + ) + ) + .scalars() + .all() + ) + + +async def get_teachers_by_group(db: AsyncSession, group_id: int) -> Sequence[User]: + return ( + ( + await db.execute( + select(User) + .join(subjectModels.TeacherSubject) + .join(subjectModels.Subject) + .join(projectModels.Project) + .join(Group) + .filter(Group.id == group_id) + ) + ) + .scalars() + .all() + ) + + +async def create_group(db: AsyncSession, group: schemas.GroupCreate) -> Group: + db_group = Group(**group.model_dump()) + db.add(db_group) + await db.commit() + await db.refresh(db_group) + return db_group + + +async def join_group(db: AsyncSession, team_id: int, user_id: str): + insert_stmnt = StudentGroup.insert().values(team_id=team_id, uid=user_id) + await db.execute(insert_stmnt) + await db.commit() + + +async def leave_group(db: AsyncSession, team_id: int, user_id: str): + await db.execute(delete(StudentGroup).filter_by(team_id=team_id, uid=user_id)) + await db.commit() diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 00000000..d297bf5a --- /dev/null +++ b/backend/src/main.py @@ -0,0 +1,44 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware + +from src import config +from src.auth.router import router as auth_router +from src.group.router import router as group_router +from src.project.router import router as project_router +from src.subject.router import router as subject_router +from src.user.router import router as user_router +from src.submission.router import router as submission_router + +app = FastAPI() + +app.add_middleware(SessionMiddleware, secret_key="!secret") + +origins = [config.CONFIG.frontend_url] +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api") +async def root(): + return { + "docs": "/api/docs", + "authentication": auth_router.prefix, + "profile": f"{user_router.prefix}/me", + "subjects": subject_router.prefix, + "projects": project_router.prefix, + "groups": group_router.prefix, + } + + +app.include_router(auth_router) +app.include_router(user_router) +app.include_router(subject_router) +app.include_router(project_router) +app.include_router(group_router) +app.include_router(submission_router) diff --git a/backend/src/project/dependencies.py b/backend/src/project/dependencies.py new file mode 100644 index 00000000..8a3b4a58 --- /dev/null +++ b/backend/src/project/dependencies.py @@ -0,0 +1,36 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from src.dependencies import get_async_db +from src.subject.dependencies import user_permission_validation +from src.user.dependencies import get_authenticated_user +from src.user.schemas import User + +from .schemas import Project, ProjectCreate +from .service import get_project +from .exceptions import ProjectNotFoundException + + +async def retrieve_project(project_id: int, db: AsyncSession = Depends(get_async_db)): + project = await get_project(db, project_id) + if not project: + raise ProjectNotFoundException() + return project + + +async def create_permission_validation( + project_in: ProjectCreate, + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +): + await user_permission_validation(project_in.subject_id, user, db) + + +async def patch_permission_validation( + project: Project = Depends(retrieve_project), + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +): + await user_permission_validation(project.subject_id, user, db) + + +delete_permission_validation = patch_permission_validation diff --git a/backend/src/project/exceptions.py b/backend/src/project/exceptions.py new file mode 100644 index 00000000..ed3c4d5c --- /dev/null +++ b/backend/src/project/exceptions.py @@ -0,0 +1,5 @@ +from fastapi import HTTPException + + +def ProjectNotFoundException(): + return HTTPException(status_code=404, detail="Project not found") diff --git a/backend/src/project/models.py b/backend/src/project/models.py new file mode 100644 index 00000000..8c33258d --- /dev/null +++ b/backend/src/project/models.py @@ -0,0 +1,25 @@ +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from src.database import Base + + +class Project(Base): + __tablename__ = "project" + + id: Mapped[int] = mapped_column(primary_key=True) + deadline: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False) + name: Mapped[str] = mapped_column(nullable=False) + subject_id: Mapped[int] = mapped_column( + ForeignKey("subject.id", ondelete="CASCADE"), nullable=True + ) + description: Mapped[str] = mapped_column(nullable=True) + enroll_deadline: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + CheckConstraint("deadline >= CURRENT_DATE", name="deadline_check"), + ) diff --git a/backend/src/project/router.py b/backend/src/project/router.py new file mode 100644 index 00000000..da9c2b4c --- /dev/null +++ b/backend/src/project/router.py @@ -0,0 +1,80 @@ +from typing import Sequence +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from src.auth.dependencies import authentication_validation +from src.dependencies import get_async_db +from src.group.dependencies import retrieve_groups_by_project +from src.group.schemas import GroupList +from src.submission.schemas import Submission +from src.submission.service import get_submissions_by_project + +from . import service +from .dependencies import ( + create_permission_validation, + delete_permission_validation, + patch_permission_validation, + retrieve_project, +) +from .schemas import Project, ProjectCreate, ProjectUpdate +from .service import ( + delete_project, + update_project, +) + +router = APIRouter( + prefix="/api/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, + dependencies=[Depends(authentication_validation)], +) + + +@router.post( + "/", + response_model=Project, + dependencies=[Depends(create_permission_validation)], + status_code=201, +) +async def create_project( + project_in: ProjectCreate, db: AsyncSession = Depends(get_async_db) +): + project = await service.create_project(db, project_in) + return project + + +@router.get("/{project_id}", response_model=Project) +async def get_project(project: Project = Depends(retrieve_project)): + return project + + +@router.delete("/{project_id}", dependencies=[Depends(delete_permission_validation)]) +async def delete_project_for_subject( + project_id: int, db: AsyncSession = Depends(get_async_db) +): + await delete_project(db, project_id) + return {"message": "Project deleted successfully"} + + +@router.patch( + "/{project_id}", + response_model=Project, + dependencies=[Depends(patch_permission_validation)], +) +async def patch_project_for_subject( + project_id: int, + project_update: ProjectUpdate, + db: AsyncSession = Depends(get_async_db), +): + return await update_project(db, project_id, project_update) + + +@router.get("/{project_id}/groups") +async def list_groups(groups: GroupList = Depends(retrieve_groups_by_project)): + return groups + + +@router.get("/{project_id}/submissions", dependencies=[Depends(patch_permission_validation)]) +async def list_submissions(group_id: int, + db: AsyncSession = Depends(get_async_db) + ) -> Sequence[Submission]: + return await get_submissions_by_project(db, group_id) diff --git a/backend/src/project/schemas.py b/backend/src/project/schemas.py new file mode 100644 index 00000000..002053dc --- /dev/null +++ b/backend/src/project/schemas.py @@ -0,0 +1,44 @@ +from datetime import datetime +from typing import Optional, Sequence + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class ProjectCreate(BaseModel): + name: str = Field(..., min_length=1) + deadline: datetime + description: str + subject_id: int + + # Check if deadline is not in the past + @field_validator("deadline") + def validate_deadline(cls, value: datetime) -> datetime: + if value < datetime.now(value.tzinfo): + raise ValueError("The deadline cannot be in the past") + return value + + +class Project(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + deadline: datetime + description: str + subject_id: int + + +class ProjectList(BaseModel): + projects: Sequence[Project] + + +class ProjectUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1) + deadline: Optional[datetime] = None + description: Optional[str] = None + + @field_validator("deadline") + def validate_deadline(cls, value: datetime) -> datetime: + if value is not None and value < datetime.now(value.tzinfo): + raise ValueError("The deadline cannot be in the past") + return value diff --git a/backend/src/project/service.py b/backend/src/project/service.py new file mode 100644 index 00000000..3c444356 --- /dev/null +++ b/backend/src/project/service.py @@ -0,0 +1,79 @@ +from typing import Sequence + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from src.subject.models import StudentSubject, Subject + +from src.subject.service import get_teachers + +from .exceptions import ProjectNotFoundException +from .models import Project +from .schemas import ProjectCreate, ProjectList, ProjectUpdate +from src.user.models import User + + +async def create_project(db: AsyncSession, project_in: ProjectCreate) -> Project: + new_project = Project( + name=project_in.name, + deadline=project_in.deadline, + subject_id=project_in.subject_id, + description=project_in.description, + ) + db.add(new_project) + await db.commit() + await db.refresh(new_project) + return new_project + + +async def get_project(db: AsyncSession, project_id: int) -> Project: + result = await db.execute(select(Project).where(Project.id == project_id)) + return result.scalars().first() + + +async def get_teachers_by_project(db: AsyncSession, project_id: int) -> Sequence[User]: + project = await get_project(db, project_id) + return await get_teachers(db, project.subject_id) + + +async def get_projects_by_user(db: AsyncSession, user_id: str) -> Sequence[Project]: + result = await db.execute( + select(Project) + .join(Subject, Project.subject_id == Subject.id) + .join(StudentSubject, StudentSubject.c.subject_id == Subject.id) + .where(StudentSubject.c.uid == user_id) + ) + return result.scalars().all() + + +async def get_projects_for_subject(db: AsyncSession, subject_id: int) -> ProjectList: + result = await db.execute(select(Project).filter_by(subject_id=subject_id)) + projects = result.scalars().all() + return ProjectList(projects=projects) + + +async def delete_project(db: AsyncSession, project_id: int): + result = await db.execute(select(Project).filter_by(id=project_id)) + project = result.scalars().first() + if project: + await db.delete(project) + await db.commit() + + +async def update_project( + db: AsyncSession, project_id: int, project_update: ProjectUpdate +) -> Project: + result = await db.execute(select(Project).filter_by(id=project_id)) + project = result.scalars().first() + if not project: + raise ProjectNotFoundException() + + if project_update.name is not None: + project.name = project_update.name + if project_update.deadline is not None: + project.deadline = project_update.deadline + if project_update.description is not None: + project.description = project_update.description + + await db.commit() + await db.refresh(project) + return project diff --git a/backend/src/subject/dependencies.py b/backend/src/subject/dependencies.py new file mode 100644 index 00000000..7ceee423 --- /dev/null +++ b/backend/src/subject/dependencies.py @@ -0,0 +1,39 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from src.auth.exceptions import NotAuthorized +from src.dependencies import get_async_db +from src.user.dependencies import get_authenticated_user +from src.user.schemas import User + +from . import service +from .exceptions import SubjectNotFound +from .schemas import Subject, SubjectList + + +async def retrieve_subject( + subject_id: int, db: AsyncSession = Depends(get_async_db) +) -> Subject: + subject = await service.get_subject(db, subject_id) + if not subject: + raise SubjectNotFound() + + return Subject.model_validate(subject) + + +async def retrieve_subjects( + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +) -> SubjectList: + subjects = await service.get_subjects(db) + return SubjectList(subjects=subjects) + + +async def user_permission_validation( + subject_id: int, + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +): + if not user.is_admin: + teachers = await service.get_teachers(db, subject_id) + if not list(filter(lambda teacher: teacher.uid == user.uid, teachers)): + raise NotAuthorized() diff --git a/backend/src/subject/exceptions.py b/backend/src/subject/exceptions.py new file mode 100644 index 00000000..dc170fc2 --- /dev/null +++ b/backend/src/subject/exceptions.py @@ -0,0 +1,7 @@ +from fastapi import HTTPException + + +class SubjectNotFound(HTTPException): + def __init__(self): + """Raised when subject not found in database""" + super().__init__(status_code=404, detail="Subject not found") diff --git a/backend/src/subject/models.py b/backend/src/subject/models.py new file mode 100644 index 00000000..ad7371db --- /dev/null +++ b/backend/src/subject/models.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, ForeignKey, Table +from sqlalchemy.orm import Mapped, mapped_column +from src.database import Base + +StudentSubject = Table( + "student_subject", + Base.metadata, + Column("uid", ForeignKey("website_user.uid")), + Column("subject_id", ForeignKey("subject.id")), +) + +TeacherSubject = Table( + "teacher_subject", + Base.metadata, + Column("uid", ForeignKey("website_user.uid")), + Column("subject_id", ForeignKey("subject.id")), +) + + +class Subject(Base): + __tablename__ = "subject" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] diff --git a/backend/src/subject/router.py b/backend/src/subject/router.py new file mode 100644 index 00000000..339102fd --- /dev/null +++ b/backend/src/subject/router.py @@ -0,0 +1,142 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from src.auth.dependencies import authentication_validation +from src.dependencies import get_async_db +from src.project.schemas import Project, ProjectList +from src.project.service import get_projects_for_subject +from src.user.dependencies import admin_user_validation, user_id_validation +from src.user.schemas import User + +from . import service +from .dependencies import ( + retrieve_subject, + retrieve_subjects, + user_permission_validation, +) +from .schemas import Subject, SubjectCreate, SubjectList + +router = APIRouter( + prefix="/api/subjects", + tags=["subjects"], + responses={404: {"description": "Not found"}}, + dependencies=[Depends(authentication_validation)], +) + + +@router.get("/", response_model=SubjectList) +async def get_subjects(subjects: SubjectList = Depends(retrieve_subjects)): + return subjects + + +@router.get("/{subject_id}", response_model=Subject) +async def get_subject(subject: Subject = Depends(retrieve_subject)): + return subject + + +@router.post( + "/", + response_model=Subject, + dependencies=[Depends(admin_user_validation)], + status_code=201, +) +async def create_subject( + subject: SubjectCreate, db: AsyncSession = Depends(get_async_db) +): + return await service.create_subject(db, subject) + + +@router.delete( + "/{subject_id}", dependencies=[Depends(admin_user_validation)], status_code=200 +) +async def delete_subject(subject_id: int, db: AsyncSession = Depends(get_async_db)): + await service.delete_subject(db, subject_id) + return "Successfully deleted" + + +@router.patch( + "/{subject_id}", + response_model=Subject, + dependencies=[Depends(user_permission_validation)], +) +async def update_subject( + subject_update: SubjectCreate, subject_original: Subject = Depends(retrieve_subject) +) -> Subject: + update_data = subject_update.model_dump(exclude_unset=True) + subject_updated = subject_original.model_copy(update=update_data) + return subject_updated + + +# ---------------Teachers------------ + + +@router.get("/{subject_id}/teachers", response_model=list[User]) +async def get_subject_teachers( + subject_id: int, db: AsyncSession = Depends(get_async_db) +): + return await service.get_teachers(db, subject_id) + + +@router.post( + "/{subject_id}/teachers", + dependencies=[Depends(user_permission_validation), + Depends(user_id_validation)], + status_code=201, +) +async def create_subject_teacher( + subject_id: int, user_id: str, db: AsyncSession = Depends(get_async_db) +): + await service.create_subject_teacher(db, subject_id, user_id) + return "Successfully added" + + +@router.delete( + "/{subject_id}/teachers/{user_id}", + dependencies=[Depends(user_permission_validation)], +) +async def delete_subject_teacher( + subject_id: int, user_id: str, db: AsyncSession = Depends(get_async_db) +): + await service.delete_subject_teacher(db, subject_id, user_id) + + +# ---------------Students------------ + +@router.get("/{subject_id}/students", response_model=list[User]) +async def get_subject_students( + subject_id: int, db: AsyncSession = Depends(get_async_db) +): + return await service.get_students(db, subject_id) + + +@router.post( + "/{subject_id}/students", + dependencies=[Depends(user_id_validation)], + status_code=201, +) +async def create_subject_student( + subject_id: int, user_id: str, db: AsyncSession = Depends(get_async_db) +): + await service.create_subject_student(db, subject_id, user_id) + return "Successfully added" + + +@router.delete( + "/{subject_id}/students/{user_id}", + dependencies=[Depends(user_permission_validation)], +) +async def delete_subject_student( + subject_id: int, user_id: str, db: AsyncSession = Depends(get_async_db) +): + await service.delete_subject_student(db, subject_id, user_id) + return "Successfully deleted" + + +# ---------------Projects------------ + + +@router.get("/{subject_id}/projects") +async def list_projects( + subject_id: int, db: AsyncSession = Depends(get_async_db) +) -> ProjectList: + projects = await get_projects_for_subject(db, subject_id) + return projects diff --git a/backend/src/subject/schemas.py b/backend/src/subject/schemas.py new file mode 100644 index 00000000..ebd6f9e1 --- /dev/null +++ b/backend/src/subject/schemas.py @@ -0,0 +1,22 @@ +from typing import Sequence + +from pydantic import BaseModel, ConfigDict, Field + + +class SubjectBase(BaseModel): + name: str = Field(min_length=1) + + +class SubjectCreate(SubjectBase): + pass + + +class Subject(SubjectBase): + model_config = ConfigDict(from_attributes=True) + + id: int + + +class SubjectList(BaseModel): + subjects: Sequence[Subject] + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/subject/service.py b/backend/src/subject/service.py new file mode 100644 index 00000000..1ca3eeb5 --- /dev/null +++ b/backend/src/subject/service.py @@ -0,0 +1,94 @@ +from typing import Sequence + +from sqlalchemy import delete, select, insert +from sqlalchemy.ext.asyncio import AsyncSession +from src.user.models import User + +from .models import StudentSubject, Subject, TeacherSubject +from .schemas import SubjectCreate + + +async def get_subjects(db: AsyncSession) -> Sequence[Subject]: + subjects = await db.execute(select(Subject)) + return subjects.scalars().all() + + +async def get_subject(db: AsyncSession, subject_id: int) -> Subject: + result = await db.execute(select(Subject).filter_by(id=subject_id)) + return result.scalars().first() + + +async def get_subjects_by_user( + db: AsyncSession, user_id: str +) -> tuple[Sequence[Subject], Sequence[Subject]]: + teachers_subjects = await db.execute( + select(Subject).join(TeacherSubject).filter( + TeacherSubject.c.uid == user_id) + ) + students_subjects = await db.execute( + select(Subject).join(StudentSubject).filter( + StudentSubject.c.uid == user_id) + + ) + return teachers_subjects.scalars().all(), students_subjects.scalars().all() + + +async def get_teachers(db: AsyncSession, subject_id: int) -> Sequence[User]: + result = await db.execute( + select(User).join(TeacherSubject, User.uid == TeacherSubject.c.uid).where( + TeacherSubject.c.subject_id == subject_id) + ) + return result.scalars().all() + + +async def create_subject(db: AsyncSession, subject: SubjectCreate) -> Subject: + db_subject = Subject(name=subject.name) + db.add(db_subject) + await db.commit() + await db.refresh(db_subject) + return db_subject + + +async def create_subject_teacher(db: AsyncSession, subject_id: int, user_id: str): + insert_stmnt = TeacherSubject.insert().values( + subject_id=subject_id, uid=user_id) + await db.execute(insert_stmnt) + await db.commit() + + +async def delete_subject_teacher(db: AsyncSession, subject_id: int, user_id: str): + await db.execute( + delete(TeacherSubject) + .where(TeacherSubject.c.subject_id == subject_id) + .where(TeacherSubject.c.uid == user_id) + ) + await db.commit() + + +async def delete_subject(db: AsyncSession, subject_id: int): + await db.execute(delete(Subject).where(Subject.id == subject_id)) + await db.commit() + + +async def create_subject_student(db: AsyncSession, subject_id: int, user_id: str): + insert_stmnt = StudentSubject.insert().values( + subject_id=subject_id, uid=user_id) + await db.execute(insert_stmnt) + await db.commit() + + +async def get_students(db: AsyncSession, subject_id: int) -> Sequence[User]: + result = await db.execute( + select(User).join(StudentSubject, User.uid == StudentSubject.c.uid).where( + StudentSubject.c.subject_id == subject_id) + ) + return result.scalars().all() + + +async def delete_subject_student(db: AsyncSession, subject_id: int, user_id: str): + await db.execute( + delete(StudentSubject) + .where(StudentSubject.c.subject_id == subject_id) + .where(StudentSubject.c.uid == user_id) + ) + await db.commit() diff --git a/backend/src/submission/dependencies.py b/backend/src/submission/dependencies.py new file mode 100644 index 00000000..4bab5d1b --- /dev/null +++ b/backend/src/submission/dependencies.py @@ -0,0 +1,48 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from src.auth.exceptions import NotAuthorized +from src.dependencies import get_async_db +from src.group.service import get_teachers_by_group, get_users_by_group +from src.submission.exceptions import SubmissionNotFound +from src.submission.schemas import SubmissionCreate +from src.user.dependencies import get_authenticated_user +from src.user.schemas import User + +from . import service + + +async def group_id_validation(group_id: int, + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db)): + if not user.is_admin: + users = await get_users_by_group(db, group_id) + for u in users: + print(u.uid) + if not any(user.uid == u.uid for u in users): + raise NotAuthorized("Not in group") + + +async def create_permission_validation( + submission: SubmissionCreate, + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db) +): + await group_id_validation(submission.group_id, user, db) + + +async def retrieve_submission( + submission_id: int, + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db) +): + submission = await service.get_submission(db, submission_id) + if not submission: + raise SubmissionNotFound() + + teachers = list(await get_teachers_by_group(db, submission.group_id)) + group_users = list(await get_users_by_group(db, submission.group_id)) + + if not any(user.uid == u.uid for u in teachers + group_users): + raise NotAuthorized() + + return submission diff --git a/backend/src/submission/exceptions.py b/backend/src/submission/exceptions.py new file mode 100644 index 00000000..e4d9b04c --- /dev/null +++ b/backend/src/submission/exceptions.py @@ -0,0 +1,7 @@ +from fastapi import HTTPException + + +class SubmissionNotFound(HTTPException): + def __init__(self): + """Raised when submission not found in database""" + super().__init__(status_code=404, detail="Submission not found") diff --git a/backend/src/submission/models.py b/backend/src/submission/models.py new file mode 100644 index 00000000..6c2d1af8 --- /dev/null +++ b/backend/src/submission/models.py @@ -0,0 +1,30 @@ +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime, timezone +import enum +from src.database import Base + + +class Status(enum.Enum): + InProgress = 1, + Accepted = 2, + Denied = 3 + + +class Submission(Base): + __tablename__ = "submission" + + id: Mapped[int] = mapped_column(primary_key=True) + date: Mapped[datetime] = mapped_column(default=datetime.now(), + nullable=False) + status: Mapped[Status] = mapped_column(default=Status.InProgress, nullable=False) + + group_id: Mapped[int] = mapped_column( + ForeignKey("team.id", ondelete="CASCADE"), + nullable=False + ) + + project_id: Mapped[int] = mapped_column( + ForeignKey("project.id", ondelete="CASCADE"), + nullable=False + ) diff --git a/backend/src/submission/router.py b/backend/src/submission/router.py new file mode 100644 index 00000000..f05cc3cd --- /dev/null +++ b/backend/src/submission/router.py @@ -0,0 +1,40 @@ +from typing import Sequence +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from src.dependencies import get_async_db +from src.group.dependencies import retrieve_group +from src.submission.dependencies import create_permission_validation, retrieve_submission +from src.user.dependencies import admin_user_validation +from .schemas import Submission, SubmissionCreate +from . import service + +router = APIRouter( + prefix="/api/submissions", + tags=["submission"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/", dependencies=[Depends(admin_user_validation)]) +async def get_submissions(db: AsyncSession = Depends(get_async_db)) -> Sequence[Submission]: + return await service.get_submissions(db) + + +@router.get("/{submission_id}") +async def get_submission(submission: Submission = Depends(retrieve_submission)) -> Submission: + return submission + + +@router.post("/", response_model=Submission, status_code=201, + dependencies=[Depends(create_permission_validation)]) +async def create_submission(submission: SubmissionCreate, + db: AsyncSession = Depends(get_async_db)): + group = await retrieve_group(submission.group_id, db) + return await service.create_submission(db, submission, submission.group_id, group.project_id) + + +@router.delete("/{submission_id}", + dependencies=[Depends(admin_user_validation)], + status_code=200) +async def delete_submision(submission_id: int, db: AsyncSession = Depends(get_async_db)): + await service.delete_submission(db, submission_id) diff --git a/backend/src/submission/schemas.py b/backend/src/submission/schemas.py new file mode 100644 index 00000000..1a5df858 --- /dev/null +++ b/backend/src/submission/schemas.py @@ -0,0 +1,20 @@ +from datetime import datetime +from pydantic import BaseModel, ConfigDict +from .models import Status + + +class SubmissionBase(BaseModel): + group_id: int + + +class SubmissionCreate(SubmissionBase): + pass + + +class Submission(SubmissionBase): + model_config = ConfigDict(from_attributes=True) + + id: int + date: datetime + project_id: int + status: Status diff --git a/backend/src/submission/service.py b/backend/src/submission/service.py new file mode 100644 index 00000000..28dba0ea --- /dev/null +++ b/backend/src/submission/service.py @@ -0,0 +1,52 @@ +from typing import Sequence +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from src.group.service import get_group_by_id + +from . import models, schemas + + +async def get_submissions(db: AsyncSession) -> Sequence[models.Submission]: + return (await db.execute(select(models.Submission))).scalars().all() + + +async def get_submissions_by_project(db: AsyncSession, + project_id: int) -> Sequence[models.Submission]: + + return (await db.execute(select(models.Submission). + filter_by(project_id=project_id))).scalars().all() + + +async def get_submissions_by_group(db: AsyncSession, + group_id: int) -> Sequence[models.Submission]: + return (await db.execute(select(models.Submission). + filter_by(group_id=group_id))).scalars().all() + + +async def get_submission(db: AsyncSession, submission_id: int) -> models.Submission: + return (await db.execute(select(models.Submission) + .filter_by(id=submission_id))).scalar_one_or_none() + + +async def get_group(db: AsyncSession, submission_id: int): + pass + + +async def create_submission(db: AsyncSession, + submission: schemas.SubmissionCreate, + group_id: int, + subject_id: int + ) -> models.Submission: + db_submission = models.Submission(group_id=group_id, project_id=subject_id) + db.add(db_submission) + await db.commit() + await db.refresh(db_submission) + return db_submission + + +async def delete_submission(db: AsyncSession, submission_id: int): + submission = (await db.execute(select(models.Submission). + filter_by(id=submission_id))).scalar() + await db.delete(submission) + await db.commit() diff --git a/backend/src/user/dependencies.py b/backend/src/user/dependencies.py new file mode 100644 index 00000000..f273feea --- /dev/null +++ b/backend/src/user/dependencies.py @@ -0,0 +1,75 @@ +import src.group.service as group_service +import src.project.service as project_service +import src.subject.service as subject_service +import src.user.service as user_service +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from src.auth.dependencies import jwt_token_validation +from src.auth.exceptions import NotAuthorized, UnAuthenticated +from src.dependencies import get_async_db +from src.group.schemas import GroupList +from src.project.schemas import ProjectList + +from .exceptions import UserNotFound +from .schemas import User, UserSimple, UserSubjectList + + +async def get_authenticated_user( + user_id: str = Depends(jwt_token_validation), + db: AsyncSession = Depends(get_async_db), +) -> User: + """Get current logged in user""" + if not user_id: + raise UnAuthenticated() + user = await user_service.get_by_id(db, user_id) + if not user: + raise UserNotFound() + + return user + + +async def admin_user_validation(user: User = Depends(get_authenticated_user)): + """Checks if user is an admin""" + if not user.is_admin: + raise NotAuthorized() + + +async def user_id_validation(user_id: str, db: AsyncSession = Depends(get_async_db)): + user = await user_service.get_by_id(db, user_id) + if not user: + raise UserNotFound() + + +async def retrieve_user( + user_id: str, db: AsyncSession = Depends(get_async_db) +) -> UserSimple: + user = await user_service.get_by_id(db, user_id) + if not user: + raise UserNotFound() + return user + + +async def retrieve_subjects( + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +) -> UserSubjectList: + teacher_subjects, student_subjects = await subject_service.get_subjects_by_user( + db, user.uid + ) + return UserSubjectList(as_student=student_subjects, as_teacher=teacher_subjects) + + +async def retrieve_groups( + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +) -> GroupList: + groups = await group_service.get_groups_by_user(db, user.uid) + return GroupList(groups=groups) + + +async def retrieve_projects( + user: User = Depends(get_authenticated_user), + db: AsyncSession = Depends(get_async_db), +) -> ProjectList: + projects = await project_service.get_projects_by_user(db, user.uid) + return ProjectList(projects=projects) diff --git a/backend/src/user/exceptions.py b/backend/src/user/exceptions.py new file mode 100644 index 00000000..8df7fbde --- /dev/null +++ b/backend/src/user/exceptions.py @@ -0,0 +1,7 @@ +from fastapi import HTTPException + + +class UserNotFound(HTTPException): + def __init__(self): + """Raised when user not found in database""" + super().__init__(status_code=404, detail="User not found") diff --git a/backend/src/user/models.py b/backend/src/user/models.py new file mode 100644 index 00000000..3d8ea1e2 --- /dev/null +++ b/backend/src/user/models.py @@ -0,0 +1,11 @@ +from sqlalchemy.orm import Mapped, mapped_column +from src.database import Base + + +class User(Base): + __tablename__ = "website_user" + + uid: Mapped[str] = mapped_column(primary_key=True) + given_name: Mapped[str] + mail: Mapped[str] + is_admin: Mapped[bool] = mapped_column(default=False) diff --git a/backend/src/user/router.py b/backend/src/user/router.py new file mode 100644 index 00000000..6969a085 --- /dev/null +++ b/backend/src/user/router.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, Depends +from src.auth.dependencies import authentication_validation +from src.group.schemas import GroupList +from src.project.schemas import ProjectList + +from .dependencies import ( + get_authenticated_user, + retrieve_groups, + retrieve_projects, + retrieve_subjects, + retrieve_user, +) +from .schemas import User, UserSimple, UserSubjectList + +router = APIRouter( + prefix="/api/users", + tags=["user"], + responses={404: {"description": "Not Found"}}, + dependencies=[Depends(authentication_validation)], +) + + +@router.get("/me") +async def profile(user: User = Depends(get_authenticated_user)) -> User: + """ + Get information about the current user + """ + return user + + +@router.get("/{user_id}") +async def user_info(user: UserSimple = Depends(retrieve_user)) -> UserSimple: + """ + Get information about a user + """ + return user + + +@router.get("/me/subjects") +async def list_subjects( + subjects: UserSubjectList = Depends(retrieve_subjects), +) -> UserSubjectList: + """ + Get the subjects of the current user + """ + return subjects + + +@router.get("/me/projects") +async def list_projects( + projects: ProjectList = Depends(retrieve_projects), +) -> ProjectList: + """ + Get the projects of the current user + """ + return projects + + +@router.get("/me/groups") +async def list_groups(groups: GroupList = Depends(retrieve_groups)) -> GroupList: + """ + Get the groups of the current user + """ + return groups diff --git a/backend/src/user/schemas.py b/backend/src/user/schemas.py new file mode 100644 index 00000000..11b3f3a6 --- /dev/null +++ b/backend/src/user/schemas.py @@ -0,0 +1,29 @@ +from typing import Sequence + +from pydantic import BaseModel, ConfigDict, Field +from src.subject.schemas import Subject + + +class Userbase(BaseModel): + uid: str # ugentID + given_name: str + mail: str + + +class UserCreate(Userbase): + pass + + +class UserSimple(Userbase): + pass + + +class User(Userbase): + model_config = ConfigDict(from_attributes=True) + is_admin: bool = Field(default=False) + + +class UserSubjectList(BaseModel): + model_config = ConfigDict(from_attributes=True) + as_teacher: Sequence[Subject] + as_student: Sequence[Subject] diff --git a/backend/src/user/service.py b/backend/src/user/service.py new file mode 100644 index 00000000..67184333 --- /dev/null +++ b/backend/src/user/service.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from .models import User +from .schemas import UserCreate + + +async def get_by_id(db: AsyncSession, user_id: str) -> User: + return await db.get(User, user_id) + + +async def create_user(db: AsyncSession, user: UserCreate) -> User: + db_user = User(**user.model_dump()) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + return db_user + + +async def set_admin(db: AsyncSession, user_id: str, value: bool): + user = await get_by_id(db, user_id) + user.is_admin = value + await db.commit() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..b1426c8d --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,72 @@ +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from src.auth.utils import create_jwt_token +from src.database import async_engine +from src.main import app +from src.dependencies import get_async_db +import pytest +import asyncio +import pytest_asyncio + +from src.user.schemas import UserCreate +from src.user.service import create_user + +connection = None +trans = None +TestingSessionLocal = None + + +async def get_session() -> AsyncSession: + global TestingSessionLocal, connection, trans + if TestingSessionLocal is None: + connection = await async_engine.connect() + trans = await connection.begin() + + TestingSessionLocal = async_sessionmaker(connection, autoflush=False) + return TestingSessionLocal() + else: + return TestingSessionLocal() + + +async def get_db_override(): + db = await get_session() + try: + yield db + finally: + await db.close() + +app.dependency_overrides[get_async_db] = get_db_override + + +@pytest.fixture(scope="session") +def event_loop(): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture +async def db(): + global trans, connection + db = await get_session() + try: + yield db + finally: + if trans is not None and connection is not None: + await db.close() + await trans.rollback() + trans = await connection.begin() + + +@pytest_asyncio.fixture +async def client(db: AsyncSession): + token = create_jwt_token("test") + + await create_user(db, UserCreate(uid="test", given_name="tester", mail="test@test.test")) + + transport = ASGITransport(app=app) # type: ignore + async with AsyncClient(transport=transport, base_url="http://test", headers={"Authorization": f"Bearer {token.token}"}) as client: + yield client diff --git a/backend/tests/test_group.py b/backend/tests/test_group.py new file mode 100644 index 00000000..409db6a3 --- /dev/null +++ b/backend/tests/test_group.py @@ -0,0 +1,73 @@ +from datetime import datetime, timedelta, timezone + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from src.user.service import set_admin + +subject = {"name": "test subject"} +future_date = datetime.now(timezone.utc) + timedelta(weeks=1) +project = { + "name": "test project", + "subject_id": 0, # temp needs to be filled in by actual subject id + "deadline": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + "description": "test", + "enroll_deadline": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"), +} +group_data = {"team_name": "test group", "project_id": 0} + + +@pytest_asyncio.fixture +async def subject_id(client: AsyncClient, db: AsyncSession) -> int: + """Create new subject""" + await set_admin(db, "test", True) + response = await client.post("/api/subjects/", json=subject) + return response.json()["id"] + + +@pytest_asyncio.fixture +async def project_id(client: AsyncClient, db: AsyncSession, subject_id: int): + project["subject_id"] = subject_id + await set_admin(db, "test", True) + response = await client.post("/api/projects/", json=project) + return response.json()["id"] + + +@pytest_asyncio.fixture +async def group_id(client: AsyncClient, db: AsyncSession, project_id: int): + group_data["project_id"] = project_id + await set_admin(db, "test", True) + response = await client.post("/api/groups/", json=group_data) + return response.json()["id"] + + +@pytest.mark.asyncio +async def test_create_group(client: AsyncClient, db: AsyncSession, project_id: int): + group_data = {"team_name": "test group", "project_id": project_id} + await set_admin(db, "test", True) + response = await client.post("/api/groups/", json=group_data) + assert response.status_code == 201 # Created + assert response.json()["team_name"] == group_data["team_name"] + + +@pytest.mark.asyncio +async def test_get_group(client: AsyncClient, group_id: int): + response = await client.get(f"/api/groups/{group_id}") + assert response.status_code == 200 + assert response.json()["id"] == group_id + + +@pytest.mark.asyncio +async def test_join_user(client: AsyncClient, group_id: int): + response = await client.post(f"/api/groups/{group_id}") + assert response.status_code == 201 + + +@pytest.mark.asyncio +async def test_remove_user(client: AsyncClient, group_id: int): + response = await client.post(f"/api/groups/{group_id}") + response = await client.delete(f"/api/groups/{group_id}") + assert response.status_code == 200 + response = await client.delete(f"/api/groups/{group_id}") + assert response.status_code == 404 diff --git a/backend/tests/test_project.py b/backend/tests/test_project.py new file mode 100644 index 00000000..dea5957d --- /dev/null +++ b/backend/tests/test_project.py @@ -0,0 +1,113 @@ +from datetime import datetime, timedelta, timezone + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from src.user.service import set_admin + +# skeletons for basic json objects +subject = {"name": "test subject"} +future_date = datetime.now(timezone.utc) + timedelta(weeks=1) +project = { + "name": "test project", + "subject_id": 0, # temp needs to be filled in by actual subject id + "deadline": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + "description": "test", + "enroll_deadline": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"), +} + + +@pytest_asyncio.fixture +async def subject_id(client: AsyncClient, db: AsyncSession) -> int: + """Create new subject""" + await set_admin(db, "test", True) + response = await client.post("/api/subjects/", json=subject) + return response.json()["id"] + + +@pytest_asyncio.fixture +async def project_id(client: AsyncClient, db: AsyncSession, subject_id: int) -> int: + """Create new project""" + project["subject_id"] = subject_id + await set_admin(db, "test", True) + response = await client.post("/api/projects/", json=project) + return response.json()["id"] + + +@pytest.mark.asyncio +async def test_create_project(client: AsyncClient, db: AsyncSession, subject_id: int): + future_date = datetime.now(timezone.utc) + timedelta(weeks=1) + project = { + "name": "test project", + "subject_id": subject_id, + "deadline": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + "description": "test", + } + await set_admin(db, "test", False) + response = await client.post("/api/projects/", json=project) + assert response.status_code == 403 # Forbidden, not admin + + await set_admin(db, "test", True) + response = await client.post("/api/projects/", json=project) + assert response.status_code == 201 # Created + assert response.json()["name"] == project["name"] + + +@pytest.mark.asyncio +async def test_get_project(client: AsyncClient, project_id: int): + response = await client.get(f"/api/projects/{project_id}") + assert response.status_code == 200 + assert response.json()["name"] == project["name"] + + +@pytest.mark.asyncio +async def test_get_projects_for_subject( + client: AsyncClient, subject_id: int, project_id: int +): + response = await client.get(f"/api/subjects/{subject_id}/projects") + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()["projects"][0]["name"] == project["name"] + + +@pytest.mark.asyncio +async def test_delete_project(client: AsyncClient, db: AsyncSession, project_id: int): + await set_admin(db, "test", False) + response = await client.delete(f"/api/projects/{project_id}") + await set_admin(db, "test", True) + response = await client.delete(f"/api/projects/{project_id}") + assert response.status_code == 200 + response = await client.get(f"/api/projects/{project_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_patch_project(client: AsyncClient, db: AsyncSession, project_id: int): + await set_admin(db, "test", False) + response = await client.patch( + f"/api/projects/{project_id}", json={"name": "new name"} + ) + assert response.status_code == 403 + await set_admin(db, "test", True) + response = await client.patch( + f"/api/projects/{project_id}", json={"name": "new name"} + ) + assert response.status_code == 200 + response = await client.get(f"/api/projects/{project_id}") + assert response.json()["name"] == "new name" + response = await client.patch( + f"/api/projects/{project_id}", + json={"deadline": future_date.strftime("%Y-%m-%dT%H:%M:%SZ")}, + ) + assert response.status_code == 200 + response = await client.get(f"/api/projects/{project_id}") + assert response.json()["deadline"] == future_date.strftime( + "%Y-%m-%dT%H:%M:%SZ") + response = await client.patch( + f"/api/projects/{project_id}", + json={"description": "new description"}, + ) + assert response.status_code == 200 + response = await client.get(f"/api/projects/{project_id}") + assert response.json()["description"] == "new description" diff --git a/backend/tests/test_subject.py b/backend/tests/test_subject.py new file mode 100644 index 00000000..fabef523 --- /dev/null +++ b/backend/tests/test_subject.py @@ -0,0 +1,178 @@ +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from src.user.schemas import UserCreate +from src.user.service import create_user, set_admin + +subject = {"name": "test_subject"} + + +@pytest_asyncio.fixture +async def subject_id(client: AsyncClient, db: AsyncSession) -> int: + """Create new subject""" + await set_admin(db, "test", True) + response = await client.post("/api/subjects/", json=subject) + return response.json()["id"] + + +@pytest.mark.asyncio +async def test_create_subject(client: AsyncClient, db: AsyncSession): + await set_admin(db, "test", False) + response = await client.post("/api/subjects/", json=subject) + assert response.status_code == 403 # Forbidden, not admin + + await set_admin(db, "test", True) + response = await client.post("/api/subjects/", json=subject) + assert response.status_code == 201 # Created + assert response.json()["name"] == subject["name"] + + +@pytest.mark.asyncio +async def test_get_subject(client: AsyncClient, subject_id: int): + response = await client.get(f"/api/subjects/{subject_id}") + assert response.status_code == 200 + assert response.json()["name"] == subject["name"] + + +@pytest.mark.asyncio +async def test_create_teacher(client: AsyncClient, db: AsyncSession, subject_id: int): + await set_admin(db, "test", False) + response2 = await client.post( + f"/api/subjects/{subject_id}/teachers", params={"user_id": "test"} + ) + assert response2.status_code == 403 # Forbidden + + await set_admin(db, "test", True) + response2 = await client.post( + f"/api/subjects/{subject_id}/teachers", params={"user_id": "test"} + ) + assert response2.status_code == 201 + + await set_admin(db, "test", False) + await create_user( + db, UserCreate(uid="test2", given_name="tester", mail="test@test.test") + ) + response2 = await client.post( + f"/api/subjects/{subject_id}/teachers", params={"user_id": "test2"} + ) + assert response2.status_code == 201 # Success because we are teacher now + + +@pytest.mark.asyncio +async def test_get_teachers(client: AsyncClient, subject_id: int, db: AsyncSession): + await set_admin(db, "test", True) + # create teacher + await create_user( + db, UserCreate(uid="get_test", given_name="teacher", + mail="blabla@gmail.com") + ) + await client.post( + f"/api/subjects/{subject_id}/teachers", params={"user_id": "get_test"} + ) + response = await client.get(f"/api/subjects/{subject_id}/teachers") + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["uid"] == "get_test" + + +@pytest.mark.asyncio +async def test_get_subjects(client: AsyncClient, subject_id: int): + # await client.post(f"/api/subjects/{subject_id}/teachers", params={'user_id': 'test'}) + response2 = await client.get("/api/subjects/") + assert response2.status_code == 200 + assert len(response2.json()) == 1 + + +@pytest.mark.asyncio +async def test_delete_subject(client: AsyncClient, db: AsyncSession, subject_id: int): + await set_admin(db, "test", False) + response2 = await client.delete(f"/api/subjects/{subject_id}") + assert response2.status_code == 403 # Forbidden + await set_admin(db, "test", True) + response3 = await client.delete(f"/api/subjects/{subject_id}") + assert response3.status_code == 200 + + response4 = await client.get(f"/api/subjects/{subject_id}") + assert response4.status_code == 404 # Not Found + + +@pytest.mark.asyncio +async def test_patch_subject(client: AsyncClient, db: AsyncSession, subject_id: int): + await set_admin(db, "test", False) + response = await client.patch( + f"/api/subjects/{subject_id}", json={"name": "new name"} + ) + assert response.status_code == 403 + await set_admin(db, "test", True) + response = await client.patch( + f"/api/subjects/{subject_id}", json={"name": "new name"} + ) + assert response.status_code == 200 + assert response.json()["name"] == "new name" + + +@pytest.mark.asyncio +async def test_enroll_student_into_course(client: AsyncClient, db: AsyncSession, subject_id: int): + await set_admin(db, "test", False) + response = await client.post( + f"/api/subjects/{subject_id}/students", params={"user_id": "test"} + ) + assert response.status_code == 201 + + # check if actually enrolled now: + response = await client.get(f"/api/subjects/{subject_id}/students") + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["uid"] == "test" + + +@pytest.mark.asyncio +async def test_get_students(client: AsyncClient, db: AsyncSession, subject_id: int): + # enroll student + await create_user( + db, UserCreate(uid="get_test", given_name="tester", + mail="blabla@gmail.com") + ) + await client.post( + f"/api/subjects/{subject_id}/students", params={"user_id": "get_test"} + ) + response = await client.get(f"/api/subjects/{subject_id}/students") + assert response.status_code == 200 + print(response.json()) + assert len(response.json()) == 1 + assert response.json()[0]["uid"] == "get_test" + + +@pytest.mark.asyncio +async def test_delete_student(client: AsyncClient, db: AsyncSession, subject_id: int): + # Enroll student + user_uid = "get_test" # Keep the user UID for further assertions + await create_user( + db, UserCreate(uid=user_uid, given_name="tester", + mail="blabla@gmail.com") + ) + await client.post( + # Use path parameters + f"/api/subjects/{subject_id}/students/{user_uid}" + ) + + # Try to delete the student without admin privileges + await set_admin(db, "test", False) + # Use path parameters + response = await client.delete(f"/api/subjects/{subject_id}/students/{user_uid}") + assert response.status_code == 403 + + # Delete the student with admin privileges + await set_admin(db, "test", True) + # Use path parameters + response = await client.delete(f"/api/subjects/{subject_id}/students/{user_uid}") + assert response.status_code == 200 + + # Check if the student has been deleted + response = await client.get(f"/api/subjects/{subject_id}/students") + assert response.status_code == 200 + students = response.json() + assert not any( + student['uid'] == user_uid for student in students), "The student should have been deleted." + assert len(students) == 0, "There should be no students in the course." diff --git a/backend/tests/test_submission.py b/backend/tests/test_submission.py new file mode 100644 index 00000000..4d1df08c --- /dev/null +++ b/backend/tests/test_submission.py @@ -0,0 +1,6 @@ +import pytest +from httpx import AsyncClient +from sqlalchemy.orm import Session +from src.user.schemas import UserCreate + +from src.user.service import set_admin diff --git a/frontend/.env.local.example b/frontend/.env.local.example new file mode 100644 index 00000000..930247ef --- /dev/null +++ b/frontend/.env.local.example @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:5173 +VITE_APP_URL=https://localhost:8080 diff --git a/frontend/.gitignore b/frontend/.gitignore index 8ee54e8d..6fedd3b9 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -28,3 +28,6 @@ coverage *.sw? *.tsbuildinfo + +# Local SSL certificates +local-cert/ diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json index cd12ad7d..c4ef772f 100644 --- a/frontend/.prettierrc.json +++ b/frontend/.prettierrc.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/prettierrc", "semi": true, "tabWidth": 4, - "singleQuote": true, + "singleQuote": false, "printWidth": 100, "trailingComma": "es5", "bracketSpacing": true, diff --git a/frontend/README.md b/frontend/README.md index 04bd4d6e..210623b9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -4,8 +4,27 @@ npm install ``` +## .env.local file + +Linux: +```sh +cp .env.local.example .env.local +``` +Windows: +```sh +copy .env.local.example .env.local +``` + ### Compile and Hot-Reload for Development +> Note: For local development, an SSL-certificate is needed to interact with the +> CAS-server of UGent. Install [mkcert](https://github.com/FiloSottile/mkcert) +> and run +> ```sh +> mkdir local-cert +> mkcert -key-file local-cert/localhost-key.pem -cert-file local-cert/localhost.pem localhost +> ``` + ```sh npm run dev ``` diff --git a/frontend/env.d.ts b/frontend/env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/frontend/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/frontend/index.html b/frontend/index.html index 083bfcd1..4906674b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + Apollo diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eb5c8554..000bdf66 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "jsdom": "^24.0.0", "npm-run-all2": "^6.1.1", "prettier": "^3.0.3", + "sass": "^1.71.1", "typescript": "~5.3.0", "vite": "^5.0.11", "vitest": "^1.2.2", @@ -1526,6 +1527,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1562,6 +1576,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1653,6 +1676,42 @@ "node": "*" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2577,6 +2636,12 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", + "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "dev": true + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -2624,6 +2689,18 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3094,6 +3171,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-normalize-package-bin": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", @@ -3611,6 +3697,18 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -3760,6 +3858,23 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/sass": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.71.1.tgz", + "integrity": "sha512-wovtnV2PxzteLlfNzbgm1tFXPLoZILYAMJtvoXXkD7/+1uP41eKkIt1ypWq5/q2uT94qHjXehEYfmjKOvjL9sg==", + "dev": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b0a17327..ea61c311 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ "jsdom": "^24.0.0", "npm-run-all2": "^6.1.1", "prettier": "^3.0.3", + "sass": "^1.71.1", "typescript": "~5.3.0", "vite": "^5.0.11", "vitest": "^1.2.2", diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico deleted file mode 100644 index df36fcfb..00000000 Binary files a/frontend/public/favicon.ico and /dev/null differ diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 00000000..9978d0e5 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8aba3c92..54c35ab7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,82 +1,14 @@ - + diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css deleted file mode 100644 index 8710b9ae..00000000 --- a/frontend/src/assets/base.css +++ /dev/null @@ -1,86 +0,0 @@ -/* color palette from */ -:root { - --vt-c-white: #ffffff; - --vt-c-white-soft: #f8f8f8; - --vt-c-white-mute: #f2f2f2; - - --vt-c-black: #181818; - --vt-c-black-soft: #222222; - --vt-c-black-mute: #282828; - - --vt-c-indigo: #2c3e50; - - --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); - --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); - --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); - --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); - - --vt-c-text-light-1: var(--vt-c-indigo); - --vt-c-text-light-2: rgba(60, 60, 60, 0.66); - --vt-c-text-dark-1: var(--vt-c-white); - --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); -} - -/* semantic color variables for this project */ -:root { - --color-background: var(--vt-c-white); - --color-background-soft: var(--vt-c-white-soft); - --color-background-mute: var(--vt-c-white-mute); - - --color-border: var(--vt-c-divider-light-2); - --color-border-hover: var(--vt-c-divider-light-1); - - --color-heading: var(--vt-c-text-light-1); - --color-text: var(--vt-c-text-light-1); - - --section-gap: 160px; -} - -@media (prefers-color-scheme: dark) { - :root { - --color-background: var(--vt-c-black); - --color-background-soft: var(--vt-c-black-soft); - --color-background-mute: var(--vt-c-black-mute); - - --color-border: var(--vt-c-divider-dark-2); - --color-border-hover: var(--vt-c-divider-dark-1); - - --color-heading: var(--vt-c-text-dark-1); - --color-text: var(--vt-c-text-dark-2); - } -} - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - font-weight: normal; -} - -body { - min-height: 100vh; - color: var(--color-text); - background: var(--color-background); - transition: - color 0.5s, - background-color 0.5s; - line-height: 1.6; - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - Roboto, - Oxygen, - Ubuntu, - Cantarell, - 'Fira Sans', - 'Droid Sans', - 'Helvetica Neue', - sans-serif; - font-size: 15px; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} diff --git a/frontend/src/assets/base.scss b/frontend/src/assets/base.scss new file mode 100644 index 00000000..b03199b6 --- /dev/null +++ b/frontend/src/assets/base.scss @@ -0,0 +1,120 @@ +/* color palette from */ +// BUG: color-mod does not seem to work, fix this somehow +:root { + --color-primary: #1d357e; + --color-primary-light: color-mod(var(--color-primary) tint(15%)); + --color-primary-dark: color-mod(var(--color-primary) shade(15%)); + --color-primary-bg: color-mod(var(--color-primary) alpha(30)); + + --color-accent: #ffd200; + --color-accent-light: color-mod(var(--color-accent) tint(15%)); + --color-accent-dark: color-mod(var(--color-accent) shade(15%)); + --color-accent-bg: color-mod(var(--color-accent) alpha(30)); + + --black: #181818; + --gray-10: #222222; + --gray-8: #282828; + --gray-6: #444444; + --gray-4: #666666; + --gray-3: #b0b0b0; + --gray-2: #f2f2f2; + --gray-0: #f8f8f8; + --white: #ffffff; + + --color-success: #88c459; + --color-succes-secondary: color-mod(var(--color-succes) tint(15%)); + --color-error: #b00020; + --color-error-secondary: color-mod(var(--color-warning) tint(15%)); + --color-warning: #ffd137; + --color-warning-secondary: color-mod(var(--color-warning) tint(15%)); +} + +/* semantic color variables for this project */ +:root { + --color-text: var(--gray-10); + --color-text-on-primary: var(--white); + --color-text-on-accent: var(--gray-6); + --color-text-on-success: var(--white); + --color-text-on-error: var(--white); + --color-text-on-warning: var(--black); + --color-text-heading: var(--black); + --color-text-subtle: var(--gray-6); + --color-link: var(--color-primary); + --color-link-visited: var(--color-primary-dark); + --color-mark: var(--color-accent-bg); + --color-blockquote-border: var(--gray-2); + + --color-border: var(--gray-2); + --color-border-hover: var(--color-primary-light); + + --color-body: var(--white); + + --form-element-border: var(--color-border); + --form-element-border-focus: var(--color-border-hover); + --form-element-border-error: var(--color-error); + --form-element-bg: var(--white); + --form-text-placehoder: var(--gray-4); + + --btn-primary-bg: var(--color-primary); + --btn-primary-hover: var(--color-primary-light); + --btn-primary-active: var(--color-primary-dark); + --btn-primary-label: var(--white); + + --color-icon-primary: var(--gray-4); + --color-icon-secondary: inherit; + + --color-background: var(--white); + --color-background-soft: var(--gray-0); + --color-background-mute: var(--gray-2); + + --section-gap: 160px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--gray-10); + --color-background-soft: var(--gray-8); + --color-background-mute: var(--gray-6); + + --color-border: var(--gray-6); + --color-border-hover: var(--gray-4); + + --color-heading: var(--white); + --color-text: var(--gray-0); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: + color 0.5s, + background-color 0.5s; + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Fira Sans", + "Droid Sans", + "Helvetica Neue", + sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css deleted file mode 100644 index 2fe73bb6..00000000 --- a/frontend/src/assets/main.css +++ /dev/null @@ -1,35 +0,0 @@ -@import './base.css'; - -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - font-weight: normal; -} - -a, -.green { - text-decoration: none; - color: hsla(160, 100%, 37%, 1); - transition: 0.4s; - padding: 3px; -} - -@media (hover: hover) { - a:hover { - background-color: hsla(160, 100%, 37%, 0.2); - } -} - -@media (min-width: 1024px) { - body { - display: flex; - place-items: center; - } - - #app { - display: grid; - grid-template-columns: 1fr 1fr; - padding: 0 2rem; - } -} diff --git a/frontend/src/assets/main.scss b/frontend/src/assets/main.scss new file mode 100644 index 00000000..4e7cd616 --- /dev/null +++ b/frontend/src/assets/main.scss @@ -0,0 +1,6 @@ +@import "./base.scss"; + +#app { + margin: 0 auto; + font-weight: normal; +} diff --git a/frontend/src/assets/universiteit-gent-logo-white.png b/frontend/src/assets/universiteit-gent-logo-white.png new file mode 100644 index 00000000..3c9bd56d Binary files /dev/null and b/frontend/src/assets/universiteit-gent-logo-white.png differ diff --git a/frontend/src/components/ApolloHeader.vue b/frontend/src/components/ApolloHeader.vue new file mode 100644 index 00000000..7ec8d81e --- /dev/null +++ b/frontend/src/components/ApolloHeader.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/frontend/src/components/TheWelcome.vue b/frontend/src/components/TheWelcome.vue index 46cf1134..8cc8afd7 100644 --- a/frontend/src/components/TheWelcome.vue +++ b/frontend/src/components/TheWelcome.vue @@ -82,10 +82,10 @@ diff --git a/frontend/src/components/WelcomeItem.vue b/frontend/src/components/WelcomeItem.vue index 8325ed13..c45dee61 100644 --- a/frontend/src/components/WelcomeItem.vue +++ b/frontend/src/components/WelcomeItem.vue @@ -59,7 +59,7 @@ h3 { } .item:before { - content: ' '; + content: " "; border-left: 1px solid var(--color-border); position: absolute; left: 0; @@ -68,7 +68,7 @@ h3 { } .item:after { - content: ' '; + content: " "; border-left: 1px solid var(--color-border); position: absolute; left: 0; diff --git a/frontend/src/components/__tests__/HelloWorld.spec.ts b/frontend/src/components/__tests__/HelloWorld.spec.ts index a4490f08..79c1e3ae 100644 --- a/frontend/src/components/__tests__/HelloWorld.spec.ts +++ b/frontend/src/components/__tests__/HelloWorld.spec.ts @@ -1,11 +1,11 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; -import { mount } from '@vue/test-utils'; -import HelloWorld from '../HelloWorld.vue'; +import { mount } from "@vue/test-utils"; +import HelloWorld from "../HelloWorld.vue"; -describe('HelloWorld', () => { - it('renders properly', () => { - const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } }); - expect(wrapper.text()).toContain('Hello Vitest'); +describe("HelloWorld", () => { + it("renders properly", () => { + const wrapper = mount(HelloWorld, { props: { msg: "Hello Vitest" } }); + expect(wrapper.text()).toContain("Hello Vitest"); }); }); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 841d69f6..af449632 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,14 +1,14 @@ -import './assets/main.css'; +import "./assets/main.scss"; -import { createApp } from 'vue'; -import { createPinia } from 'pinia'; +import { createApp } from "vue"; +import { createPinia } from "pinia"; -import App from './App.vue'; -import router from './router'; +import App from "./App.vue"; +import router from "./router"; const app = createApp(App); app.use(createPinia()); app.use(router); -app.mount('#app'); +app.mount("#app"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6a03024a..7015d323 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,28 +1,64 @@ -import { createRouter, createWebHistory } from 'vue-router'; -import HomeView from '@/views/HomeView.vue'; +import { useAuthStore } from "@/stores/auth-store"; +import { createRouter, createWebHistory } from "vue-router"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { - path: '/', - name: 'home', - component: HomeView, + path: "/", + redirect: { name: "home" }, }, { - path: '/about', - name: 'about', - // route level code-splitting - // this generates a separate chunk (About.[hash].js) for this route - // which is lazy-loaded when the route is visited. - component: () => import('../views/AboutView.vue'), + path: "/about", + name: "about", + component: () => import("../views/AboutView.vue"), }, { - path: '/login', - name: 'Login', - component: () => import('../views/LoginView.vue'), + path: "/login", + name: "login", + component: () => import("../views/LoginView.vue"), + beforeEnter: async (to, from, next) => { + const { isLoggedIn, login, setNext } = useAuthStore(); + if (isLoggedIn) { + router.replace("/home"); + next(); + } + const ticket = to.query.ticket?.toString(); + setNext(from.path); + const redirect = await login(ticket); + if (redirect) { + router.replace(redirect); + } + next(); + }, + meta: { + requiresAuth: false, + hideHeader: true, + }, + }, + { + path: "/home", + name: "home", + component: () => import("../views/UserView.vue"), + }, + { + path: "/:pathMatch(.*)", + name: "default", + component: () => import("../views/NotFoundView.vue"), + meta: { + requiresAuth: false, + }, }, ], }); +router.beforeEach(async (to, _, next) => { + const requiresAuth = to.meta.requiresAuth !== undefined ? to.meta.requiresAuth : true; + const { isLoggedIn } = useAuthStore(); + if (requiresAuth && !isLoggedIn) { + router.replace({ name: "login" }); + } + next(); +}); + export default router; diff --git a/frontend/src/stores/auth-store.ts b/frontend/src/stores/auth-store.ts new file mode 100644 index 00000000..096e567e --- /dev/null +++ b/frontend/src/stores/auth-store.ts @@ -0,0 +1,62 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { useRouter } from "vue-router"; +import { useCASUrl } from "./cas-url"; + +interface Token { + token: string; + token_type: string; +} + +const apiUrl = import.meta.env.VITE_API_URL; + +export const useAuthStore = defineStore("auth", () => { + const storedToken = localStorage.getItem("token"); + const token = ref(storedToken ? JSON.parse(storedToken) : null); + const isLoggedIn = computed(() => token.value !== null && token.value !== undefined); + const { redirectUrl } = useCASUrl(); + const router = useRouter(); + // FIXME: after redirect to CAS server, value is reset -> use query parameter instead? + const next = ref("/home"); + + function setNext(url: string) { + next.value = url; + } + + async function login(ticket?: string): Promise { + if (isLoggedIn.value) { + return next.value; + } + if (ticket) { + try { + const response = await fetch(`${apiUrl}/api/auth/token`, { + method: "POST", + body: JSON.stringify({ + returnUrl: redirectUrl, + ticket: ticket, + }), + headers: { "content-type": "application/json" }, + }); + if (!response.ok) { + throw new Error("Failed to verify ticket"); + } + const new_token = await response.json(); + token.value = new_token; + localStorage.setItem("token", JSON.stringify(token.value)); + return next.value; + } catch (e) { + router.replace({ query: { ticket: null } }); + alert("Failed to login"); + return null; + } + } + return null; + } + + async function logout() { + token.value = null; + localStorage.removeItem("token"); + await router.replace({ name: "login" }); + } + return { token, isLoggedIn, login, logout, setNext }; +}); diff --git a/frontend/src/stores/cas-url.ts b/frontend/src/stores/cas-url.ts new file mode 100644 index 00000000..14856736 --- /dev/null +++ b/frontend/src/stores/cas-url.ts @@ -0,0 +1,40 @@ +import { defineStore } from "pinia"; +import { ref, watch } from "vue"; + +interface Authority { + authority: string; + method: string; +} + +export const useCASUrl = defineStore("cas_url", () => { + const redirectUrl = ref("/login"); // TODO: this should not be a hardcoded ref, is registrated to CAS server + const CASUrl = ref(""); + + async function fetchAuthority(): Promise { + try { + const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/authority`); + if (!response.ok) { + throw new Error("Failed to fetch authority"); + } + return await response.json(); + } catch (error) { + console.error("Error fetching authority:", error); + return null; + } + } + + async function updateCASUrl() { + const authority = await fetchAuthority(); + if (!authority) { + return; + } + if (authority.method.toLowerCase() !== "cas") { + console.error("Authority is not a CAS server"); + return; + } + CASUrl.value = `${authority.authority}?service=${encodeURIComponent(`${import.meta.env.VITE_APP_URL}${redirectUrl.value}`)}`; + } + watch(redirectUrl, updateCASUrl, { immediate: true }); + + return { CASUrl, redirectUrl }; +}); diff --git a/frontend/src/stores/counter.ts b/frontend/src/stores/counter.ts deleted file mode 100644 index a4c345e0..00000000 --- a/frontend/src/stores/counter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ref, computed } from 'vue'; -import { defineStore } from 'pinia'; - -export const useCounterStore = defineStore('counter', () => { - const count = ref(0); - const doubleCount = computed(() => count.value * 2); - function increment() { - count.value++; - } - - return { count, doubleCount, increment }; -}); diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index a6ac7202..ce954f74 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -5,5 +5,5 @@ diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 750bab3e..7684b7ee 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,31 +1,34 @@ - + diff --git a/frontend/src/views/NotFoundView.vue b/frontend/src/views/NotFoundView.vue new file mode 100644 index 00000000..2f63c101 --- /dev/null +++ b/frontend/src/views/NotFoundView.vue @@ -0,0 +1,9 @@ + + + + + diff --git a/frontend/src/views/UserView.vue b/frontend/src/views/UserView.vue new file mode 100644 index 00000000..d3bba584 --- /dev/null +++ b/frontend/src/views/UserView.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index e14c754d..b9410bd8 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -1,6 +1,7 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", - "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "include": [ + "vite-env.d.ts", "src/**/*", "src/**/*.vue"], "exclude": ["src/**/__tests__/*"], "compilerOptions": { "composite": true, @@ -9,6 +10,11 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"] - } + }, + "typeRoots": [ + "./node_modules/@types/", + "./node_modules" + + ] } } diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts new file mode 100644 index 00000000..2cc70612 --- /dev/null +++ b/frontend/vite-env.d.ts @@ -0,0 +1,9 @@ +/// +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_APP_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5c45e1d9..372f1a88 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,15 +2,23 @@ import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import fs from "fs"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [ - vue(), - ], - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - } - } + plugins: [ + vue(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + https: { + key: fs.readFileSync('./local-cert/localhost-key.pem'), + cert: fs.readFileSync('./local-cert/localhost.pem') + }, + port: 8080, + }, })