diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..aaab0cb --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,72 @@ +FROM ubuntu:22.04 + +# Setup workspace directory +RUN mkdir /workspace +WORKDIR /workspace + +# Install useful system utilities +ENV TZ=America/New_York +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install --yes \ + apt-transport-https \ + ca-certificates \ + curl \ + debian-keyring \ + debian-archive-keyring \ + git \ + gnupg \ + locales \ + postgresql-client \ + software-properties-common \ + sudo \ + tzdata \ + wget \ + zsh \ + && rm -rf /var/lib/apt/lists/* + +# Install Python 3.11 +RUN add-apt-repository ppa:deadsnakes/ppa \ + && apt update \ + && apt install --yes \ + python3.11 \ + python3-pip \ + libpq-dev \ + python3.11-dev \ + && rm -rf /var/lib/apt/lists* \ + && unlink /usr/bin/python3 \ + && ln -s /usr/bin/python3.11 /usr/bin/python3 + +# Use a non-root user per https://code.visualstudio.com/remote/advancedcontainers/add-nonroot-user +ARG USERNAME=vscode +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +# Add non-root user and add to sudoers +RUN groupadd --gid $USER_GID $USERNAME \ + && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME -s /usr/bin/zsh \ + && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +# Set code to default git commit editor +RUN git config --system core.editor "code --wait" +# Set Safe Directory +RUN git config --system safe.directory '/workspace' + +# Configure zsh +USER $USERNAME +ENV HOME /home/$USERNAME + +# Add zsh theme with niceties +RUN curl https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh | bash - \ + && sed -i 's/robbyrussell/kennethreitz/g' ~/.zshrc \ + && echo 'source <(ng completion script)' >>~/.zshrc \ + && echo 'export PATH=$PATH:$HOME/.local/bin' >>~/.zshrc + +# Set Locale for Functional Autocompletion in zsh +RUN sudo locale-gen en_US.UTF-8 + +# Install Database Dependencies +COPY backend/requirements.txt /workspace/backend/requirements.txt +WORKDIR /workspace/backend +RUN python3 -m pip install -r requirements.txt \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..793f5d8 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,47 @@ +{ + "dockerComposeFile": "docker-compose.yml", + "workspaceFolder": "/workspace", + "service": "httpd", + "remoteUser": "vscode", + "forwardPorts": [ + 5432, + 5050 + ], + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "ecmel.vscode-html-css", + "ms-vscode.vscode-typescript-next", + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss", + "vscode-icons-team.vscode-icons", + "tamasfe.even-better-toml", + "ckolkman.vscode-postgres", + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.black-formatter", + "gruntfuggly.todo-tree", + "ms-azuretools.vscode-docker" + ], + "settings": { + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "python.analysis.extraPaths": [ + "/backend/" + ], + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "python.analysis.diagnosticSeverityOverrides": { + "reportMissingParameterType": "error", + "reportGeneralTypeIssues": "error", + "reportDeprecated": "error", + "reportImportCycles": "error" + } + } + } + } +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..1503d5b --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,35 @@ +version: "3" + +services: + httpd: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + volumes: + - ..:/workspace + command: /bin/sh -c "while sleep 1000; do :; done" + environment: + - windir # Defined on Windows but not on other platforms + + db: + image: "postgres:latest" + ports: + - "5432:5432" + env_file: + - ../backend/.env + volumes: + - compass-center-postgres:/var/lib/postgresql/data + # - ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + + pgadmin: + image: dpage/pgadmin4:latest + environment: + PGADMIN_DEFAULT_EMAIL: admin@example.com + PGADMIN_DEFAULT_PASSWORD: admin + ports: + - "5050:80" + depends_on: + - db + +volumes: + compass-center-postgres: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c303a94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/backend/.env +__pycache__ \ No newline at end of file diff --git a/README.md b/README.md index d5e8b31..21b84a4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # 🧭 Compass Center's Internal Resource Management App ## 🛠 Technologies + - Next.js - TailwindCSS - TypeScript - Supabase ## 📁 File Setup + ``` \compass \components // Components organized in folders related to specific pages @@ -18,9 +20,11 @@ ``` ## 🚀 To Start + Follow these steps to set up your local environment: + ``` -\\ Clone this repository +\\ Clone this repository git clone https://github.com/cssgunc/compass.git \\ Go into main folder cd compass @@ -30,6 +34,72 @@ npm install npm run dev ``` +Also add following variables inside of a .env file inside of the backend directory + +``` +\\ .env file contents + +POSTGRES_DATABASE=compass +POSTGRES_USER=postgres +POSTGRES_PASSWORD=admin +POSTGRES_HOST=db +POSTGRES_PORT=5432 +HOST=localhost +``` + +## Backend Starter + +- Please open the VS Code Command Palette +- Run the command **Dev Containers: Rebuild and Reopen in Container** +- This should open the dev container with the same file directory mounted so any changes in the dev container will be seen in the local repo + +### In Dev Container + +Run this to reset the database and populate it with the approprate tables that reflect the entities folder +``` +python3 -m backend.script.reset_demo +``` + +### Possible Dev Container Errors + +- Sometimes the ports allocated to our services will be allocated (5432 for Postgres and 5050 for PgAdmin4) +- Run **docker stop** to stop all containers +- If that does not work using **sudo lsof -i :[PORT_NUMBER]** to find the process running on the needed ports and idenitfy the PID +- Run **sudo kill [PID]** +- If you are on Windows please consult ChatGPT or set up WSL (will be very useful in the future) + +### Accesing pgAdmin 4 + +- First go to http://localhost:5050/ on your browser +- Log in using the credentials admin@example.com and admin +- Click **Add New Server** +- Fill in the name field with Compass (can be anything) +- Click **Connection** tab and fill in the following: + - Host name/address: db + - Maintence database: compass + - Username: postgres + - Password: admin +- Click **Save** at the bottom to add connection +- Click **Server** dropdown on the left and click through items inside the **Compass** server + +### Testing Backend Code + +- Write tests for any service you create and any function in those services +- Make sure to add docstrings detailing what the file is doing and what each test is doing +- Name all test functions with test\_[testContent] (Must be prefixed with test to be recognized by pytest) +- Utitlize dependency injection for commonly used services + +``` +\\ Run all tests by being in the backend directory +pytest + +\\ Run specific tests by passing in file as a parameter +\\ Passing the -s allows us to see any print statements or debugging statements in the console +pytest -s --rootdir=/workspace [testFilePath]::[testFunctionSignature] +``` + ## 💡 Dev Notes + - For each task, create a branch in the format '[your name]-[ticket number]-[task description]' - Only commit your work to that branch and then make a git request to '/main' +- When creating new files in the backend and code is in python make sure to add a docstring for the file and any function you create ("""[content]"""") diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..b53d444 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,29 @@ +"""SQLAlchemy DB Engine and Session niceties for FastAPI dependency injection.""" + +import sqlalchemy +from sqlalchemy.orm import Session +from .env import getenv + + +def _engine_str(database: str = getenv("POSTGRES_DATABASE")) -> str: + """Helper function for reading settings from environment variables to produce connection string.""" + dialect = "postgresql+psycopg2" + user = getenv("POSTGRES_USER") + password = getenv("POSTGRES_PASSWORD") + host = getenv("POSTGRES_HOST") + port = getenv("POSTGRES_PORT") + return f"{dialect}://{user}:{password}@{host}:{port}/{database}" + + +engine = sqlalchemy.create_engine(_engine_str(), echo=True) +"""Application-level SQLAlchemy database engine.""" + + +def db_session(): + """Generator function offering dependency injection of SQLAlchemy Sessions.""" + print("ran") + session = Session(engine) + try: + yield session + finally: + session.close() diff --git a/backend/entities/__init__.py b/backend/entities/__init__.py new file mode 100644 index 0000000..39abb6f --- /dev/null +++ b/backend/entities/__init__.py @@ -0,0 +1,2 @@ +from .entity_base import EntityBase +from .sample_entity import SampleEntity diff --git a/backend/entities/entity_base.py b/backend/entities/entity_base.py new file mode 100644 index 0000000..5e34685 --- /dev/null +++ b/backend/entities/entity_base.py @@ -0,0 +1,12 @@ +"""Abstract superclass of all entities in the application. + +There is no reason to instantiate this class directly. Instead, look toward the child classes. +Additionally, import from the top-level entities file which indexes all entity implementations. +""" + + +from sqlalchemy.orm import DeclarativeBase + + +class EntityBase(DeclarativeBase): + pass diff --git a/backend/entities/sample_entity.py b/backend/entities/sample_entity.py new file mode 100644 index 0000000..4759694 --- /dev/null +++ b/backend/entities/sample_entity.py @@ -0,0 +1,12 @@ +from sqlalchemy import create_engine, Column, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from .entity_base import EntityBase + + +class SampleEntity(EntityBase): + __tablename__ = "persons" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String, nullable=False) + age: Mapped[int] = mapped_column(Integer) + email: Mapped[str] = mapped_column(String, unique=True, nullable=False) diff --git a/backend/env.py b/backend/env.py new file mode 100644 index 0000000..356efb7 --- /dev/null +++ b/backend/env.py @@ -0,0 +1,21 @@ +"""Load environment variables from a .env file or the process' environment.""" + +import os +import dotenv + +# Load envirnment variables from .env file upon module start. +dotenv.load_dotenv(f"{os.path.dirname(__file__)}/.env", verbose=True) + + +def getenv(variable: str) -> str: + """Get value of environment variable or raise an error if undefined. + + Unlike `os.getenv`, our application expects all environment variables it needs to be defined + and we intentionally fast error out with a diagnostic message to avoid scenarios of running + the application when expected environment variables are not set. + """ + value = os.getenv(variable) + if value is not None: + return value + else: + raise NameError(f"Error: {variable} Environment Variable not Defined") diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..5c532a5 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi[all] >=0.100.0, <0.101.0 +sqlalchemy >=2.0.4, <2.1.0 +psycopg2 >=2.9.5, <2.10.0 +alembic >=1.10.2, <1.11.0 +pytest >=7.2.1, <7.3.0 +python-dotenv >=1.0.0, <1.1.0 \ No newline at end of file diff --git a/backend/script/__init__.py b/backend/script/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/script/create_database.py b/backend/script/create_database.py new file mode 100644 index 0000000..197176f --- /dev/null +++ b/backend/script/create_database.py @@ -0,0 +1,14 @@ +from sqlalchemy import text, create_engine +from ..database import engine, _engine_str +from ..env import getenv + +engine = create_engine(_engine_str(database=""), echo=True) +"""Application-level SQLAlchemy database engine.""" + +with engine.connect() as connection: + connection.execute( + text("COMMIT") + ) + database = getenv("POSTGRES_DATABASE") + stmt = text(f"CREATE DATABASE {database}") + connection.execute(stmt) \ No newline at end of file diff --git a/backend/script/delete_database.py b/backend/script/delete_database.py new file mode 100644 index 0000000..44b3f6a --- /dev/null +++ b/backend/script/delete_database.py @@ -0,0 +1,14 @@ +from sqlalchemy import text, create_engine +from ..database import engine, _engine_str +from ..env import getenv + +engine = create_engine(_engine_str(database=""), echo=True) +"""Application-level SQLAlchemy database engine.""" + +with engine.connect() as connection: + connection.execute( + text("COMMIT") + ) + database = getenv("POSTGRES_DATABASE") + stmt = text(f"DROP DATABASE IF EXISTS {database}") + connection.execute(stmt) \ No newline at end of file diff --git a/backend/script/reset_demo.py b/backend/script/reset_demo.py new file mode 100644 index 0000000..da06da8 --- /dev/null +++ b/backend/script/reset_demo.py @@ -0,0 +1,18 @@ +from sqlalchemy import create_engine +import subprocess + +from ..database import engine, _engine_str +from ..env import getenv +from .. import entities + +database = getenv("POSTGRES_DATABASE") + +engine = create_engine(_engine_str(), echo=True) +"""Application-level SQLAlchemy database engine.""" + +# Run Delete and Create Database Scripts +subprocess.run(["python3", "-m", "backend.script.delete_database"]) +subprocess.run(["python3", "-m", "backend.script.create_database"]) + +entities.EntityBase.metadata.drop_all(engine) +entities.EntityBase.metadata.create_all(engine) diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/test/__init__.py b/backend/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/test/entities/__init__.py b/backend/test/entities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/test/entities/conftest.py b/backend/test/entities/conftest.py new file mode 100644 index 0000000..5b2a984 --- /dev/null +++ b/backend/test/entities/conftest.py @@ -0,0 +1,50 @@ +"""Shared pytest fixtures for database dependent tests.""" + +import pytest +from sqlalchemy import Engine, create_engine, text +from sqlalchemy.orm import Session +from sqlalchemy.exc import OperationalError + +from ...database import _engine_str +from ...env import getenv +from ... import entities + +POSTGRES_DATABASE = f'{getenv("POSTGRES_DATABASE")}_test' +POSTGRES_USER = getenv("POSTGRES_USER") + +def reset_database(): + engine = create_engine(_engine_str(database="")) + with engine.connect() as connection: + try: + conn = connection.execution_options(autocommit=False) + conn.execute(text("ROLLBACK")) # Get out of transactional mode... + conn.execute(text(f"DROP DATABASE IF EXISTS {POSTGRES_DATABASE}")) + except OperationalError: + print( + "Could not drop database because it's being accessed by others (psql open?)" + ) + exit(1) + + conn.execute(text(f"CREATE DATABASE {POSTGRES_DATABASE}")) + conn.execute( + text( + f"GRANT ALL PRIVILEGES ON DATABASE {POSTGRES_DATABASE} TO {POSTGRES_USER}" + ) + ) + + +@pytest.fixture(scope="session") +def test_engine() -> Engine: + reset_database() + return create_engine(_engine_str(POSTGRES_DATABASE)) + + +@pytest.fixture(scope="function") +def session(test_engine: Engine): + entities.EntityBase.metadata.drop_all(test_engine) + entities.EntityBase.metadata.create_all(test_engine) + session = Session(test_engine) + try: + yield session + finally: + session.close() diff --git a/backend/test/entities/sample_test.py b/backend/test/entities/sample_test.py new file mode 100644 index 0000000..f21423e --- /dev/null +++ b/backend/test/entities/sample_test.py @@ -0,0 +1,21 @@ +"""Sample Test File""" + +from sqlalchemy import Engine, select + +from ... import entities +from ...entities.sample_entity import SampleEntity + + +def test_entity_count(): + """Checks the number of entities to be inserted""" + print(entities.EntityBase.metadata.tables.keys()) + assert len(entities.EntityBase.metadata.tables.keys()) == 1 + + +def test_add_sample_data(session: Engine): + """Inserts a sample data point and verifies it is in the database""" + entity = SampleEntity(name="Praj", age=19, email="pmoha@unc.edu") + session.add(entity) + session.commit() + data = session.get(SampleEntity, 1) + assert data.name == "Praj" diff --git a/backend/test/models/__init__.py b/backend/test/models/__init__.py new file mode 100644 index 0000000..e69de29