From 9ddc098587f6caffc5804767700683b64984b88e Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:14:54 +0700 Subject: [PATCH 01/14] update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index eed77de..6cc664f 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,4 @@ Request JSON Format: * โฑ๏ธ The API will be available at `http://127.0.0.1:` * ๐Ÿ“ Folder IDs correspond to folders under `tests/`. +The test will be further update as database From c88ad5a20ecfe2a3fb193f69d545d2fc38b9b4e7 Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:16:24 +0700 Subject: [PATCH 02/14] update README --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 6cc664f..a06b77e 100644 --- a/README.md +++ b/README.md @@ -62,5 +62,4 @@ Request JSON Format: * โฑ๏ธ Default port is `3000` change port as needed in `docker-compose.yml`. * โฑ๏ธ The API will be available at `http://127.0.0.1:` * ๐Ÿ“ Folder IDs correspond to folders under `tests/`. - -The test will be further update as database +NOTE: The test will be further update as database From 315237ea56e5fb35424d68f787207a787f10ea59 Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:30:15 +0700 Subject: [PATCH 03/14] update github workflow --- .github/workflows/auto-release.yml | 47 +++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 5d8945b..14ef5e8 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -11,15 +11,28 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout Code + uses: actions/checkout@v4 with: - fetch-depth: 0 - - - name: Get latest tag + fetch-depth: 0 + + - name: Get latest tag from remote id: get_tag run: | - tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v1.0.0") + # Fetch all tags to ensure we see tags even if they aren't in this branch's history + git fetch --tags --force + + # Find the highest version tag using natural version sorting + # Filters for tags starting with 'v' + tag=$(git tag -l "v*" --sort=-v:refname | head -n 1) + + # If no tag is found, start at v1.0.0 + if [ -z "$tag" ]; then + tag="v1.0.0" + fi + echo "latest_tag=$tag" >> $GITHUB_OUTPUT + echo "Found latest tag: $tag" - name: Bump version (semantic roll over) id: bump @@ -28,6 +41,7 @@ jobs: base=${tag#v} IFS='.' read -r major minor patch <<< "$base" + # Logic: Rolls over to next decimal place if value is 9 if [ "$patch" -lt 9 ]; then patch=$((patch + 1)) else @@ -42,26 +56,31 @@ jobs: new_tag="v${major}.${minor}.${patch}" echo "new_tag=$new_tag" >> $GITHUB_OUTPUT + echo "New calculated tag: $new_tag" - name: Generate changelog id: changelog run: | prev=${{ steps.get_tag.outputs.latest_tag }} - new=${{ steps.bump.outputs.new_tag }} - - # If there was no previous tag, show all commits - if [ "$prev" = "v1.0.0" ]; then - log=$(git log --pretty=format:"- %s") - else + + # If the latest_tag was the fallback v1.0.0 and doesn't actually exist in git + if git rev-parse "$prev" >/dev/null 2>&1; then log=$(git log $prev..HEAD --pretty=format:"- %s") + else + log=$(git log --pretty=format:"- %s") fi + # Handle empty logs + if [ -z "$log" ]; then log="- No changes documented."; fi + echo "changelog<> $GITHUB_OUTPUT echo "$log" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - - name: Create new tag + - name: Create and Push Tag run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" git tag ${{ steps.bump.outputs.new_tag }} git push origin ${{ steps.bump.outputs.new_tag }} @@ -69,9 +88,9 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.bump.outputs.new_tag }} - name: "${{ steps.bump.outputs.new_tag }}" + name: "Release ${{ steps.bump.outputs.new_tag }}" body: | ## Changes since last release ${{ steps.changelog.outputs.changelog }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From ae7b3c1f22c7c1fcc6d5678c8550ac2b60daf66e Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:31:49 +0700 Subject: [PATCH 04/14] update auto release --- .github/workflows/auto-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 14ef5e8..ce347e6 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - + - name: Get latest tag from remote id: get_tag run: | @@ -88,7 +88,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.bump.outputs.new_tag }} - name: "Release ${{ steps.bump.outputs.new_tag }}" + name: "${{ steps.bump.outputs.new_tag }}" body: | ## Changes since last release ${{ steps.changelog.outputs.changelog }} From c4d3c4205d000802581b5bb51823118cc9191d16 Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Mon, 9 Feb 2026 00:23:34 +0700 Subject: [PATCH 05/14] feat: Introduce database integration for problems and test cases, refactor application structure to `src/`, add new API endpoints, and include Bruno API collections. --- .dockerignore | 21 +++++ .env.example | 8 ++ .gitignore | 1 + API/requirements.txt | 2 - API/tests/README.md | 52 ------------ Dockerfile | 30 +++++-- bruno/bruno.json | 9 ++ bruno/environments/code-exec.bru | 3 + bruno/local/GET- Problems/folder.bru | 7 ++ .../local/GET- Problems/get all problems.bru | 16 ++++ .../GET- Problems/get problems by id.bru | 16 ++++ bruno/local/POST- Send Code/folder.bru | 8 ++ .../POST- Send Code/send code failed.bru | 23 +++++ .../local/POST- Send Code/send code pass.bru | 23 +++++ bruno/local/folder.bru | 8 ++ bruno/prod/POST- Send Code/folder.bru | 8 ++ bruno/prod/POST- Send Code/send code.bru | 23 +++++ bruno/prod/folder.bru | 8 ++ docker-compose.yml | 32 ++++++- migrations/0001_initial_schema.py | 58 +++++++++++++ migrations/0002_seed_data.py | 48 +++++++++++ migrations/0003_multiply_problem.py | 37 +++++++++ {API => src}/app.py | 32 +++++++ {API => src}/config.py | 23 ++++- src/db.py | 83 +++++++++++++++++++ {API => src}/executor.py | 0 {API => src}/html/404.html | 0 {API => src}/html/index.html | 0 src/requirements.txt | 4 + 29 files changed, 513 insertions(+), 70 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example delete mode 100644 API/requirements.txt delete mode 100644 API/tests/README.md create mode 100644 bruno/bruno.json create mode 100644 bruno/environments/code-exec.bru create mode 100644 bruno/local/GET- Problems/folder.bru create mode 100644 bruno/local/GET- Problems/get all problems.bru create mode 100644 bruno/local/GET- Problems/get problems by id.bru create mode 100644 bruno/local/POST- Send Code/folder.bru create mode 100644 bruno/local/POST- Send Code/send code failed.bru create mode 100644 bruno/local/POST- Send Code/send code pass.bru create mode 100644 bruno/local/folder.bru create mode 100644 bruno/prod/POST- Send Code/folder.bru create mode 100644 bruno/prod/POST- Send Code/send code.bru create mode 100644 bruno/prod/folder.bru create mode 100644 migrations/0001_initial_schema.py create mode 100644 migrations/0002_seed_data.py create mode 100644 migrations/0003_multiply_problem.py rename {API => src}/app.py (59%) rename {API => src}/config.py (74%) create mode 100644 src/db.py rename {API => src}/executor.py (100%) rename {API => src}/html/404.html (100%) rename {API => src}/html/index.html (100%) create mode 100644 src/requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..144419e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.env +.git +.gitignore +.dockerignore +node_modules/ +tests/ +code-exec-bruno/ +README.md +docker-compose.yml +Dockerfile +postgres/ +yoyo.ini +yoyo.ini.lock +scripts/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cd8bdef --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Database Configuration +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=code_executor + +# Database Connection URL (used by yoyo-migrations) +# Format: postgresql://user:password@host:port/dbname +DATABASE_URL=postgresql://postgres:postgres@db:5432/code_executor diff --git a/.gitignore b/.gitignore index c5462ba..fac4b35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env */tests/*/ */__pycache__/ *.class diff --git a/API/requirements.txt b/API/requirements.txt deleted file mode 100644 index afd93b9..0000000 --- a/API/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask==3.0.3 -gunicorn==23.0.0 \ No newline at end of file diff --git a/API/tests/README.md b/API/tests/README.md deleted file mode 100644 index a806cdf..0000000 --- a/API/tests/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# ๐Ÿงช Test Case Guidelines - -Each question is stored in a folder named with a number, for example: `1`, `2`, `3`. - -## ๐Ÿ“‚ Folder Structure - -``` -tests/ -โ”œโ”€โ”€ 1/ -โ”‚ โ”œโ”€โ”€ 1.in # ๐Ÿ“ Input for test case 1 -โ”‚ โ”œโ”€โ”€ 1.out # โœ… Expected output for test case 1 -โ”‚ โ”œโ”€โ”€ 2.in -โ”‚ โ””โ”€โ”€ 2.out -โ”œโ”€โ”€ 2/ -โ”‚ โ”œโ”€โ”€ 1.in -โ”‚ โ”œโ”€โ”€ 1.out -โ”‚ โ””โ”€โ”€ config.json # โš™๏ธ Optional: timeout, rules, templates -``` - ---- -- **Folder name** = `folder_id` used in the API endpoint. -- `.in` โ†’ Input file. -- `.out` โ†’ Expected output file. -- Optional **`config.json`** can define: - - โฑ๏ธ Timeout - - ๐Ÿงฉ Default code templates - - ๐Ÿงฎ Validation rules - -## ๐Ÿงพ Example `config.json` - -```json -{ - "timeout": 10, - "templates": { - "python": "def main():\n __CODE_GOES_HERE__\n\nif __name__ == '__main__':\n main()" - }, - "rules": { - "python": [ - "\\w+\\s*=\\s*\\[.*\\]", - "len\\(\\w+\\)" - ] - } -} -``` - -## โž• Adding a New Test Case - -1. Create a new folder with the next number. -2. Add `.in` files for inputs. -3. Add `.out` files for expected outputs. -4. Make sure input and output filenames match. -4. Add `config.json` if special rules or timeout are needed. diff --git a/Dockerfile b/Dockerfile index ebe550d..a339643 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,37 @@ FROM debian:bookworm-slim +# Install system dependencies RUN apt-get update && \ apt-get install -y --no-install-recommends \ - build-essential \ - default-jdk \ - python3 \ - python3-pip \ - nodejs \ - npm && \ + build-essential \ + default-jdk \ + python3 \ + python3-pip \ + nodejs \ + npm && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* +# Fix python naming RUN update-alternatives --install /usr/bin/python python /usr/bin/python3 1 +# Create a non-root user +RUN useradd -m coder && \ + mkdir -p /usr/src/app && \ + chown coder:coder /usr/src/app + WORKDIR /usr/src/app -COPY API/requirements.txt . +# Install python dependencies as root (system-wide) +COPY src/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt --break-system-packages -COPY API/ . +# Copy application source +COPY --chown=coder:coder src/ . +COPY --chown=coder:coder migrations/ ./migrations/ + +USER coder EXPOSE 3000 -CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:3000", "--timeout", "60", "app:app"] +CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:3000", "--timeout", "60", "--access-logfile", "-", "app:app"] diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..b271425 --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "bruno", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/bruno/environments/code-exec.bru b/bruno/environments/code-exec.bru new file mode 100644 index 0000000..484251d --- /dev/null +++ b/bruno/environments/code-exec.bru @@ -0,0 +1,3 @@ +vars:secret [ + code-exec-url +] diff --git a/bruno/local/GET- Problems/folder.bru b/bruno/local/GET- Problems/folder.bru new file mode 100644 index 0000000..8b9fca9 --- /dev/null +++ b/bruno/local/GET- Problems/folder.bru @@ -0,0 +1,7 @@ +meta { + name: GET: Problems +} + +auth { + mode: inherit +} diff --git a/bruno/local/GET- Problems/get all problems.bru b/bruno/local/GET- Problems/get all problems.bru new file mode 100644 index 0000000..ddc1a85 --- /dev/null +++ b/bruno/local/GET- Problems/get all problems.bru @@ -0,0 +1,16 @@ +meta { + name: get all problems + type: http + seq: 1 +} + +get { + url: http://localhost:3000/problems + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/local/GET- Problems/get problems by id.bru b/bruno/local/GET- Problems/get problems by id.bru new file mode 100644 index 0000000..1acda17 --- /dev/null +++ b/bruno/local/GET- Problems/get problems by id.bru @@ -0,0 +1,16 @@ +meta { + name: get problems by id + type: http + seq: 2 +} + +get { + url: http://localhost:3000/problem/b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/local/POST- Send Code/folder.bru b/bruno/local/POST- Send Code/folder.bru new file mode 100644 index 0000000..b07b899 --- /dev/null +++ b/bruno/local/POST- Send Code/folder.bru @@ -0,0 +1,8 @@ +meta { + name: POST: Send Code + seq: 2 +} + +auth { + mode: inherit +} diff --git a/bruno/local/POST- Send Code/send code failed.bru b/bruno/local/POST- Send Code/send code failed.bru new file mode 100644 index 0000000..2c1f1a8 --- /dev/null +++ b/bruno/local/POST- Send Code/send code failed.bru @@ -0,0 +1,23 @@ +meta { + name: send code failed + type: http + seq: 1 +} + +post { + url: http://localhost:3000/code/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 + body: json + auth: inherit +} + +body:json { + { + "code": "num1, num2 = map(int, input().split())\nprint(num1 * num2)", + "language": "python" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/local/POST- Send Code/send code pass.bru b/bruno/local/POST- Send Code/send code pass.bru new file mode 100644 index 0000000..216200e --- /dev/null +++ b/bruno/local/POST- Send Code/send code pass.bru @@ -0,0 +1,23 @@ +meta { + name: send code pass + type: http + seq: 2 +} + +post { + url: http://localhost:3000/code/b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d + body: json + auth: inherit +} + +body:json { + { + "code": "num1, num2 = map(int, input().split())\nprint(num1 * num2)", + "language": "python" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/local/folder.bru b/bruno/local/folder.bru new file mode 100644 index 0000000..5bcd900 --- /dev/null +++ b/bruno/local/folder.bru @@ -0,0 +1,8 @@ +meta { + name: local + seq: 1 +} + +auth { + mode: inherit +} diff --git a/bruno/prod/POST- Send Code/folder.bru b/bruno/prod/POST- Send Code/folder.bru new file mode 100644 index 0000000..0c2d31c --- /dev/null +++ b/bruno/prod/POST- Send Code/folder.bru @@ -0,0 +1,8 @@ +meta { + name: POST: Send Code + seq: 1 +} + +auth { + mode: inherit +} diff --git a/bruno/prod/POST- Send Code/send code.bru b/bruno/prod/POST- Send Code/send code.bru new file mode 100644 index 0000000..6b4fdbc --- /dev/null +++ b/bruno/prod/POST- Send Code/send code.bru @@ -0,0 +1,23 @@ +meta { + name: send code + type: http + seq: 1 +} + +post { + url: {{code-exec-url}}/code/b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d + body: json + auth: inherit +} + +body:json { + { + "code": "num1, num2 = map(int, input().split())\nprint(num1 * num2)", + "language": "python" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/prod/folder.bru b/bruno/prod/folder.bru new file mode 100644 index 0000000..ed47821 --- /dev/null +++ b/bruno/prod/folder.bru @@ -0,0 +1,8 @@ +meta { + name: prod + seq: 2 +} + +auth { + mode: inherit +} diff --git a/docker-compose.yml b/docker-compose.yml index d895b4a..586f79b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,38 @@ services: + db: + image: postgres:16-alpine + restart: always + environment: + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_DB=${POSTGRES_DB:-code_executor} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-code_executor}" ] + interval: 5s + timeout: 5s + retries: 5 + code-api: image: code-api:latest - build: context: . dockerfile: ./Dockerfile - ports: - "3000:3000" - restart: always stdin_open: true - tty: true \ No newline at end of file + tty: true + depends_on: + db: + condition: service_healthy + environment: + - DATABASE_URL=${DATABASE_URL} + command: > + sh -c "yoyo apply --database $$DATABASE_URL ./migrations -b && gunicorn --bind 0.0.0.0:3000 --access-logfile - app:app" + +volumes: + postgres_data: diff --git a/migrations/0001_initial_schema.py b/migrations/0001_initial_schema.py new file mode 100644 index 0000000..63b895f --- /dev/null +++ b/migrations/0001_initial_schema.py @@ -0,0 +1,58 @@ +from yoyo import step + +__depends__ = {} + +steps = [ + step( + """ + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + + -- Problems Table + CREATE TABLE IF NOT EXISTS problems ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + difficulty VARCHAR(20), -- easy, medium, hard + config JSONB, -- Stores timeout, templates, and rules + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + -- Test Cases Table + CREATE TABLE IF NOT EXISTS test_cases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + problem_id UUID REFERENCES problems(id) ON DELETE CASCADE, + input TEXT, + output TEXT, + is_hidden BOOLEAN DEFAULT TRUE, + sort_order INT + ); + + -- Categories Table + CREATE TABLE IF NOT EXISTS categories ( + id SERIAL PRIMARY KEY, + name VARCHAR UNIQUE NOT NULL + ); + + -- Tags Table + CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + name VARCHAR UNIQUE NOT NULL + ); + + -- Join Table: Many-to-Many for Categories + CREATE TABLE IF NOT EXISTS problem_categories ( + problem_id UUID REFERENCES problems(id) ON DELETE CASCADE, + category_id INT REFERENCES categories(id) ON DELETE CASCADE, + PRIMARY KEY (problem_id, category_id) + ); + + -- Join Table: Many-to-Many for Tags + CREATE TABLE IF NOT EXISTS problem_tags ( + problem_id UUID REFERENCES problems(id) ON DELETE CASCADE, + tag_id INT REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (problem_id, tag_id) + ); + """, + "DROP TABLE IF EXISTS problem_tags; DROP TABLE IF EXISTS problem_categories; DROP TABLE IF EXISTS tags; DROP TABLE IF EXISTS categories; DROP TABLE IF EXISTS test_cases; DROP TABLE IF EXISTS problems;" + ) +] diff --git a/migrations/0002_seed_data.py b/migrations/0002_seed_data.py new file mode 100644 index 0000000..a21e7e6 --- /dev/null +++ b/migrations/0002_seed_data.py @@ -0,0 +1,48 @@ +from yoyo import step + +__depends__ = {"0001_initial_schema"} + +steps = [ + step( + """ + -- Seed Categories + INSERT INTO categories (name) VALUES ('Array'), ('String'), ('Math') ON CONFLICT (name) DO NOTHING; + + -- Seed Tags + INSERT INTO tags (name) VALUES ('Easy'), ('LeetCode'), ('Classic') ON CONFLICT (name) DO NOTHING; + + -- Seed Problem: Two Sum + INSERT INTO problems (id, title, description, difficulty, config) + VALUES ( + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + 'Two Sum', + 'Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.', + 'easy', + '{"timeout": 5, "templates": {"python": "def twoSum(nums, target):\\n pass"}}' + ) ON CONFLICT (id) DO NOTHING; + + -- Seed Test Cases for Two Sum + INSERT INTO test_cases (problem_id, input, output, is_hidden, sort_order) + VALUES + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '[2,7,11,15]\\n9', '[0,1]', false, 1), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '[3,2,4]\\n6', '[1,2]', false, 2), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '[3,3]\\n6', '[0,1]', true, 3) + ON CONFLICT (id) DO NOTHING; + + -- Link Problem to Category and Tag + INSERT INTO problem_categories (problem_id, category_id) + SELECT 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', id FROM categories WHERE name = 'Array' + ON CONFLICT DO NOTHING; + + INSERT INTO problem_tags (problem_id, tag_id) + SELECT 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', id FROM tags WHERE name = 'Easy' + ON CONFLICT DO NOTHING; + """, + """ + DELETE FROM problem_tags WHERE problem_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; + DELETE FROM problem_categories WHERE problem_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; + DELETE FROM test_cases WHERE problem_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; + DELETE FROM problems WHERE id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; + """ + ) +] diff --git a/migrations/0003_multiply_problem.py b/migrations/0003_multiply_problem.py new file mode 100644 index 0000000..47447a9 --- /dev/null +++ b/migrations/0003_multiply_problem.py @@ -0,0 +1,37 @@ +from yoyo import step + +__depends__ = {"0002_seed_data"} + +steps = [ + step( + """ + -- Seed Problem: Multiply Two Numbers + INSERT INTO problems (id, title, description, difficulty, config) + VALUES ( + 'b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d', + 'Multiply Two Numbers', + 'Given two integers separated by a space, return their product.', + 'easy', + '{"timeout": 2, "templates": {"python": "num1, num2 = map(int, input().split())\\nprint(num1 * num2)"}}' + ) ON CONFLICT (id) DO NOTHING; + + -- Seed Test Cases for Multiply + INSERT INTO test_cases (problem_id, input, output, is_hidden, sort_order) + VALUES + ('b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d', '2 3', '6', false, 1), + ('b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d', '-4 5', '-20', false, 2), + ('b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d', '10 10', '100', true, 3) + ON CONFLICT (id) DO NOTHING; + + -- Link Problem to Category 'Math' + INSERT INTO problem_categories (problem_id, category_id) + SELECT 'b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d', id FROM categories WHERE name = 'Math' + ON CONFLICT DO NOTHING; + """, + """ + DELETE FROM problem_categories WHERE problem_id = 'b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d'; + DELETE FROM test_cases WHERE problem_id = 'b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d'; + DELETE FROM problems WHERE id = 'b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d'; + """ + ) +] diff --git a/API/app.py b/src/app.py similarity index 59% rename from API/app.py rename to src/app.py index 86f1390..083bc30 100644 --- a/API/app.py +++ b/src/app.py @@ -1,8 +1,11 @@ from flask import Flask, request, jsonify from executor import execute_code, execute_custom_code +from db import init_db app = Flask(__name__, static_folder='html') +init_db() + @app.post('/run') def custom_code_executor(): """Endpoint to run the specific code with given lang""" @@ -21,6 +24,35 @@ def custom_code_executor(): res = execute_custom_code(code, lang) return jsonify(res), (500 if res.get("status") == "error" else 200) +@app.get('/problems') +def get_problems(): + """Endpoint to list all problems.""" + from db import list_problems + problems = list_problems() + return jsonify(status="success", data=problems), 200 + +@app.get('/problem/') +def get_problem(problem_id): + """Endpoint to fetch problem details by ID.""" + from db import get_problem_details, get_public_test_cases + problem = get_problem_details(problem_id) + if not problem: + return jsonify(status="error", message="Problem not found"), 404 + + # Construct an ordered result for better readability + response_data = { + "id": problem.get("id"), + "title": problem.get("title"), + "difficulty": problem.get("difficulty"), + "description": problem.get("description"), + "categories": problem.get("categories"), + "tags": problem.get("tags"), + "config": problem.get("config"), + "test_cases": get_public_test_cases(problem_id) + } + + return jsonify(status="success", data=response_data), 200 + @app.post('/code/') def code_executor(question_id): """Endpoint to execute code for a given question ID.""" diff --git a/API/config.py b/src/config.py similarity index 74% rename from API/config.py rename to src/config.py index c003bd0..28227a7 100644 --- a/API/config.py +++ b/src/config.py @@ -1,4 +1,5 @@ -import os, json, re +import os, json, re, uuid +from db import query_db, get_problem_details, get_problem_test_cases COMPILERS = { "c": {"compiler": "gcc", "extension": ".c"}, @@ -16,10 +17,26 @@ def validate_code(lang: str, code: str, rules: dict) -> bool: return all(re.search(p, code) for p in patterns) def loadTest(qid: str) -> dict: - # Load test cases and configuration for a given question ID + # Try loading from database first + try: + problem = get_problem_details(qid) + if problem: + test_cases = get_problem_test_cases(qid) + cfg = problem['config'] + data = { + "timeout": cfg.get("timeout", 5), + "test_pairs": test_cases, + "templates": cfg.get("templates", {}), + "rules": cfg.get("rules", {}) + } + return data + except Exception: + pass + + # Fallback to file-based loading qpath = os.path.join(TEST_CASES_ROOT_DIR, str(qid)) if not os.path.isdir(qpath): - raise FileNotFoundError(f"Question directory not found: {qpath}") + raise FileNotFoundError(f"Question id not found: {qpath}") data = {"timeout": 5, "test_pairs": [], "templates": {}} cfg_path = os.path.join(qpath, "config.json") diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..5f30b2b --- /dev/null +++ b/src/db.py @@ -0,0 +1,83 @@ +import os +import psycopg2 +import psycopg2.extras +from psycopg2 import pool +import logging + +# Database configuration +DATABASE_URL = os.environ.get('DATABASE_URL') + +# Connection pool setup +db_pool = None + +def init_db(): + global db_pool + try: + if DATABASE_URL: + db_pool = psycopg2.pool.SimpleConnectionPool(1, 10, dsn=DATABASE_URL) + logging.info("Database connection pool initialized.") + else: + logging.warning("DATABASE_URL not set. Database integration disabled.") + except Exception as e: + logging.error(f"Error initializing database pool: {e}") + +def get_db_connection(): + if db_pool: + return db_pool.getconn() + return None + +def release_db_connection(conn): + if db_pool and conn: + db_pool.putconn(conn) + +def query_db(query, args=(), one=False): + conn = get_db_connection() + if not conn: + return None + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(query, args) + rv = cur.fetchall() + return (rv[0] if rv else None) if one else rv + except Exception as e: + logging.error(f"Database query error: {e}") + return None + finally: + release_db_connection(conn) + +def get_problem_details(problem_id): + """Fetch problem metadata, categories, and tags.""" + problem = query_db("SELECT id, title, description, difficulty, config FROM problems WHERE id = %s", (problem_id,), one=True) + if not problem: + return None + + # Filter config to only include allowed keys + allowed_keys = {'timeout', 'templates'} + original_config = problem.get('config', {}) + problem['config'] = {k: v for k, v in original_config.items() if k in allowed_keys} + + problem['categories'] = [c['name'] for c in query_db(""" + SELECT c.name FROM categories c + JOIN problem_categories pc ON c.id = pc.category_id + WHERE pc.problem_id = %s + """, (problem_id,))] or [] + + problem['tags'] = [t['name'] for t in query_db(""" + SELECT t.name FROM tags t + JOIN problem_tags pt ON t.id = pt.tag_id + WHERE pt.problem_id = %s + """, (problem_id,))] or [] + + return problem + +def get_problem_test_cases(problem_id): + """Fetch all test cases for a problem (used for execution).""" + return query_db("SELECT input, output as expected_output, sort_order as test_number FROM test_cases WHERE problem_id = %s ORDER BY sort_order", (problem_id,)) + +def get_public_test_cases(problem_id): + """Fetch only non-hidden test cases (used for problem description).""" + return query_db("SELECT input, output, sort_order FROM test_cases WHERE problem_id = %s AND is_hidden = FALSE ORDER BY sort_order", (problem_id,)) + +def list_problems(): + """List all problems with basic info.""" + return query_db("SELECT id, title, difficulty FROM problems ORDER BY created_at DESC") diff --git a/API/executor.py b/src/executor.py similarity index 100% rename from API/executor.py rename to src/executor.py diff --git a/API/html/404.html b/src/html/404.html similarity index 100% rename from API/html/404.html rename to src/html/404.html diff --git a/API/html/index.html b/src/html/index.html similarity index 100% rename from API/html/index.html rename to src/html/index.html diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..69c9983 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.0.3 +gunicorn==23.0.0 +psycopg2-binary==2.9.9 +yoyo-migrations==8.2.0 \ No newline at end of file From b5a0614f350604c6b4c374f514b0fd4cbd6c93fb Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Mon, 9 Feb 2026 00:50:14 +0700 Subject: [PATCH 06/14] feat: Introduce interactive API documentation via Scalar, an OpenAPI specification, and a static markdown guide. --- API_DOCUMENTATION.md | 65 ++++++++++++ bruno/docs.bru | 15 +++ src/app.py | 28 +++++- src/html/index.html | 229 +++++++++++++++++++++++-------------------- src/openapi.yaml | 92 +++++++++++++++++ 5 files changed, 319 insertions(+), 110 deletions(-) create mode 100644 API_DOCUMENTATION.md create mode 100644 bruno/docs.bru create mode 100644 src/openapi.yaml diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..59d41a0 --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,65 @@ +# CodeExecutor-API Documentation + +Dynamic code execution API supporting multiple languages and database-backed problem management. + +## Base URL +`http://localhost:3000` + +## Interactive Documentation +Interactive docs are available at: [http://localhost:3000/docs](http://localhost:3000/docs) + +--- + +## Endpoints + +### 1. List Problems +Retrieve a summary of all problems in the database. +- **URL**: `/problems` +- **Method**: `GET` +- **Response**: `200 OK` +```json +{ + "status": "success", + "data": [ + { "id": "uuid", "title": "Two Sum", "difficulty": "easy" } + ] +} +``` + +### 2. Get Problem Details +Retrieve specific problem metadata, configuration, and public test cases. +- **URL**: `/problem/` +- **Method**: `GET` +- **Response**: `200 OK` + +### 3. Execute Code (Problem-based) +Run code against test cases associated with a specific problem. +- **URL**: `/code/` +- **Method**: `POST` +- **Body**: +```json +{ + "language": "python", + "code": "print('hello')" +} +``` + +### 4. Custom Execution +Run arbitrary code without a pre-defined problem. +- **URL**: `/run?lang=python` +- **Method**: `POST` +- **Body**: +```json +{ + "code": "print('hello world')" +} +``` + +--- + +## Supported Languages +- **Python**: `python` +- **JavaScript**: `javascript` +- **Java**: `java` +- **C**: `c` +- **C++**: `cpp` diff --git a/bruno/docs.bru b/bruno/docs.bru new file mode 100644 index 0000000..ac90b32 --- /dev/null +++ b/bruno/docs.bru @@ -0,0 +1,15 @@ +meta { + name: docs + type: http + seq: 3 +} + +get { + url: http://localhost:3000/docs + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/src/app.py b/src/app.py index 083bc30..259c2b1 100644 --- a/src/app.py +++ b/src/app.py @@ -1,9 +1,35 @@ -from flask import Flask, request, jsonify +from flask import Flask, request, jsonify, send_from_directory from executor import execute_code, execute_custom_code from db import init_db app = Flask(__name__, static_folder='html') +@app.route('/openapi.yaml') +def serve_openapi(): + """Serve the OpenAPI specification.""" + return send_from_directory(app.root_path, 'openapi.yaml') + +@app.route('/docs') +def scalar_docs(): + """Serve interactive Scalar documentation.""" + return f""" + + + + CodeExecutor-API Documentation + + + + + + + + + + """, 200 + init_db() @app.post('/run') diff --git a/src/html/index.html b/src/html/index.html index 050eabe..3747c83 100644 --- a/src/html/index.html +++ b/src/html/index.html @@ -1,116 +1,127 @@ - - - DevScape API - - - - -
-
-

Code Executor API

-

- Welcome to the backend gateway of the - Code Executor API. -

-
-

Made for developers, by developers. View Docs

-
-
- - + } + + + + +
+
+

Code Executor API

+

+ Welcome to the backend gateway of the + Code Executor API. +

+
+

Made for developers, by developers. View Docs

+
+
+ + + \ No newline at end of file diff --git a/src/openapi.yaml b/src/openapi.yaml new file mode 100644 index 0000000..bfac176 --- /dev/null +++ b/src/openapi.yaml @@ -0,0 +1,92 @@ +openapi: 3.1.0 +info: + title: CodeExecutor-API + description: API for executing code in various languages against database-backed problems. + version: 1.0.0 +servers: + - url: http://localhost:3000 + description: Local development server + +paths: + /problems: + get: + summary: List all problems + responses: + '200': + description: A list of problems + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + data: + type: array + items: + type: object + properties: + id: { type: string, format: uuid } + title: { type: string } + difficulty: { type: string } + + /problem/{problem_id}: + get: + summary: Get problem details + parameters: + - name: problem_id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Detailed problem information + '404': + description: Problem not found + + /code/{problem_id}: + post: + summary: Execute code for a problem + parameters: + - name: problem_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + language: { type: string } + code: { type: string } + responses: + '200': + description: Execution results + + /run: + post: + summary: Run arbitrary code + parameters: + - name: lang + in: query + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: { type: string } + responses: + '200': + description: Execution results From 133bec26c6b08f398596cc7bf3908ac636c6b491 Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Mon, 9 Feb 2026 02:07:33 +0700 Subject: [PATCH 07/14] refactor: Implement a modular architecture with dedicated service and repository layers, and update database and API structures. --- API_DOCUMENTATION.md | 68 +++++++++ .../GET- Problems/get problems by tag.bru | 16 ++ migrations/0004_more_problems_seed.py | 140 ++++++++++++++++++ migrations/0005_standardize_to_uuid.py | 66 +++++++++ src/api/__init__.py | 1 + src/api/routes.py | 77 ++++++++++ src/app.py | 93 +++--------- src/config.py | 76 ---------- src/core/__init__.py | 1 + src/core/config.py | 14 ++ src/{ => core}/executor.py | 15 +- src/db.py | 83 ----------- src/infrastructure/__init__.py | 1 + src/infrastructure/database.py | 68 +++++++++ src/openapi.yaml | 103 +++++++++++++ src/repositories/__init__.py | 2 + src/repositories/problem_repository.py | 53 +++++++ src/repositories/test_case_repository.py | 22 +++ src/services/__init__.py | 2 + src/services/execution_service.py | 25 ++++ src/services/problem_service.py | 35 +++++ 21 files changed, 720 insertions(+), 241 deletions(-) create mode 100644 API_DOCUMENTATION.md create mode 100644 bruno/local/GET- Problems/get problems by tag.bru create mode 100644 migrations/0004_more_problems_seed.py create mode 100644 migrations/0005_standardize_to_uuid.py create mode 100644 src/api/__init__.py create mode 100644 src/api/routes.py delete mode 100644 src/config.py create mode 100644 src/core/__init__.py create mode 100644 src/core/config.py rename src/{ => core}/executor.py (89%) delete mode 100644 src/db.py create mode 100644 src/infrastructure/__init__.py create mode 100644 src/infrastructure/database.py create mode 100644 src/openapi.yaml create mode 100644 src/repositories/__init__.py create mode 100644 src/repositories/problem_repository.py create mode 100644 src/repositories/test_case_repository.py create mode 100644 src/services/__init__.py create mode 100644 src/services/execution_service.py create mode 100644 src/services/problem_service.py diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..11fded4 --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,68 @@ +# CodeExecutor-API Documentation + +Dynamic code execution API supporting multiple languages and database-backed problem management. + +## Base URL +`http://localhost:3000` + +## Interactive Documentation +Interactive docs are available at: [http://localhost:3000/docs](http://localhost:3000/docs) + +--- + +## Endpoints + +### 1. List Problems +Retrieve a summary of all problems in the database. Supports filtering by category or tag. +- **URL**: `/problems` +- **Method**: `GET` +- **Query Parameters**: + - `category`: Filter by category name (e.g., `/problems?category=Math`) + - `tag`: Filter by tag name (e.g., `/problems?tag=String`) +- **Response**: `200 OK` +```json +{ + "status": "success", + "data": [ + { "id": "uuid", "title": "Two Sum", "difficulty": "easy" } + ] +} +``` + +### 2. Get Problem Details +Retrieve specific problem metadata, configuration, and public test cases. +- **URL**: `/problem/` +- **Method**: `GET` +- **Response**: `200 OK` + +### 3. Execute Code (Problem-based) +Run code against test cases associated with a specific problem. +- **URL**: `/code/` +- **Method**: `POST` +- **Body**: +```json +{ + "language": "python", + "code": "print('hello')" +} +``` + +### 4. Custom Execution +Run arbitrary code without a pre-defined problem. +- **URL**: `/run?lang=python` +- **Method**: `POST` +- **Body**: +```json +{ + "code": "print('hello world')" +} +``` + +--- + +## Supported Languages +- **Python**: `python` +- **JavaScript**: `javascript` +- **Java**: `java` +- **C**: `c` +- **C++**: `cpp` diff --git a/bruno/local/GET- Problems/get problems by tag.bru b/bruno/local/GET- Problems/get problems by tag.bru new file mode 100644 index 0000000..649e7a5 --- /dev/null +++ b/bruno/local/GET- Problems/get problems by tag.bru @@ -0,0 +1,16 @@ +meta { + name: get problems by tag + type: http + seq: 3 +} + +get { + url: http://localhost:3000/problem/b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/migrations/0004_more_problems_seed.py b/migrations/0004_more_problems_seed.py new file mode 100644 index 0000000..e651974 --- /dev/null +++ b/migrations/0004_more_problems_seed.py @@ -0,0 +1,140 @@ +import uuid +import json +from yoyo import step + +def apply_step(conn): + cursor = conn.cursor() + + # Problems Data (ID, Title, Description, Difficulty, Config) + p_ids = { + 'palindrome': str(uuid.uuid4()), + 'factorial': str(uuid.uuid4()), + 'fibonacci': str(uuid.uuid4()), + 'find_max': str(uuid.uuid4()), + 'anagram': str(uuid.uuid4()) + } + + problems = [ + (p_ids['palindrome'], 'Palindrome Check', 'Write a function that checks if a given string is a palindrome.', 'Easy', { + 'timeout': 5, + 'templates': { + 'python': 'def is_palindrome(s):\n # __CODE_GOES_HERE__\n pass', + 'javascript': 'function isPalindrome(s) {\n // __CODE_GOES_HERE__\n}' + } + }), + (p_ids['factorial'], 'Factorial', 'Write a function that calculates the factorial of a given non-negative integer n.', 'Easy', { + 'timeout': 5, + 'templates': { + 'python': 'def factorial(n):\n # __CODE_GOES_HERE__\n pass', + 'javascript': 'function factorial(n) {\n // __CODE_GOES_HERE__\n}' + } + }), + (p_ids['fibonacci'], 'Fibonacci Number', 'Write a function that returns the n-th Fibonacci number.', 'Easy', { + 'timeout': 5, + 'templates': { + 'python': 'def fib(n):\n # __CODE_GOES_HERE__\n pass', + 'javascript': 'function fib(n) {\n // __CODE_GOES_HERE__\n}' + } + }), + (p_ids['find_max'], 'Find Maximum', 'Write a function that returns the maximum element in a given array of integers.', 'Easy', { + 'timeout': 5, + 'templates': { + 'python': 'def find_max(arr):\n # __CODE_GOES_HERE__\n pass', + 'javascript': 'function findMax(arr) {\n // __CODE_GOES_HERE__\n}' + } + }), + (p_ids['anagram'], 'Valid Anagram', 'Write a function that determines if two strings are anagrams of each other.', 'Easy', { + 'timeout': 5, + 'templates': { + 'python': 'def is_anagram(s, t):\n # __CODE_GOES_HERE__\n pass', + 'javascript': 'function isAnagram(s, t) {\n // __CODE_GOES_HERE__\n}' + } + }) + ] + + for p in problems: + cursor.execute( + "INSERT INTO problems (id, title, description, difficulty, config) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING", + (p[0], p[1], p[2], p[3], json.dumps(p[4])) + ) + + # Test Cases Data + test_cases = [ + # Palindrome Check + (p_ids['palindrome'], '"racecar"', 'True', 1, False), + (p_ids['palindrome'], '"hello"', 'False', 2, False), + # Factorial + (p_ids['factorial'], '5', '120', 1, False), + (p_ids['factorial'], '0', '1', 2, False), + # Fibonacci + (p_ids['fibonacci'], '6', '8', 1, False), + (p_ids['fibonacci'], '0', '0', 2, False), + # Find Max + (p_ids['find_max'], '[1, 5, 3, 9, 2]', '9', 1, False), + (p_ids['find_max'], '[-1, -5, -3]', '-1', 2, False), + # Valid Anagram + (p_ids['anagram'], '"anagram", "nagaram"', 'True', 1, False), + (p_ids['anagram'], '"rat", "car"', 'False', 2, False) + ] + + for tc in test_cases: + cursor.execute( + "INSERT INTO test_cases (problem_id, input, output, sort_order, is_hidden) VALUES (%s, %s, %s, %s, %s)", + tc + ) + + # Categories and Tags + categories = [('Basic',), ('Array',), ('String',), ('Math',)] + for cat in categories: + cursor.execute("INSERT INTO categories (name) VALUES (%s) ON CONFLICT (name) DO NOTHING", cat) + + tags = [('String',), ('Math',), ('Recursion',), ('Array',)] + for t in tags: + cursor.execute("INSERT INTO tags (name) VALUES (%s) ON CONFLICT (name) DO NOTHING", t) + + # Linking + cursor.execute(""" + INSERT INTO problem_categories (problem_id, category_id) + SELECT %s, id FROM categories WHERE name = 'String' + """, (p_ids['palindrome'],)) + cursor.execute(""" + INSERT INTO problem_categories (problem_id, category_id) + SELECT %s, id FROM categories WHERE name = 'Math' + """, (p_ids['factorial'],)) + cursor.execute(""" + INSERT INTO problem_categories (problem_id, category_id) + SELECT %s, id FROM categories WHERE name = 'Math' + """, (p_ids['fibonacci'],)) + cursor.execute(""" + INSERT INTO problem_categories (problem_id, category_id) + SELECT %s, id FROM categories WHERE name = 'Array' + """, (p_ids['find_max'],)) + cursor.execute(""" + INSERT INTO problem_categories (problem_id, category_id) + SELECT %s, id FROM categories WHERE name = 'String' + """, (p_ids['anagram'],)) + + cursor.execute(""" + INSERT INTO problem_tags (problem_id, tag_id) + SELECT %s, id FROM tags WHERE name = 'String' + """, (p_ids['palindrome'],)) + cursor.execute(""" + INSERT INTO problem_tags (problem_id, tag_id) + SELECT %s, id FROM tags WHERE name = 'Math' + """, (p_ids['factorial'],)) + cursor.execute(""" + INSERT INTO problem_tags (problem_id, tag_id) + SELECT %s, id FROM tags WHERE name = 'Recursion' + """, (p_ids['fibonacci'],)) + cursor.execute(""" + INSERT INTO problem_tags (problem_id, tag_id) + SELECT %s, id FROM tags WHERE name = 'Array' + """, (p_ids['find_max'],)) + cursor.execute(""" + INSERT INTO problem_tags (problem_id, tag_id) + SELECT %s, id FROM tags WHERE name = 'String' + """, (p_ids['anagram'],)) + +steps = [ + step(apply_step) +] diff --git a/migrations/0005_standardize_to_uuid.py b/migrations/0005_standardize_to_uuid.py new file mode 100644 index 0000000..6f0dd68 --- /dev/null +++ b/migrations/0005_standardize_to_uuid.py @@ -0,0 +1,66 @@ +from yoyo import step + +__depends__ = {"0004_more_problems_seed"} + +def apply_step(conn): + cursor = conn.cursor() + + # 1. Categories Migration + # Add new UUID column + cursor.execute("ALTER TABLE categories ADD COLUMN uuid_id UUID DEFAULT gen_random_uuid()") + + # Update problem_categories to use a temporary UUID column + cursor.execute("ALTER TABLE problem_categories ADD COLUMN category_uuid UUID") + cursor.execute(""" + UPDATE problem_categories pc + SET category_uuid = c.uuid_id + FROM categories c + WHERE pc.category_id = c.id + """) + + # Drop old constraints and columns for categories + cursor.execute("ALTER TABLE problem_categories DROP CONSTRAINT problem_categories_category_id_fkey") + cursor.execute("ALTER TABLE problem_categories DROP COLUMN category_id") + cursor.execute("ALTER TABLE categories DROP CONSTRAINT categories_pkey CASCADE") + cursor.execute("ALTER TABLE categories DROP COLUMN id") + + # Make uuid_id the new primary key and rename it + cursor.execute("ALTER TABLE categories ADD PRIMARY KEY (uuid_id)") + cursor.execute("ALTER TABLE categories RENAME COLUMN uuid_id TO id") + + # finalize problem_categories + cursor.execute("ALTER TABLE problem_categories RENAME COLUMN category_uuid TO category_id") + cursor.execute("ALTER TABLE problem_categories ADD CONSTRAINT problem_categories_category_id_fkey FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE") + cursor.execute("ALTER TABLE problem_categories ADD PRIMARY KEY (problem_id, category_id)") + + # 2. Tags Migration + # Add new UUID column + cursor.execute("ALTER TABLE tags ADD COLUMN uuid_id UUID DEFAULT gen_random_uuid()") + + # Update problem_tags to use a temporary UUID column + cursor.execute("ALTER TABLE problem_tags ADD COLUMN tag_uuid UUID") + cursor.execute(""" + UPDATE problem_tags pt + SET tag_uuid = t.uuid_id + FROM tags t + WHERE pt.tag_id = t.id + """) + + # Drop old constraints and columns for tags + cursor.execute("ALTER TABLE problem_tags DROP CONSTRAINT problem_tags_tag_id_fkey") + cursor.execute("ALTER TABLE problem_tags DROP COLUMN tag_id") + cursor.execute("ALTER TABLE tags DROP CONSTRAINT tags_pkey CASCADE") + cursor.execute("ALTER TABLE tags DROP COLUMN id") + + # Make uuid_id the new primary key and rename it + cursor.execute("ALTER TABLE tags ADD PRIMARY KEY (uuid_id)") + cursor.execute("ALTER TABLE tags RENAME COLUMN uuid_id TO id") + + # finalize problem_tags + cursor.execute("ALTER TABLE problem_tags RENAME COLUMN tag_uuid TO tag_id") + cursor.execute("ALTER TABLE problem_tags ADD CONSTRAINT problem_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE") + cursor.execute("ALTER TABLE problem_tags ADD PRIMARY KEY (problem_id, tag_id)") + +steps = [ + step(apply_step) +] diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..c917077 --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1 @@ +from .routes import api_bp diff --git a/src/api/routes.py b/src/api/routes.py new file mode 100644 index 0000000..bc38cb2 --- /dev/null +++ b/src/api/routes.py @@ -0,0 +1,77 @@ +from flask import Blueprint, request, jsonify, send_from_directory, current_app +from services import ProblemService, ExecutionService +from core import execute_custom_code + +api_bp = Blueprint('api', __name__) +problem_service = ProblemService() +execution_service = ExecutionService() + +@api_bp.route('/openapi.yaml') +def serve_openapi(): + return send_from_directory(current_app.root_path, 'openapi.yaml') + +@api_bp.route('/docs') +def scalar_docs(): + return f""" + + + + CodeExecutor-API Documentation + + + + + + + + + + """, 200 + +@api_bp.get('/problems') +def get_problems(): + category = request.args.get('category') + tag = request.args.get('tag') + + if category: + problems = problem_service.list_problems_by_category(category) + elif tag: + problems = problem_service.list_problems_by_tag(tag) + else: + problems = problem_service.list_all_problems() + + return jsonify(status="success", data=problems), 200 + +@api_bp.get('/problem/') +def get_problem(problem_id): + problem = problem_service.get_problem_details(problem_id) + if not problem: + return jsonify(status="error", message="Problem not found"), 404 + return jsonify(status="success", data=problem), 200 + +@api_bp.post('/code/') +def execute_problem_code(problem_id): + if not request.is_json: + return jsonify(status="error", message="Request must be JSON"), 400 + + data = request.get_json() + code, lang = data.get('code'), data.get('language') + if not code or not lang: + return jsonify(status="error", message="Missing 'code' or 'language'"), 400 + + res = execution_service.run_problem_code(problem_id, code, lang) + return jsonify(res), (500 if res.get("status") == "error" else 200) + +@api_bp.post('/run') +def custom_code_executor(): + lang = request.args.get('lang') + if not lang or not request.is_json: + return jsonify(status="error", message="Missing lang or invalid body"), 400 + + data = request.get_json() + code = data.get('code') + if not code: + return jsonify(status="error", message="Missing code"), 400 + + res = execute_custom_code(code, lang) + return jsonify(res), (500 if res.get("status") == "error" else 200) diff --git a/src/app.py b/src/app.py index 083bc30..0a29f2a 100644 --- a/src/app.py +++ b/src/app.py @@ -1,78 +1,27 @@ -from flask import Flask, request, jsonify -from executor import execute_code, execute_custom_code -from db import init_db +from flask import Flask +from infrastructure import init_db +from api import api_bp -app = Flask(__name__, static_folder='html') - -init_db() - -@app.post('/run') -def custom_code_executor(): - """Endpoint to run the specific code with given lang""" - lang = request.args.get('lang') - if not lang: - return jsonify(status="error", message="Missing 'lang' query parameter"), 400 - - if not request.is_json: - return jsonify(status="error", message="Request must be JSON"), 400 - - data = request.get_json() - code = data.get('code') - if not code: - return jsonify(status="error", message="Missing 'code' in request body"), 400 - - res = execute_custom_code(code, lang) - return jsonify(res), (500 if res.get("status") == "error" else 200) - -@app.get('/problems') -def get_problems(): - """Endpoint to list all problems.""" - from db import list_problems - problems = list_problems() - return jsonify(status="success", data=problems), 200 - -@app.get('/problem/') -def get_problem(problem_id): - """Endpoint to fetch problem details by ID.""" - from db import get_problem_details, get_public_test_cases - problem = get_problem_details(problem_id) - if not problem: - return jsonify(status="error", message="Problem not found"), 404 +def create_app(): + app = Flask(__name__, static_folder='html') - # Construct an ordered result for better readability - response_data = { - "id": problem.get("id"), - "title": problem.get("title"), - "difficulty": problem.get("difficulty"), - "description": problem.get("description"), - "categories": problem.get("categories"), - "tags": problem.get("tags"), - "config": problem.get("config"), - "test_cases": get_public_test_cases(problem_id) - } + # Initialize infrastructure + init_db() - return jsonify(status="success", data=response_data), 200 - -@app.post('/code/') -def code_executor(question_id): - """Endpoint to execute code for a given question ID.""" - if not request.is_json: - return jsonify(status="error", message="Request must be JSON"), 400 - - data = request.get_json() - code, lang = data.get('code'), data.get('language') - if not code or not lang: - return jsonify(status="error", message="Missing 'code' or 'language'"), 400 + # Register routes + app.register_blueprint(api_bp) + + @app.route('/') + def home(): + return app.send_static_file('index.html') - res = execute_code(code, lang, question_id) - return jsonify(res), (500 if res.get("status") == "error" else 200) + @app.errorhandler(404) + def page_not_found(e): + return app.send_static_file('404.html'), 404 + + return app -@app.route('/') -def home(): - """Serve the main HTML page.""" - return app.send_static_file('index.html') +app = create_app() -@app.errorhandler(404) -def page_not_found(e): - """Handle 404 errors by serving a custom page.""" - return app.send_static_file('404.html'), 404 +if __name__ == "__main__": + app.run(host="0.0.0.0", port=3000) diff --git a/src/config.py b/src/config.py deleted file mode 100644 index 28227a7..0000000 --- a/src/config.py +++ /dev/null @@ -1,76 +0,0 @@ -import os, json, re, uuid -from db import query_db, get_problem_details, get_problem_test_cases - -COMPILERS = { - "c": {"compiler": "gcc", "extension": ".c"}, - "cpp": {"compiler": "g++", "extension": ".cpp"}, - "java": {"compiler": "javac", "extension": ".java"}, - "python": {"interpreter": "python", "extension": ".py"}, - "javascript": {"interpreter": "node", "extension": ".js"}, -} - -TEST_CASES_ROOT_DIR = "tests" - -def validate_code(lang: str, code: str, rules: dict) -> bool: - # Validate code against language-specific rules - patterns = rules.get(lang, []) - return all(re.search(p, code) for p in patterns) - -def loadTest(qid: str) -> dict: - # Try loading from database first - try: - problem = get_problem_details(qid) - if problem: - test_cases = get_problem_test_cases(qid) - cfg = problem['config'] - data = { - "timeout": cfg.get("timeout", 5), - "test_pairs": test_cases, - "templates": cfg.get("templates", {}), - "rules": cfg.get("rules", {}) - } - return data - except Exception: - pass - - # Fallback to file-based loading - qpath = os.path.join(TEST_CASES_ROOT_DIR, str(qid)) - if not os.path.isdir(qpath): - raise FileNotFoundError(f"Question id not found: {qpath}") - - data = {"timeout": 5, "test_pairs": [], "templates": {}} - cfg_path = os.path.join(qpath, "config.json") - - if os.path.exists(cfg_path): - try: - with open(cfg_path, encoding="utf-8") as f: - cfg = json.load(f) - data["timeout"] = cfg.get("timeout", 5) - data["templates"] = cfg.get("templates", {}) - data["rules"] = cfg.get("rules", {}) - except json.JSONDecodeError: - print(f"Warning: invalid JSON in {cfg_path}. Using default timeout.") - else: - print(f"Warning: config.json not found for {qid}. Using default timeout.") - - ins, outs = {}, {} - for fn in os.listdir(qpath): - m = re.match(r'^(\d+)\.(in|out)$', fn) - if not m: - continue - with open(os.path.join(qpath, fn), encoding="utf-8") as f: - (ins if m.group(2) == "in" else outs)[m.group(1)] = f.read() - - nums = sorted(set(ins) | set(outs), key=int) - for n in nums: - if n not in outs: - raise ValueError(f"Missing {n}.out for question {qid}") - data["test_pairs"].append({ - "test_number": n, - "input": ins.get(n, ""), - "expected_output": outs[n] - }) - - if not data["test_pairs"]: - raise FileNotFoundError(f"No test pairs found for {qid}") - return data diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..61128e7 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1 @@ +from .executor import execute_code, execute_custom_code diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..937fe5d --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,14 @@ +import os, re + +COMPILERS = { + "c": {"compiler": "gcc", "extension": ".c"}, + "cpp": {"compiler": "g++", "extension": ".cpp"}, + "java": {"compiler": "javac", "extension": ".java"}, + "python": {"interpreter": "python", "extension": ".py"}, + "javascript": {"interpreter": "node", "extension": ".js"}, +} + +def validate_code(lang: str, code: str, rules: dict) -> bool: + """Validate code against language-specific rules.""" + patterns = rules.get(lang, []) + return all(not re.search(p, code) for p in patterns) diff --git a/src/executor.py b/src/core/executor.py similarity index 89% rename from src/executor.py rename to src/core/executor.py index 2e7ce4b..b8292b1 100644 --- a/src/executor.py +++ b/src/core/executor.py @@ -1,5 +1,5 @@ import subprocess, os, tempfile -from config import COMPILERS, loadTest, validate_code +from .config import COMPILERS, validate_code def execute_custom_code(code: str, lang: str) -> dict: """Execute the given code in the specific language""" @@ -13,18 +13,13 @@ def execute_custom_code(code: str, lang: str) -> dict: else: return _run_interpreted(code, lang) -def execute_code(code: str, lang: str, qid: str) -> dict: - """Execute the given code in the specified language against test cases for the question ID.""" +def execute_code(code: str, lang: str, tests: list, timeout: int = 5, templates: dict = None, rules: dict = None) -> dict: + """Execute the given code against provided test cases.""" if lang not in COMPILERS: return {"status": "error", "message": f"Unsupported language: {lang}"} - try: - data = loadTest(qid) - tests, timeout = data['test_pairs'], data['timeout'] - templates = data.get('templates', {}) - rules = data.get('rules', {}) - except Exception as e: - return {"status": "error", "message": f"Error loading test cases: {e}"} + templates = templates or {} + rules = rules or {} if not validate_code(lang, code, rules): return {"status": "error", "message": "Code does not meet the required rules."} diff --git a/src/db.py b/src/db.py deleted file mode 100644 index 5f30b2b..0000000 --- a/src/db.py +++ /dev/null @@ -1,83 +0,0 @@ -import os -import psycopg2 -import psycopg2.extras -from psycopg2 import pool -import logging - -# Database configuration -DATABASE_URL = os.environ.get('DATABASE_URL') - -# Connection pool setup -db_pool = None - -def init_db(): - global db_pool - try: - if DATABASE_URL: - db_pool = psycopg2.pool.SimpleConnectionPool(1, 10, dsn=DATABASE_URL) - logging.info("Database connection pool initialized.") - else: - logging.warning("DATABASE_URL not set. Database integration disabled.") - except Exception as e: - logging.error(f"Error initializing database pool: {e}") - -def get_db_connection(): - if db_pool: - return db_pool.getconn() - return None - -def release_db_connection(conn): - if db_pool and conn: - db_pool.putconn(conn) - -def query_db(query, args=(), one=False): - conn = get_db_connection() - if not conn: - return None - try: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute(query, args) - rv = cur.fetchall() - return (rv[0] if rv else None) if one else rv - except Exception as e: - logging.error(f"Database query error: {e}") - return None - finally: - release_db_connection(conn) - -def get_problem_details(problem_id): - """Fetch problem metadata, categories, and tags.""" - problem = query_db("SELECT id, title, description, difficulty, config FROM problems WHERE id = %s", (problem_id,), one=True) - if not problem: - return None - - # Filter config to only include allowed keys - allowed_keys = {'timeout', 'templates'} - original_config = problem.get('config', {}) - problem['config'] = {k: v for k, v in original_config.items() if k in allowed_keys} - - problem['categories'] = [c['name'] for c in query_db(""" - SELECT c.name FROM categories c - JOIN problem_categories pc ON c.id = pc.category_id - WHERE pc.problem_id = %s - """, (problem_id,))] or [] - - problem['tags'] = [t['name'] for t in query_db(""" - SELECT t.name FROM tags t - JOIN problem_tags pt ON t.id = pt.tag_id - WHERE pt.problem_id = %s - """, (problem_id,))] or [] - - return problem - -def get_problem_test_cases(problem_id): - """Fetch all test cases for a problem (used for execution).""" - return query_db("SELECT input, output as expected_output, sort_order as test_number FROM test_cases WHERE problem_id = %s ORDER BY sort_order", (problem_id,)) - -def get_public_test_cases(problem_id): - """Fetch only non-hidden test cases (used for problem description).""" - return query_db("SELECT input, output, sort_order FROM test_cases WHERE problem_id = %s AND is_hidden = FALSE ORDER BY sort_order", (problem_id,)) - -def list_problems(): - """List all problems with basic info.""" - return query_db("SELECT id, title, difficulty FROM problems ORDER BY created_at DESC") diff --git a/src/infrastructure/__init__.py b/src/infrastructure/__init__.py new file mode 100644 index 0000000..9dbd11f --- /dev/null +++ b/src/infrastructure/__init__.py @@ -0,0 +1 @@ +from .database import init_db diff --git a/src/infrastructure/database.py b/src/infrastructure/database.py new file mode 100644 index 0000000..d81fee4 --- /dev/null +++ b/src/infrastructure/database.py @@ -0,0 +1,68 @@ +import os +import psycopg2 +import psycopg2.extras +from psycopg2 import pool +import logging + +# Database configuration +DATABASE_URL = os.environ.get('DATABASE_URL') + +# Connection pool setup +db_pool = None + +from yoyo import read_migrations, get_backend + +def init_db(): + global db_pool + try: + if DATABASE_URL: + db_pool = psycopg2.pool.SimpleConnectionPool(1, 10, dsn=DATABASE_URL) + logging.info("Database connection pool initialized.") + + # Run migrations + backend = get_backend(DATABASE_URL) + migrations = read_migrations('./migrations') + with backend.lock(): + backend.apply_migrations(backend.to_apply(migrations)) + logging.info("Database migrations applied successfully.") + else: + logging.warning("DATABASE_URL not set. Database integration disabled.") + except Exception as e: + logging.error(f"Error initializing database pool or running migrations: {e}") + +def get_db_connection(): + if db_pool: + return db_pool.getconn() + return None + +def release_db_connection(conn): + if db_pool and conn: + db_pool.putconn(conn) + +def query_one(query, args=()): + """Execute query and return one result.""" + conn = get_db_connection() + if not conn: return None + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(query, args) + return cur.fetchone() + except Exception as e: + logging.error(f"Database query error: {e}") + return None + finally: + release_db_connection(conn) + +def query_all(query, args=()): + """Execute query and return all results.""" + conn = get_db_connection() + if not conn: return [] + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(query, args) + return cur.fetchall() + except Exception as e: + logging.error(f"Database query error: {e}") + return [] + finally: + release_db_connection(conn) diff --git a/src/openapi.yaml b/src/openapi.yaml new file mode 100644 index 0000000..1193bd4 --- /dev/null +++ b/src/openapi.yaml @@ -0,0 +1,103 @@ +openapi: 3.1.0 +info: + title: CodeExecutor-API + description: API for executing code in various languages against database-backed problems. + version: 1.0.0 +servers: + - url: http://localhost:3000 + description: Local development server + +paths: + /problems: + get: + summary: List all problems + parameters: + - name: category + in: query + description: Filter problems by category name + schema: + type: string + - name: tag + in: query + description: Filter problems by tag name + schema: + type: string + responses: + '200': + description: A list of problems + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + data: + type: array + items: + type: object + properties: + id: { type: string } + title: { type: string } + difficulty: { type: string } + + /problem/{problem_id}: + get: + summary: Get problem details + parameters: + - name: problem_id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Detailed problem information + '404': + description: Problem not found + + /code/{problem_id}: + post: + summary: Execute code for a problem + parameters: + - name: problem_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + language: { type: string } + code: { type: string } + responses: + '200': + description: Execution results + + /run: + post: + summary: Run arbitrary code + parameters: + - name: lang + in: query + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: { type: string } + responses: + '200': + description: Execution results diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py new file mode 100644 index 0000000..9114006 --- /dev/null +++ b/src/repositories/__init__.py @@ -0,0 +1,2 @@ +from .problem_repository import ProblemRepository +from .test_case_repository import TestCaseRepository diff --git a/src/repositories/problem_repository.py b/src/repositories/problem_repository.py new file mode 100644 index 0000000..4166aa4 --- /dev/null +++ b/src/repositories/problem_repository.py @@ -0,0 +1,53 @@ +from infrastructure.database import query_one, query_all + +class ProblemRepository: + @staticmethod + def find_all(): + """Retrieve all problems with basic info.""" + return query_all("SELECT id, title, difficulty FROM problems ORDER BY created_at DESC") + + @staticmethod + def find_by_id(problem_id): + """Retrieve basic problem details by ID.""" + return query_one("SELECT id, title, description, difficulty, config FROM problems WHERE id = %s", (problem_id,)) + + @staticmethod + def get_categories(problem_id): + """Retrieve category names for a problem.""" + rows = query_all(""" + SELECT c.name FROM categories c + JOIN problem_categories pc ON c.id = pc.category_id + WHERE pc.problem_id = %s + """, (problem_id,)) + return [r['name'] for r in rows] + + @staticmethod + def get_tags(problem_id): + """Retrieve tag names for a problem.""" + rows = query_all(""" + SELECT t.name FROM tags t + JOIN problem_tags pt ON t.id = pt.tag_id + WHERE pt.problem_id = %s + """, (problem_id,)) + return [r['name'] for r in rows] + @staticmethod + def find_by_category(category_name): + """Retrieve problems filtered by category name.""" + return query_all(""" + SELECT p.id, p.title, p.difficulty FROM problems p + JOIN problem_categories pc ON p.id = pc.problem_id + JOIN categories c ON pc.category_id = c.id + WHERE c.name ILIKE %s + ORDER BY p.created_at DESC + """, (category_name,)) + + @staticmethod + def find_by_tag(tag_name): + """Retrieve problems filtered by tag name.""" + return query_all(""" + SELECT p.id, p.title, p.difficulty FROM problems p + JOIN problem_tags pt ON p.id = pt.problem_id + JOIN tags t ON pt.tag_id = t.id + WHERE t.name ILIKE %s + ORDER BY p.created_at DESC + """, (tag_name,)) diff --git a/src/repositories/test_case_repository.py b/src/repositories/test_case_repository.py new file mode 100644 index 0000000..7a07bf6 --- /dev/null +++ b/src/repositories/test_case_repository.py @@ -0,0 +1,22 @@ +from infrastructure.database import query_all + +class TestCaseRepository: + @staticmethod + def find_all_by_problem(problem_id): + """Fetch all test cases for a problem (internal/execution use).""" + return query_all(""" + SELECT input, output as expected_output, sort_order as test_number + FROM test_cases + WHERE problem_id = %s + ORDER BY sort_order + """, (problem_id,)) + + @staticmethod + def find_public_by_problem(problem_id): + """Fetch only non-hidden test cases (public description use).""" + return query_all(""" + SELECT input, output, sort_order + FROM test_cases + WHERE problem_id = %s AND is_hidden = FALSE + ORDER BY sort_order + """, (problem_id,)) diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..2de0b93 --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1,2 @@ +from .problem_service import ProblemService +from .execution_service import ExecutionService diff --git a/src/services/execution_service.py b/src/services/execution_service.py new file mode 100644 index 0000000..5c85d3a --- /dev/null +++ b/src/services/execution_service.py @@ -0,0 +1,25 @@ +from repositories import ProblemRepository, TestCaseRepository +from core import execute_code as core_execute + +class ExecutionService: + def __init__(self): + self.test_case_repo = TestCaseRepository() + self.problem_repo = ProblemRepository() + + def run_problem_code(self, problem_id, code, lang): + """Execute code against stored test cases for a problem.""" + problem = self.problem_repo.find_by_id(problem_id) + if not problem: + return {"status": "error", "message": "Problem not found"} + + test_cases = self.test_case_repo.find_all_by_problem(problem_id) + cfg = problem.get('config', {}) + + return core_execute( + code=code, + lang=lang, + tests=test_cases, + timeout=cfg.get("timeout", 5), + templates=cfg.get("templates", {}), + rules=cfg.get("rules", {}) + ) diff --git a/src/services/problem_service.py b/src/services/problem_service.py new file mode 100644 index 0000000..a6c2c82 --- /dev/null +++ b/src/services/problem_service.py @@ -0,0 +1,35 @@ +from repositories import ProblemRepository, TestCaseRepository + +class ProblemService: + def __init__(self): + self.problem_repo = ProblemRepository() + self.test_case_repo = TestCaseRepository() + + def list_all_problems(self): + """Get summary list of problems.""" + return self.problem_repo.find_all() + + def get_problem_details(self, problem_id): + """Get full problem details for the API response.""" + problem = self.problem_repo.find_by_id(problem_id) + if not problem: + return None + + # Filter config (Security/Business logic) + allowed_keys = {'timeout', 'templates'} + original_config = problem.get('config', {}) + problem['config'] = {k: v for k, v in original_config.items() if k in allowed_keys} + + # Enrich with categories, tags, and public test cases + problem['categories'] = self.problem_repo.get_categories(problem_id) + problem['tags'] = self.problem_repo.get_tags(problem_id) + problem['test_cases'] = self.test_case_repo.find_public_by_problem(problem_id) + + return problem + def list_problems_by_category(self, category_name): + """Get problems filtered by category.""" + return self.problem_repo.find_by_category(category_name) + + def list_problems_by_tag(self, tag_name): + """Get problems filtered by tag.""" + return self.problem_repo.find_by_tag(tag_name) From 471e2cbaebb8d1125d020a5fc5baa41512af89d3 Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Mon, 9 Feb 2026 02:07:57 +0700 Subject: [PATCH 08/14] feat: update Bruno request to query problems by tag instead of by ID. --- bruno/local/GET- Problems/get problems by tag.bru | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bruno/local/GET- Problems/get problems by tag.bru b/bruno/local/GET- Problems/get problems by tag.bru index 649e7a5..064fdb7 100644 --- a/bruno/local/GET- Problems/get problems by tag.bru +++ b/bruno/local/GET- Problems/get problems by tag.bru @@ -5,11 +5,15 @@ meta { } get { - url: http://localhost:3000/problem/b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d + url: http://localhost:3000/problems?tag=string body: none auth: inherit } +params:query { + tag: string +} + settings { encodeUrl: true timeout: 0 From 8101176a3499b5a290e365055542505dba4abb55 Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Mon, 9 Feb 2026 02:17:34 +0700 Subject: [PATCH 09/14] docs: overhaul and expand README with detailed setup instructions, API documentation references, and usage guidelines. --- README.md | 118 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 70 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index a06b77e..3907188 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,87 @@ - # ๐Ÿ–ฅ๏ธ CodeExecutor-API -This project provides a API to run submitted code against predefined test cases. ๐Ÿงช +A professional, layered-architecture API for dynamic code execution. This project allows you to run code in multiple languages against database-backed problems and test cases. ๐Ÿš€๐Ÿงช + +--- + +## ๐Ÿ› ๏ธ Prerequisites +Before embarking on this journey, ensure your machine is equipped with: +- ๐Ÿณ **Docker**: [Install Docker](https://docs.docker.com/get-docker/) +- ๐Ÿณ **Docker Compose**: [Install Docker Compose](https://docs.docker.com/compose/install/) + +--- + +## ๐Ÿš€ Step-by-Step Setup Guide + +Follow these steps to get your own instance of **CodeExecutor-API** up and running in minutes! -## ๐Ÿ› ๏ธ Requirements: - `๐Ÿณ Docker` `๐Ÿณ Docker compose` +### 1๏ธโƒฃ Clone the Repository +Open your terminal and clone this project: +```bash +git clone +cd CodeExecutor-API +``` -## ๐Ÿš€ Running the API -Build and start the container: +### 2๏ธโƒฃ Configure Environment โš™๏ธ +The project uses a `.env` file for configuration. Copy the example to get started: +```bash +cp .env.example .env +``` +*(You can tweak the `DATABASE_URL` or ports inside `.env` if needed, but defaults work perfectly!)* +### 3๏ธโƒฃ Build and Launch ๐Ÿ—๏ธ +Run the entire stack (API + PostgreSQL DB) using Docker Compose: ```bash docker compose up -d --build ``` +- `-d`: Runs containers in the background (**detached mode**). +- `--build`: Ensures all recent code changes are freshly compiled. + +### 4๏ธโƒฃ Verify System Health โœ… +Once started, you can check if everything is breathing: +- **Container Status**: `docker compose ps` (Look for `Up (healthy)`) +- **API Logs**: `docker compose logs -f code-api` +- **Database Logs**: `docker compose logs -f db` + +### 5๏ธโƒฃ Automatic initialization ๐Ÿช„ +On the very first run, the system will automatically: +- Create all database tables (using UUIDs for safety). +- Seed **7+ coding problems** (Two Sum, Palindrome, etc.). +- Link problems with their respective categories and tags. + --- -## ๐Ÿ”— API Endpoint -**POST** `http://127.0.0.1:3000/code/` +## ๐Ÿ“– Complete API Documentation +Since you're up and running, you'll want to know how to talk to the API! -Request JSON Format: +- **Detailed Endpoints**: See [API_DOCUMENTATION.md](./API_DOCUMENTATION.md) for full request/response examples. +- **Interactive Swagger/Scalar Docs**: Visit [http://localhost:3000/docs](http://localhost:3000/docs) while the app is running. +- **OpenAPI Spec**: Available at `http://localhost:3000/openapi.yaml`. -```json -{ - "code": "", - "language": "python" -} -``` - `๐Ÿ“ Notes: For \t (tab) please sent 4 spacebar instead` - -Response JSON Format: - -```json -{ - "status": "correct|incorrect|error", - "msg": "Summary message", - "tests": [ - { - "case": 1, - "status": "passed|failed", - "msg": "...", - "stdout": "...", - "stderr": "..." - } - ] -} -``` +--- -**POST** `http://127.0.0.1:3000/run` +## ๏ฟฝ Testing with Bruno +For the best developer experience, we've included a **Bruno Collection**: +1. Open **Bruno**. +2. Import the folder located at `./bruno`. +3. Start firing requests! -Request JSON Format: +--- -```json -{ - "code": "", - "language": "python" -} -``` - `๐Ÿ“ Notes: For \t (tab) please sent 4 spacebar instead` +## ๐Ÿงช Supported Languages +- ๐Ÿ **Python** (`python`) +- ๐ŸŸจ **JavaScript** (`javascript`) +- โ˜• **Java** (`java`) +- ๐ŸŸฆ **C** (`c`) +- ๐ŸŸฅ **C++** (`cpp`) + +--- + +## ๐Ÿ“ Usage Best Practices +- ๐Ÿ’ก **4 Spaces**: Always use spaces instead of tabs in your JSON `code` strings. +- โฑ๏ธ **Timeouts**: Most problems are capped at 5 seconds for safety. +- ๐Ÿ”’ **Sandboxing**: Code runs in a dedicated non-root container environment. -### ๐Ÿ“ Notes +--- -* โฑ๏ธ Default port is `3000` change port as needed in `docker-compose.yml`. -* โฑ๏ธ The API will be available at `http://127.0.0.1:` -* ๐Ÿ“ Folder IDs correspond to folders under `tests/`. -NOTE: The test will be further update as database +Build with focus on **Modular, Scalable, and Professional** architecture. โœจ From 7dc67ce4f3243d738c3a473da8cd959a0e225cff Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Mon, 9 Feb 2026 02:18:00 +0700 Subject: [PATCH 10/14] docs: update repository clone URL in README. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3907188..e383eac 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Follow these steps to get your own instance of **CodeExecutor-API** up and runni ### 1๏ธโƒฃ Clone the Repository Open your terminal and clone this project: ```bash -git clone +git clone https://github.com/Saannddy/CodeExecutor-API cd CodeExecutor-API ``` From fe3e404934ab0bace28929036354a10cd30be51b Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:46:58 +0700 Subject: [PATCH 11/14] docs: add tooling recommendations to README and update Bruno request for problems from tag-only to tag and category --- README.md | 10 +++++++++- ...by tag.bru => get problems by tag and category.bru} | 5 +++-- 2 files changed, 12 insertions(+), 3 deletions(-) rename bruno/local/GET- Problems/{get problems by tag.bru => get problems by tag and category.bru} (54%) diff --git a/README.md b/README.md index e383eac..b98adf5 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,15 @@ On the very first run, the system will automatically: --- -## ๐Ÿ“– Complete API Documentation +## ๏ฟฝ๏ธ Recommended Tooling +For the most professional development experience, we recommend the following tools: +- โšก **[OrbStack](https://orbstack.dev/)**: A fast, light, and easy way to run Docker containers on macOS. +- ๐Ÿ **[Beekeeper Studio](https://www.beekeeperstudio.io/)**: A smooth and easy-to-use SQL editor and database manager for PostgreSQL. +- ๐Ÿถ **[Bruno](https://www.usebruno.com/)**: A fast and git-friendly API client for testing the CodeExecutor endpoints. + +--- + +## ๏ฟฝ๐Ÿ“– Complete API Documentation Since you're up and running, you'll want to know how to talk to the API! - **Detailed Endpoints**: See [API_DOCUMENTATION.md](./API_DOCUMENTATION.md) for full request/response examples. diff --git a/bruno/local/GET- Problems/get problems by tag.bru b/bruno/local/GET- Problems/get problems by tag and category.bru similarity index 54% rename from bruno/local/GET- Problems/get problems by tag.bru rename to bruno/local/GET- Problems/get problems by tag and category.bru index 064fdb7..cccaea7 100644 --- a/bruno/local/GET- Problems/get problems by tag.bru +++ b/bruno/local/GET- Problems/get problems by tag and category.bru @@ -1,17 +1,18 @@ meta { - name: get problems by tag + name: get problems by tag and category type: http seq: 3 } get { - url: http://localhost:3000/problems?tag=string + url: http://localhost:3000/problems?tag=string&&category=math body: none auth: inherit } params:query { tag: string + category: math } settings { From 7f1ccc44bfd6877ffa5fd569b63ad9e262507056 Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:38:49 +0700 Subject: [PATCH 12/14] feat: Migrate from Yoyo to Alembic for database management and refactor repositories to use SQLModel ORM. --- .gitignore | 45 +++++- Dockerfile | 3 +- README.md | 27 +++- alembic.ini | 149 ++++++++++++++++++ alembic/README | 1 + alembic/env.py | 59 +++++++ alembic/script.py.mako | 29 ++++ .../0052deea4f5e_fix_forward_references.py | 33 ++++ ...bbb4a0_use_unidirectional_relationships.py | 33 ++++ ...d9d3fb87_standardize_relationship_names.py | 33 ++++ ...c48ea95cb4e7_initial_sqlmodel_migration.py | 85 ++++++++++ ...f5d8dfa5_resolve_relationship_conflicts.py | 33 ++++ docker-compose.yml | 4 +- migrations/0001_initial_schema.py | 58 ------- migrations/0002_seed_data.py | 48 ------ migrations/0003_multiply_problem.py | 37 ----- migrations/0004_more_problems_seed.py | 140 ---------------- migrations/0005_standardize_to_uuid.py | 66 -------- src/infrastructure/database.py | 83 +++------- src/models/__init__.py | 1 + src/models/base.py | 48 ++++++ src/repositories/problem_repository.py | 90 ++++++----- src/repositories/test_case_repository.py | 39 +++-- src/requirements.txt | 4 +- src/seed.py | 117 ++++++++++++++ src/services/problem_service.py | 16 +- 26 files changed, 790 insertions(+), 491 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0052deea4f5e_fix_forward_references.py create mode 100644 alembic/versions/4aa144bbb4a0_use_unidirectional_relationships.py create mode 100644 alembic/versions/a901d9d3fb87_standardize_relationship_names.py create mode 100644 alembic/versions/c48ea95cb4e7_initial_sqlmodel_migration.py create mode 100644 alembic/versions/e010f5d8dfa5_resolve_relationship_conflicts.py delete mode 100644 migrations/0001_initial_schema.py delete mode 100644 migrations/0002_seed_data.py delete mode 100644 migrations/0003_multiply_problem.py delete mode 100644 migrations/0004_more_problems_seed.py delete mode 100644 migrations/0005_standardize_to_uuid.py create mode 100644 src/models/__init__.py create mode 100644 src/models/base.py create mode 100644 src/seed.py diff --git a/.gitignore b/.gitignore index fac4b35..e42378f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,46 @@ +# Environment .env -*/tests/*/ -*/__pycache__/ +.venv/ +ENV/ +env/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# OS / IDE +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +.vscode/ +.idea/ + +# Database / Tools +*.sqlite +*.db +alembic/versions/__pycache__/ *.class *.exe \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a339643..b0c4e37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,8 @@ RUN pip install --no-cache-dir -r requirements.txt --break-system-packages # Copy application source COPY --chown=coder:coder src/ . -COPY --chown=coder:coder migrations/ ./migrations/ +COPY --chown=coder:coder alembic/ ./alembic/ +COPY --chown=coder:coder alembic.ini . USER coder diff --git a/README.md b/README.md index b98adf5..049be2f 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,7 @@ docker compose up -d --build - `-d`: Runs containers in the background (**detached mode**). - `--build`: Ensures all recent code changes are freshly compiled. -### 4๏ธโƒฃ Verify System Health โœ… -Once started, you can check if everything is breathing: -- **Container Status**: `docker compose ps` (Look for `Up (healthy)`) -- **API Logs**: `docker compose logs -f code-api` -- **Database Logs**: `docker compose logs -f db` - -### 5๏ธโƒฃ Automatic initialization ๐Ÿช„ +### 5๏ธโƒฃ Automatic Initialization ๐Ÿช„ On the very first run, the system will automatically: - Create all database tables (using UUIDs for safety). - Seed **7+ coding problems** (Two Sum, Palindrome, etc.). @@ -51,6 +45,25 @@ On the very first run, the system will automatically: --- +## ๐Ÿ—๏ธ Database Management +While the system handles initialization automatically, you may need these manual commands for development: + +### ๐Ÿ”„ Migrations (Alembic) +If you modify the models in `src/models/`, use these commands to sync the database: +- **Check Status**: `docker compose exec code-api alembic current` +- **Apply Migrations**: `docker compose exec code-api alembic upgrade head` +- **Generate New Migration**: + ```bash + docker compose exec code-api alembic revision --autogenerate -m "description_of_change" + ``` + +### ๐ŸŒฑ Data Seeding +If you need to re-seed or reset the initial data: +- **Run Seeder**: `docker compose exec code-api python3 seed.py` +*(The seeder is idempotent and will skip problems that already exist!)* + +--- + ## ๏ฟฝ๏ธ Recommended Tooling For the most professional development experience, we recommend the following tools: - โšก **[OrbStack](https://orbstack.dev/)**: A fast, light, and easy way to run Docker containers on macOS. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..807ded2 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/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 +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be 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 /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 "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# 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 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[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 module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +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/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..d847087 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,59 @@ +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context + +# Add src to path if needed +sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'src'))) + +from sqlmodel import SQLModel +from models import Problem, TestCase, Category, Tag, ProblemCategoryLink, ProblemTagLink # noqa + +# 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) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = SQLModel.metadata + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = os.environ.get("DATABASE_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.""" + url = os.environ.get("DATABASE_URL") + + from sqlalchemy import create_engine + connectable = create_engine(url) + + 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/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..697cf67 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,29 @@ +"""${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 +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[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: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0052deea4f5e_fix_forward_references.py b/alembic/versions/0052deea4f5e_fix_forward_references.py new file mode 100644 index 0000000..99070ae --- /dev/null +++ b/alembic/versions/0052deea4f5e_fix_forward_references.py @@ -0,0 +1,33 @@ +"""fix_forward_references + +Revision ID: 0052deea4f5e +Revises: a901d9d3fb87 +Create Date: 2026-02-09 13:12:24.953037 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '0052deea4f5e' +down_revision: Union[str, Sequence[str], None] = 'a901d9d3fb87' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/4aa144bbb4a0_use_unidirectional_relationships.py b/alembic/versions/4aa144bbb4a0_use_unidirectional_relationships.py new file mode 100644 index 0000000..277329c --- /dev/null +++ b/alembic/versions/4aa144bbb4a0_use_unidirectional_relationships.py @@ -0,0 +1,33 @@ +"""use_unidirectional_relationships + +Revision ID: 4aa144bbb4a0 +Revises: 0052deea4f5e +Create Date: 2026-02-09 13:12:53.164099 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '4aa144bbb4a0' +down_revision: Union[str, Sequence[str], None] = '0052deea4f5e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/a901d9d3fb87_standardize_relationship_names.py b/alembic/versions/a901d9d3fb87_standardize_relationship_names.py new file mode 100644 index 0000000..d36778d --- /dev/null +++ b/alembic/versions/a901d9d3fb87_standardize_relationship_names.py @@ -0,0 +1,33 @@ +"""standardize_relationship_names + +Revision ID: a901d9d3fb87 +Revises: e010f5d8dfa5 +Create Date: 2026-02-09 13:11:49.782157 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'a901d9d3fb87' +down_revision: Union[str, Sequence[str], None] = 'e010f5d8dfa5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/c48ea95cb4e7_initial_sqlmodel_migration.py b/alembic/versions/c48ea95cb4e7_initial_sqlmodel_migration.py new file mode 100644 index 0000000..1b35ff6 --- /dev/null +++ b/alembic/versions/c48ea95cb4e7_initial_sqlmodel_migration.py @@ -0,0 +1,85 @@ +"""initial_sqlmodel_migration + +Revision ID: c48ea95cb4e7 +Revises: +Create Date: 2026-02-09 13:01:14.448516 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'c48ea95cb4e7' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('categories', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True) + op.create_table('problems', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('difficulty', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('config', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_problems_title'), 'problems', ['title'], unique=False) + op.create_table('tags', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tags_name'), 'tags', ['name'], unique=True) + op.create_table('problem_categories', + sa.Column('problem_id', sa.Uuid(), nullable=False), + sa.Column('category_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), + sa.ForeignKeyConstraint(['problem_id'], ['problems.id'], ), + sa.PrimaryKeyConstraint('problem_id', 'category_id') + ) + op.create_table('problem_tags', + sa.Column('problem_id', sa.Uuid(), nullable=False), + sa.Column('tag_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['problem_id'], ['problems.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('problem_id', 'tag_id') + ) + op.create_table('test_cases', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('problem_id', sa.Uuid(), nullable=False), + sa.Column('input', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('output', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('is_hidden', sa.Boolean(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['problem_id'], ['problems.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('test_cases') + op.drop_table('problem_tags') + op.drop_table('problem_categories') + op.drop_index(op.f('ix_tags_name'), table_name='tags') + op.drop_table('tags') + op.drop_index(op.f('ix_problems_title'), table_name='problems') + op.drop_table('problems') + op.drop_index(op.f('ix_categories_name'), table_name='categories') + op.drop_table('categories') + # ### end Alembic commands ### diff --git a/alembic/versions/e010f5d8dfa5_resolve_relationship_conflicts.py b/alembic/versions/e010f5d8dfa5_resolve_relationship_conflicts.py new file mode 100644 index 0000000..3f276e3 --- /dev/null +++ b/alembic/versions/e010f5d8dfa5_resolve_relationship_conflicts.py @@ -0,0 +1,33 @@ +"""resolve_relationship_conflicts + +Revision ID: e010f5d8dfa5 +Revises: c48ea95cb4e7 +Create Date: 2026-02-09 13:11:16.449054 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'e010f5d8dfa5' +down_revision: Union[str, Sequence[str], None] = 'c48ea95cb4e7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/docker-compose.yml b/docker-compose.yml index 586f79b..9a34331 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,9 +30,9 @@ services: db: condition: service_healthy environment: - - DATABASE_URL=${DATABASE_URL} + - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-code_executor} command: > - sh -c "yoyo apply --database $$DATABASE_URL ./migrations -b && gunicorn --bind 0.0.0.0:3000 --access-logfile - app:app" + sh -c "python3 -m alembic upgrade head && python3 seed.py && gunicorn --bind 0.0.0.0:3000 --access-logfile - app:app" volumes: postgres_data: diff --git a/migrations/0001_initial_schema.py b/migrations/0001_initial_schema.py deleted file mode 100644 index 63b895f..0000000 --- a/migrations/0001_initial_schema.py +++ /dev/null @@ -1,58 +0,0 @@ -from yoyo import step - -__depends__ = {} - -steps = [ - step( - """ - CREATE EXTENSION IF NOT EXISTS "pgcrypto"; - - -- Problems Table - CREATE TABLE IF NOT EXISTS problems ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - title VARCHAR(255) NOT NULL, - description TEXT NOT NULL, - difficulty VARCHAR(20), -- easy, medium, hard - config JSONB, -- Stores timeout, templates, and rules - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - - -- Test Cases Table - CREATE TABLE IF NOT EXISTS test_cases ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - problem_id UUID REFERENCES problems(id) ON DELETE CASCADE, - input TEXT, - output TEXT, - is_hidden BOOLEAN DEFAULT TRUE, - sort_order INT - ); - - -- Categories Table - CREATE TABLE IF NOT EXISTS categories ( - id SERIAL PRIMARY KEY, - name VARCHAR UNIQUE NOT NULL - ); - - -- Tags Table - CREATE TABLE IF NOT EXISTS tags ( - id SERIAL PRIMARY KEY, - name VARCHAR UNIQUE NOT NULL - ); - - -- Join Table: Many-to-Many for Categories - CREATE TABLE IF NOT EXISTS problem_categories ( - problem_id UUID REFERENCES problems(id) ON DELETE CASCADE, - category_id INT REFERENCES categories(id) ON DELETE CASCADE, - PRIMARY KEY (problem_id, category_id) - ); - - -- Join Table: Many-to-Many for Tags - CREATE TABLE IF NOT EXISTS problem_tags ( - problem_id UUID REFERENCES problems(id) ON DELETE CASCADE, - tag_id INT REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (problem_id, tag_id) - ); - """, - "DROP TABLE IF EXISTS problem_tags; DROP TABLE IF EXISTS problem_categories; DROP TABLE IF EXISTS tags; DROP TABLE IF EXISTS categories; DROP TABLE IF EXISTS test_cases; DROP TABLE IF EXISTS problems;" - ) -] diff --git a/migrations/0002_seed_data.py b/migrations/0002_seed_data.py deleted file mode 100644 index a21e7e6..0000000 --- a/migrations/0002_seed_data.py +++ /dev/null @@ -1,48 +0,0 @@ -from yoyo import step - -__depends__ = {"0001_initial_schema"} - -steps = [ - step( - """ - -- Seed Categories - INSERT INTO categories (name) VALUES ('Array'), ('String'), ('Math') ON CONFLICT (name) DO NOTHING; - - -- Seed Tags - INSERT INTO tags (name) VALUES ('Easy'), ('LeetCode'), ('Classic') ON CONFLICT (name) DO NOTHING; - - -- Seed Problem: Two Sum - INSERT INTO problems (id, title, description, difficulty, config) - VALUES ( - 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', - 'Two Sum', - 'Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.', - 'easy', - '{"timeout": 5, "templates": {"python": "def twoSum(nums, target):\\n pass"}}' - ) ON CONFLICT (id) DO NOTHING; - - -- Seed Test Cases for Two Sum - INSERT INTO test_cases (problem_id, input, output, is_hidden, sort_order) - VALUES - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '[2,7,11,15]\\n9', '[0,1]', false, 1), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '[3,2,4]\\n6', '[1,2]', false, 2), - ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '[3,3]\\n6', '[0,1]', true, 3) - ON CONFLICT (id) DO NOTHING; - - -- Link Problem to Category and Tag - INSERT INTO problem_categories (problem_id, category_id) - SELECT 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', id FROM categories WHERE name = 'Array' - ON CONFLICT DO NOTHING; - - INSERT INTO problem_tags (problem_id, tag_id) - SELECT 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', id FROM tags WHERE name = 'Easy' - ON CONFLICT DO NOTHING; - """, - """ - DELETE FROM problem_tags WHERE problem_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; - DELETE FROM problem_categories WHERE problem_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; - DELETE FROM test_cases WHERE problem_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; - DELETE FROM problems WHERE id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; - """ - ) -] diff --git a/migrations/0003_multiply_problem.py b/migrations/0003_multiply_problem.py deleted file mode 100644 index 47447a9..0000000 --- a/migrations/0003_multiply_problem.py +++ /dev/null @@ -1,37 +0,0 @@ -from yoyo import step - -__depends__ = {"0002_seed_data"} - -steps = [ - step( - """ - -- Seed Problem: Multiply Two Numbers - INSERT INTO problems (id, title, description, difficulty, config) - VALUES ( - 'b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d', - 'Multiply Two Numbers', - 'Given two integers separated by a space, return their product.', - 'easy', - '{"timeout": 2, "templates": {"python": "num1, num2 = map(int, input().split())\\nprint(num1 * num2)"}}' - ) ON CONFLICT (id) DO NOTHING; - - -- Seed Test Cases for Multiply - INSERT INTO test_cases (problem_id, input, output, is_hidden, sort_order) - VALUES - ('b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d', '2 3', '6', false, 1), - ('b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d', '-4 5', '-20', false, 2), - ('b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d', '10 10', '100', true, 3) - ON CONFLICT (id) DO NOTHING; - - -- Link Problem to Category 'Math' - INSERT INTO problem_categories (problem_id, category_id) - SELECT 'b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d', id FROM categories WHERE name = 'Math' - ON CONFLICT DO NOTHING; - """, - """ - DELETE FROM problem_categories WHERE problem_id = 'b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d'; - DELETE FROM test_cases WHERE problem_id = 'b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d'; - DELETE FROM problems WHERE id = 'b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d'; - """ - ) -] diff --git a/migrations/0004_more_problems_seed.py b/migrations/0004_more_problems_seed.py deleted file mode 100644 index e651974..0000000 --- a/migrations/0004_more_problems_seed.py +++ /dev/null @@ -1,140 +0,0 @@ -import uuid -import json -from yoyo import step - -def apply_step(conn): - cursor = conn.cursor() - - # Problems Data (ID, Title, Description, Difficulty, Config) - p_ids = { - 'palindrome': str(uuid.uuid4()), - 'factorial': str(uuid.uuid4()), - 'fibonacci': str(uuid.uuid4()), - 'find_max': str(uuid.uuid4()), - 'anagram': str(uuid.uuid4()) - } - - problems = [ - (p_ids['palindrome'], 'Palindrome Check', 'Write a function that checks if a given string is a palindrome.', 'Easy', { - 'timeout': 5, - 'templates': { - 'python': 'def is_palindrome(s):\n # __CODE_GOES_HERE__\n pass', - 'javascript': 'function isPalindrome(s) {\n // __CODE_GOES_HERE__\n}' - } - }), - (p_ids['factorial'], 'Factorial', 'Write a function that calculates the factorial of a given non-negative integer n.', 'Easy', { - 'timeout': 5, - 'templates': { - 'python': 'def factorial(n):\n # __CODE_GOES_HERE__\n pass', - 'javascript': 'function factorial(n) {\n // __CODE_GOES_HERE__\n}' - } - }), - (p_ids['fibonacci'], 'Fibonacci Number', 'Write a function that returns the n-th Fibonacci number.', 'Easy', { - 'timeout': 5, - 'templates': { - 'python': 'def fib(n):\n # __CODE_GOES_HERE__\n pass', - 'javascript': 'function fib(n) {\n // __CODE_GOES_HERE__\n}' - } - }), - (p_ids['find_max'], 'Find Maximum', 'Write a function that returns the maximum element in a given array of integers.', 'Easy', { - 'timeout': 5, - 'templates': { - 'python': 'def find_max(arr):\n # __CODE_GOES_HERE__\n pass', - 'javascript': 'function findMax(arr) {\n // __CODE_GOES_HERE__\n}' - } - }), - (p_ids['anagram'], 'Valid Anagram', 'Write a function that determines if two strings are anagrams of each other.', 'Easy', { - 'timeout': 5, - 'templates': { - 'python': 'def is_anagram(s, t):\n # __CODE_GOES_HERE__\n pass', - 'javascript': 'function isAnagram(s, t) {\n // __CODE_GOES_HERE__\n}' - } - }) - ] - - for p in problems: - cursor.execute( - "INSERT INTO problems (id, title, description, difficulty, config) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING", - (p[0], p[1], p[2], p[3], json.dumps(p[4])) - ) - - # Test Cases Data - test_cases = [ - # Palindrome Check - (p_ids['palindrome'], '"racecar"', 'True', 1, False), - (p_ids['palindrome'], '"hello"', 'False', 2, False), - # Factorial - (p_ids['factorial'], '5', '120', 1, False), - (p_ids['factorial'], '0', '1', 2, False), - # Fibonacci - (p_ids['fibonacci'], '6', '8', 1, False), - (p_ids['fibonacci'], '0', '0', 2, False), - # Find Max - (p_ids['find_max'], '[1, 5, 3, 9, 2]', '9', 1, False), - (p_ids['find_max'], '[-1, -5, -3]', '-1', 2, False), - # Valid Anagram - (p_ids['anagram'], '"anagram", "nagaram"', 'True', 1, False), - (p_ids['anagram'], '"rat", "car"', 'False', 2, False) - ] - - for tc in test_cases: - cursor.execute( - "INSERT INTO test_cases (problem_id, input, output, sort_order, is_hidden) VALUES (%s, %s, %s, %s, %s)", - tc - ) - - # Categories and Tags - categories = [('Basic',), ('Array',), ('String',), ('Math',)] - for cat in categories: - cursor.execute("INSERT INTO categories (name) VALUES (%s) ON CONFLICT (name) DO NOTHING", cat) - - tags = [('String',), ('Math',), ('Recursion',), ('Array',)] - for t in tags: - cursor.execute("INSERT INTO tags (name) VALUES (%s) ON CONFLICT (name) DO NOTHING", t) - - # Linking - cursor.execute(""" - INSERT INTO problem_categories (problem_id, category_id) - SELECT %s, id FROM categories WHERE name = 'String' - """, (p_ids['palindrome'],)) - cursor.execute(""" - INSERT INTO problem_categories (problem_id, category_id) - SELECT %s, id FROM categories WHERE name = 'Math' - """, (p_ids['factorial'],)) - cursor.execute(""" - INSERT INTO problem_categories (problem_id, category_id) - SELECT %s, id FROM categories WHERE name = 'Math' - """, (p_ids['fibonacci'],)) - cursor.execute(""" - INSERT INTO problem_categories (problem_id, category_id) - SELECT %s, id FROM categories WHERE name = 'Array' - """, (p_ids['find_max'],)) - cursor.execute(""" - INSERT INTO problem_categories (problem_id, category_id) - SELECT %s, id FROM categories WHERE name = 'String' - """, (p_ids['anagram'],)) - - cursor.execute(""" - INSERT INTO problem_tags (problem_id, tag_id) - SELECT %s, id FROM tags WHERE name = 'String' - """, (p_ids['palindrome'],)) - cursor.execute(""" - INSERT INTO problem_tags (problem_id, tag_id) - SELECT %s, id FROM tags WHERE name = 'Math' - """, (p_ids['factorial'],)) - cursor.execute(""" - INSERT INTO problem_tags (problem_id, tag_id) - SELECT %s, id FROM tags WHERE name = 'Recursion' - """, (p_ids['fibonacci'],)) - cursor.execute(""" - INSERT INTO problem_tags (problem_id, tag_id) - SELECT %s, id FROM tags WHERE name = 'Array' - """, (p_ids['find_max'],)) - cursor.execute(""" - INSERT INTO problem_tags (problem_id, tag_id) - SELECT %s, id FROM tags WHERE name = 'String' - """, (p_ids['anagram'],)) - -steps = [ - step(apply_step) -] diff --git a/migrations/0005_standardize_to_uuid.py b/migrations/0005_standardize_to_uuid.py deleted file mode 100644 index 6f0dd68..0000000 --- a/migrations/0005_standardize_to_uuid.py +++ /dev/null @@ -1,66 +0,0 @@ -from yoyo import step - -__depends__ = {"0004_more_problems_seed"} - -def apply_step(conn): - cursor = conn.cursor() - - # 1. Categories Migration - # Add new UUID column - cursor.execute("ALTER TABLE categories ADD COLUMN uuid_id UUID DEFAULT gen_random_uuid()") - - # Update problem_categories to use a temporary UUID column - cursor.execute("ALTER TABLE problem_categories ADD COLUMN category_uuid UUID") - cursor.execute(""" - UPDATE problem_categories pc - SET category_uuid = c.uuid_id - FROM categories c - WHERE pc.category_id = c.id - """) - - # Drop old constraints and columns for categories - cursor.execute("ALTER TABLE problem_categories DROP CONSTRAINT problem_categories_category_id_fkey") - cursor.execute("ALTER TABLE problem_categories DROP COLUMN category_id") - cursor.execute("ALTER TABLE categories DROP CONSTRAINT categories_pkey CASCADE") - cursor.execute("ALTER TABLE categories DROP COLUMN id") - - # Make uuid_id the new primary key and rename it - cursor.execute("ALTER TABLE categories ADD PRIMARY KEY (uuid_id)") - cursor.execute("ALTER TABLE categories RENAME COLUMN uuid_id TO id") - - # finalize problem_categories - cursor.execute("ALTER TABLE problem_categories RENAME COLUMN category_uuid TO category_id") - cursor.execute("ALTER TABLE problem_categories ADD CONSTRAINT problem_categories_category_id_fkey FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE") - cursor.execute("ALTER TABLE problem_categories ADD PRIMARY KEY (problem_id, category_id)") - - # 2. Tags Migration - # Add new UUID column - cursor.execute("ALTER TABLE tags ADD COLUMN uuid_id UUID DEFAULT gen_random_uuid()") - - # Update problem_tags to use a temporary UUID column - cursor.execute("ALTER TABLE problem_tags ADD COLUMN tag_uuid UUID") - cursor.execute(""" - UPDATE problem_tags pt - SET tag_uuid = t.uuid_id - FROM tags t - WHERE pt.tag_id = t.id - """) - - # Drop old constraints and columns for tags - cursor.execute("ALTER TABLE problem_tags DROP CONSTRAINT problem_tags_tag_id_fkey") - cursor.execute("ALTER TABLE problem_tags DROP COLUMN tag_id") - cursor.execute("ALTER TABLE tags DROP CONSTRAINT tags_pkey CASCADE") - cursor.execute("ALTER TABLE tags DROP COLUMN id") - - # Make uuid_id the new primary key and rename it - cursor.execute("ALTER TABLE tags ADD PRIMARY KEY (uuid_id)") - cursor.execute("ALTER TABLE tags RENAME COLUMN uuid_id TO id") - - # finalize problem_tags - cursor.execute("ALTER TABLE problem_tags RENAME COLUMN tag_uuid TO tag_id") - cursor.execute("ALTER TABLE problem_tags ADD CONSTRAINT problem_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE") - cursor.execute("ALTER TABLE problem_tags ADD PRIMARY KEY (problem_id, tag_id)") - -steps = [ - step(apply_step) -] diff --git a/src/infrastructure/database.py b/src/infrastructure/database.py index d81fee4..98328e4 100644 --- a/src/infrastructure/database.py +++ b/src/infrastructure/database.py @@ -1,68 +1,37 @@ import os -import psycopg2 -import psycopg2.extras -from psycopg2 import pool import logging +from sqlmodel import create_engine, Session, SQLModel +from sqlalchemy.orm import sessionmaker # Database configuration DATABASE_URL = os.environ.get('DATABASE_URL') -# Connection pool setup -db_pool = None +# SQLModel engine setup +engine = None +SessionLocal = None -from yoyo import read_migrations, get_backend +if DATABASE_URL: + engine = create_engine(DATABASE_URL, echo=False) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=Session) + logging.info("SQLModel engine and sessionmaker initialized.") def init_db(): - global db_pool + """Perform database initialization tasks like seeding.""" try: - if DATABASE_URL: - db_pool = psycopg2.pool.SimpleConnectionPool(1, 10, dsn=DATABASE_URL) - logging.info("Database connection pool initialized.") - - # Run migrations - backend = get_backend(DATABASE_URL) - migrations = read_migrations('./migrations') - with backend.lock(): - backend.apply_migrations(backend.to_apply(migrations)) - logging.info("Database migrations applied successfully.") - else: - logging.warning("DATABASE_URL not set. Database integration disabled.") - except Exception as e: - logging.error(f"Error initializing database pool or running migrations: {e}") - + pass # Seeding moved to entrypoint + except Exception: + logging.exception("Error during database initialization/seeding") + +def get_session(): + """Dependency for getting a database session.""" + if SessionLocal: + with SessionLocal() as session: + yield session + else: + yield None + +# Legacy helpers for raw SQL if absolutely needed during transition def get_db_connection(): - if db_pool: - return db_pool.getconn() - return None - -def release_db_connection(conn): - if db_pool and conn: - db_pool.putconn(conn) - -def query_one(query, args=()): - """Execute query and return one result.""" - conn = get_db_connection() - if not conn: return None - try: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute(query, args) - return cur.fetchone() - except Exception as e: - logging.error(f"Database query error: {e}") - return None - finally: - release_db_connection(conn) - -def query_all(query, args=()): - """Execute query and return all results.""" - conn = get_db_connection() - if not conn: return [] - try: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute(query, args) - return cur.fetchall() - except Exception as e: - logging.error(f"Database query error: {e}") - return [] - finally: - release_db_connection(conn) + # Deprecated: use SQLModel Session instead + import psycopg2 + return psycopg2.connect(DATABASE_URL) diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..4564675 --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1 @@ +from .base import Problem, TestCase, Category, Tag, ProblemCategoryLink, ProblemTagLink diff --git a/src/models/base.py b/src/models/base.py new file mode 100644 index 0000000..5d4cbad --- /dev/null +++ b/src/models/base.py @@ -0,0 +1,48 @@ +from typing import List, Optional +from uuid import UUID, uuid4 +from sqlmodel import SQLModel, Field, Relationship, JSON, Column + +class ProblemCategoryLink(SQLModel, table=True): + __tablename__ = "problem_categories" + problem_id: UUID = Field(foreign_key="problems.id", primary_key=True) + category_id: UUID = Field(foreign_key="categories.id", primary_key=True) + +class ProblemTagLink(SQLModel, table=True): + __tablename__ = "problem_tags" + problem_id: UUID = Field(foreign_key="problems.id", primary_key=True) + tag_id: UUID = Field(foreign_key="tags.id", primary_key=True) + +class Problem(SQLModel, table=True): + __tablename__ = "problems" + id: UUID = Field(default_factory=uuid4, primary_key=True) + title: str = Field(index=True) + description: str + difficulty: str + config: Optional[dict] = Field(default_factory=dict, sa_column=Column(JSON)) + + # Relationships + test_cases: List["TestCase"] = Relationship(back_populates="problem", sa_relationship_kwargs={"cascade": "all, delete-orphan"}) + categories: List["Category"] = Relationship(link_model=ProblemCategoryLink) + tags: List["Tag"] = Relationship(link_model=ProblemTagLink) + +class Category(SQLModel, table=True): + __tablename__ = "categories" + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(unique=True, index=True) + +class Tag(SQLModel, table=True): + __tablename__ = "tags" + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(unique=True, index=True) + +class TestCase(SQLModel, table=True): + __tablename__ = "test_cases" + id: UUID = Field(default_factory=uuid4, primary_key=True) + problem_id: UUID = Field(foreign_key="problems.id") + input: str + output: str + is_hidden: bool = Field(default=True) + sort_order: int + + # Relationships + problem: Problem = Relationship(back_populates="test_cases") diff --git a/src/repositories/problem_repository.py b/src/repositories/problem_repository.py index 4166aa4..8d9080b 100644 --- a/src/repositories/problem_repository.py +++ b/src/repositories/problem_repository.py @@ -1,53 +1,51 @@ -from infrastructure.database import query_one, query_all +from sqlmodel import select, or_ +from sqlalchemy.orm import selectinload, joinedload +from infrastructure.database import SessionLocal +from models import Problem, Category, Tag class ProblemRepository: - @staticmethod - def find_all(): + def __init__(self, session=None): + self._session = session + + def _get_session(self): + return self._session if self._session else SessionLocal() + + def find_all(self): """Retrieve all problems with basic info.""" - return query_all("SELECT id, title, difficulty FROM problems ORDER BY created_at DESC") + with self._get_session() as session: + statement = select(Problem).order_by(Problem.id) + return [p.model_dump(exclude={"config", "description"}) for p in session.exec(statement).all()] - @staticmethod - def find_by_id(problem_id): - """Retrieve basic problem details by ID.""" - return query_one("SELECT id, title, description, difficulty, config FROM problems WHERE id = %s", (problem_id,)) + def find_by_id(self, problem_id): + """Retrieve a single problem by UUID (Internal use).""" + with self._get_session() as session: + return session.get(Problem, problem_id) - @staticmethod - def get_categories(problem_id): - """Retrieve category names for a problem.""" - rows = query_all(""" - SELECT c.name FROM categories c - JOIN problem_categories pc ON c.id = pc.category_id - WHERE pc.problem_id = %s - """, (problem_id,)) - return [r['name'] for r in rows] + def find_details_by_id(self, problem_id): + """Retrieve a single problem by UUID with categories and tags hydrated (Dict).""" + with self._get_session() as session: + statement = select(Problem).where(Problem.id == problem_id).options( + joinedload(Problem.categories), + joinedload(Problem.tags) + ) + problem = session.exec(statement).first() + if not problem: + return None + + # Hydrate while session is active + p_dict = problem.model_dump() + p_dict['categories'] = [c.name for c in problem.categories] + p_dict['tags'] = [t.name for t in problem.tags] + return p_dict - @staticmethod - def get_tags(problem_id): - """Retrieve tag names for a problem.""" - rows = query_all(""" - SELECT t.name FROM tags t - JOIN problem_tags pt ON t.id = pt.tag_id - WHERE pt.problem_id = %s - """, (problem_id,)) - return [r['name'] for r in rows] - @staticmethod - def find_by_category(category_name): - """Retrieve problems filtered by category name.""" - return query_all(""" - SELECT p.id, p.title, p.difficulty FROM problems p - JOIN problem_categories pc ON p.id = pc.problem_id - JOIN categories c ON pc.category_id = c.id - WHERE c.name ILIKE %s - ORDER BY p.created_at DESC - """, (category_name,)) + def find_by_category(self, category_name): + """Retrieve problems filtered by category name (case-insensitive).""" + with self._get_session() as session: + statement = select(Problem).join(Problem.categories).where(Category.name.ilike(category_name)) + return [p.model_dump(exclude={"config", "description"}) for p in session.exec(statement).all()] - @staticmethod - def find_by_tag(tag_name): - """Retrieve problems filtered by tag name.""" - return query_all(""" - SELECT p.id, p.title, p.difficulty FROM problems p - JOIN problem_tags pt ON p.id = pt.problem_id - JOIN tags t ON pt.tag_id = t.id - WHERE t.name ILIKE %s - ORDER BY p.created_at DESC - """, (tag_name,)) + def find_by_tag(self, tag_name): + """Retrieve problems filtered by tag name (case-insensitive).""" + with self._get_session() as session: + statement = select(Problem).join(Problem.tags).where(Tag.name.ilike(tag_name)) + return [p.model_dump(exclude={"config", "description"}) for p in session.exec(statement).all()] diff --git a/src/repositories/test_case_repository.py b/src/repositories/test_case_repository.py index 7a07bf6..feaa765 100644 --- a/src/repositories/test_case_repository.py +++ b/src/repositories/test_case_repository.py @@ -1,22 +1,27 @@ -from infrastructure.database import query_all +from sqlmodel import select +from infrastructure.database import SessionLocal +from models import TestCase class TestCaseRepository: - @staticmethod - def find_all_by_problem(problem_id): + def __init__(self, session=None): + self._session = session + + def _get_session(self): + return self._session if self._session else SessionLocal() + + def find_all_by_problem(self, problem_id): """Fetch all test cases for a problem (internal/execution use).""" - return query_all(""" - SELECT input, output as expected_output, sort_order as test_number - FROM test_cases - WHERE problem_id = %s - ORDER BY sort_order - """, (problem_id,)) + with self._get_session() as session: + statement = select(TestCase).where(TestCase.problem_id == problem_id).order_by(TestCase.sort_order) + results = session.exec(statement).all() + # Map to expected format for execution engine + return [{"input": tc.input, "expected_output": tc.output, "test_number": tc.sort_order} for tc in results] - @staticmethod - def find_public_by_problem(problem_id): + def find_public_by_problem(self, problem_id): """Fetch only non-hidden test cases (public description use).""" - return query_all(""" - SELECT input, output, sort_order - FROM test_cases - WHERE problem_id = %s AND is_hidden = FALSE - ORDER BY sort_order - """, (problem_id,)) + with self._get_session() as session: + statement = select(TestCase).where( + TestCase.problem_id == problem_id, + TestCase.is_hidden == False + ).order_by(TestCase.sort_order) + return [tc.model_dump(exclude={"is_hidden"}) for tc in session.exec(statement).all()] diff --git a/src/requirements.txt b/src/requirements.txt index 69c9983..8a9454d 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,4 +1,6 @@ Flask==3.0.3 gunicorn==23.0.0 psycopg2-binary==2.9.9 -yoyo-migrations==8.2.0 \ No newline at end of file +yoyo-migrations==8.2.0 +sqlmodel==0.0.22 +alembic==1.13.1 \ No newline at end of file diff --git a/src/seed.py b/src/seed.py new file mode 100644 index 0000000..d1d5420 --- /dev/null +++ b/src/seed.py @@ -0,0 +1,117 @@ +import logging +import uuid +from sqlmodel import Session, select +from infrastructure.database import engine +from models import Problem, Category, Tag, TestCase + +def seed_data(): + with Session(engine) as session: + # Check if we already have data + if session.exec(select(Problem)).first(): + logging.info("Database already seeded. Skipping.") + return + + logging.info("Seeding initial data...") + + def get_or_create_category(name): + cat = session.exec(select(Category).where(Category.name == name)).first() + if not cat: + cat = Category(name=name) + session.add(cat) + session.commit() + session.refresh(cat) + return cat + + def get_or_create_tag(name): + tag = session.exec(select(Tag).where(Tag.name == name)).first() + if not tag: + tag = Tag(name=name) + session.add(tag) + session.commit() + session.refresh(tag) + return tag + + # 1. Categories + math = get_or_create_category("Math") + logic = get_or_create_category("Logic") + string_cat = get_or_create_category("String") + algorithms = get_or_create_category("Algorithms") + + # 2. Tags + easy = get_or_create_tag("Easy") + medium = get_or_create_tag("Medium") + basic = get_or_create_tag("Basic") + array = get_or_create_tag("Array") + hash_table = get_or_create_tag("Hash Table") + + # 3. Create Problems + p1 = Problem( + title="Two Sum", + description="Given an array of integers `nums` and an integer `target`, return indices of the two numbers such that they add up to `target`.", + difficulty="Easy", + config={ + "timeout": 5, + "templates": { + "python": "def two_sum(nums, target):\n # Write your code here\n pass", + "javascript": "function twoSum(nums, target) {\n // Write your code here\n}" + } + } + ) + p1.categories = [math, algorithms] + p1.tags = [easy, array, hash_table] + + p2 = Problem( + title="Palindrome Check", + description="Check if a given string is a palindrome.", + difficulty="Easy", + config={"timeout": 2} + ) + p2.categories = [string_cat] + p2.tags = [easy, basic] + + p3 = Problem( + title="Factorial", + description="Calculate the factorial of a non-negative integer `n`.", + difficulty="Easy", + config={"timeout": 2} + ) + p3.categories = [math] + p3.tags = [easy, basic] + + p4 = Problem( + title="Fibonacci Number", + description="Find the `n`-th Fibonacci number.", + difficulty="Medium", + config={"timeout": 3} + ) + p4.categories = [math, algorithms] + p4.tags = [medium] + + p5 = Problem( + title="Valid Anagram", + description="Given two strings `s` and `t`, return `true` if `t` is an anagram of `s`, and `false` otherwise.", + difficulty="Easy", + config={"timeout": 2} + ) + p5.categories = [string_cat] + p5.tags = [easy, hash_table] + + session.add_all([p1, p2, p3, p4, p5]) + session.commit() + + # 4. Create Test Cases + session.add_all([ + TestCase(problem_id=p1.id, input="[2, 7, 11, 15], 9", output="[0, 1]", is_hidden=False, sort_order=1), + TestCase(problem_id=p2.id, input="'racecar'", output="True", is_hidden=False, sort_order=1), + TestCase(problem_id=p3.id, input="5", output="120", is_hidden=False, sort_order=1), + TestCase(problem_id=p4.id, input="10", output="55", is_hidden=False, sort_order=1), + TestCase(problem_id=p5.id, input="'anagram', 'nagaram'", output="True", is_hidden=False, sort_order=1), + ]) + session.commit() + + logging.info("Successfully seeded 5 problems.") + +if __name__ == "__main__": + from infrastructure.database import init_db + init_db() + seed_data() diff --git a/src/services/problem_service.py b/src/services/problem_service.py index a6c2c82..b2de02c 100644 --- a/src/services/problem_service.py +++ b/src/services/problem_service.py @@ -11,21 +11,19 @@ def list_all_problems(self): def get_problem_details(self, problem_id): """Get full problem details for the API response.""" - problem = self.problem_repo.find_by_id(problem_id) - if not problem: + problem_dict = self.problem_repo.find_details_by_id(problem_id) + if not problem_dict: return None # Filter config (Security/Business logic) allowed_keys = {'timeout', 'templates'} - original_config = problem.get('config', {}) - problem['config'] = {k: v for k, v in original_config.items() if k in allowed_keys} + problem_dict['config'] = {k: v for k, v in problem_dict.get('config', {}).items() if k in allowed_keys} + + # Enrich with public test cases + problem_dict['test_cases'] = self.test_case_repo.find_public_by_problem(problem_id) - # Enrich with categories, tags, and public test cases - problem['categories'] = self.problem_repo.get_categories(problem_id) - problem['tags'] = self.problem_repo.get_tags(problem_id) - problem['test_cases'] = self.test_case_repo.find_public_by_problem(problem_id) + return problem_dict - return problem def list_problems_by_category(self, category_name): """Get problems filtered by category.""" return self.problem_repo.find_by_category(category_name) From efa5a23c0984b4926a3b7b31e967305e4557047f Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:22:20 +0700 Subject: [PATCH 13/14] refactor: Refactor database initialization and enhance problem seeding with deterministic UUIDs, integrated test cases, and templates. --- bruno/docs.bru | 1 + .../GET- Problems/get problems by id.bru | 2 +- .../POST- Send Code/send code failed.bru | 2 +- .../local/POST- Send Code/send code pass.bru | 2 +- bruno/prod/POST- Send Code/send code.bru | 2 +- src/api/routes.py | 6 + src/app.py | 4 - src/core/executor.py | 66 +++++-- src/infrastructure/__init__.py | 2 +- src/infrastructure/database.py | 25 +-- src/models/base.py | 4 +- src/repositories/problem_repository.py | 17 +- src/repositories/test_case_repository.py | 7 +- src/seed.py | 180 +++++++++++++----- src/services/execution_service.py | 4 +- src/services/problem_service.py | 10 +- 16 files changed, 212 insertions(+), 122 deletions(-) diff --git a/bruno/docs.bru b/bruno/docs.bru index ac90b32..c741aa3 100644 --- a/bruno/docs.bru +++ b/bruno/docs.bru @@ -12,4 +12,5 @@ get { settings { encodeUrl: true + timeout: 0 } diff --git a/bruno/local/GET- Problems/get problems by id.bru b/bruno/local/GET- Problems/get problems by id.bru index 1acda17..8ee6f16 100644 --- a/bruno/local/GET- Problems/get problems by id.bru +++ b/bruno/local/GET- Problems/get problems by id.bru @@ -5,7 +5,7 @@ meta { } get { - url: http://localhost:3000/problem/b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d + url: http://localhost:3000/problem/2a3380b4-fbc7-517d-9583-01d41d1cbb80 body: none auth: inherit } diff --git a/bruno/local/POST- Send Code/send code failed.bru b/bruno/local/POST- Send Code/send code failed.bru index 2c1f1a8..18e7816 100644 --- a/bruno/local/POST- Send Code/send code failed.bru +++ b/bruno/local/POST- Send Code/send code failed.bru @@ -5,7 +5,7 @@ meta { } post { - url: http://localhost:3000/code/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 + url: http://localhost:3000/code/176dbf00-7afd-5250-9442-11d464d7278b body: json auth: inherit } diff --git a/bruno/local/POST- Send Code/send code pass.bru b/bruno/local/POST- Send Code/send code pass.bru index 216200e..be99caf 100644 --- a/bruno/local/POST- Send Code/send code pass.bru +++ b/bruno/local/POST- Send Code/send code pass.bru @@ -5,7 +5,7 @@ meta { } post { - url: http://localhost:3000/code/b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d + url: http://localhost:3000/code/2a3380b4-fbc7-517d-9583-01d41d1cbb80 body: json auth: inherit } diff --git a/bruno/prod/POST- Send Code/send code.bru b/bruno/prod/POST- Send Code/send code.bru index 6b4fdbc..7bad388 100644 --- a/bruno/prod/POST- Send Code/send code.bru +++ b/bruno/prod/POST- Send Code/send code.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{code-exec-url}}/code/b1f2e3d4-c5b6-4a7b-8c9d-0e1f2a3b4c5d + url: {{code-exec-url}}/code/2a3380b4-fbc7-517d-9583-01d41d1cbb80 body: json auth: inherit } diff --git a/src/api/routes.py b/src/api/routes.py index bc38cb2..ed5b11f 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -8,10 +8,12 @@ @api_bp.route('/openapi.yaml') def serve_openapi(): + """Serve the OpenAPI specification file.""" return send_from_directory(current_app.root_path, 'openapi.yaml') @api_bp.route('/docs') def scalar_docs(): + """Serve interactive API documentation via Scalar.""" return f""" @@ -30,6 +32,7 @@ def scalar_docs(): @api_bp.get('/problems') def get_problems(): + """List problems with optional category/tag filtering.""" category = request.args.get('category') tag = request.args.get('tag') @@ -44,6 +47,7 @@ def get_problems(): @api_bp.get('/problem/') def get_problem(problem_id): + """Retrieve detailed information for a single problem.""" problem = problem_service.get_problem_details(problem_id) if not problem: return jsonify(status="error", message="Problem not found"), 404 @@ -51,6 +55,7 @@ def get_problem(problem_id): @api_bp.post('/code/') def execute_problem_code(problem_id): + """Execute code against stored test cases for a problem.""" if not request.is_json: return jsonify(status="error", message="Request must be JSON"), 400 @@ -64,6 +69,7 @@ def execute_problem_code(problem_id): @api_bp.post('/run') def custom_code_executor(): + """Execute arbitrary code without test cases.""" lang = request.args.get('lang') if not lang or not request.is_json: return jsonify(status="error", message="Missing lang or invalid body"), 400 diff --git a/src/app.py b/src/app.py index 0a29f2a..4830ff3 100644 --- a/src/app.py +++ b/src/app.py @@ -1,13 +1,9 @@ from flask import Flask -from infrastructure import init_db from api import api_bp def create_app(): app = Flask(__name__, static_folder='html') - # Initialize infrastructure - init_db() - # Register routes app.register_blueprint(api_bp) diff --git a/src/core/executor.py b/src/core/executor.py index b8292b1..97a18b6 100644 --- a/src/core/executor.py +++ b/src/core/executor.py @@ -1,8 +1,10 @@ -import subprocess, os, tempfile +import subprocess +import os +import tempfile from .config import COMPILERS, validate_code def execute_custom_code(code: str, lang: str) -> dict: - """Execute the given code in the specific language""" + """Execute raw code without test cases (custom run).""" if lang not in COMPILERS: return {"status": "error", "stdout": "", "stderr": f"Unsupported language: {lang}"} @@ -14,7 +16,7 @@ def execute_custom_code(code: str, lang: str) -> dict: return _run_interpreted(code, lang) def execute_code(code: str, lang: str, tests: list, timeout: int = 5, templates: dict = None, rules: dict = None) -> dict: - """Execute the given code against provided test cases.""" + """Execute code against a list of test cases with validation and templating.""" if lang not in COMPILERS: return {"status": "error", "message": f"Unsupported language: {lang}"} @@ -22,7 +24,7 @@ def execute_code(code: str, lang: str, tests: list, timeout: int = 5, templates: rules = rules or {} if not validate_code(lang, code, rules): - return {"status": "error", "message": "Code does not meet the required rules."} + return {"status": "error", "message": "Code does not meet the prescribed rules."} template = templates.get(lang) if template and "__CODE_GOES_HERE__" in template: @@ -35,65 +37,86 @@ def execute_code(code: str, lang: str, tests: list, timeout: int = 5, templates: else: return _run_interpreted(code, lang, tests, timeout) - -def _run_c_cpp(code, lang, tests, timeout): - """Compile and run C/C++ code.""" +def _run_c_cpp(code, lang, tests=None, timeout=5): + """Compile and execute C/C++ code.""" cfg = COMPILERS[lang] fd, src = tempfile.mkstemp(suffix=cfg['extension']) os.close(fd) exe = src.replace(cfg['extension'], "") try: - open(src, 'w', encoding='utf-8').write(code) + with open(src, 'w', encoding='utf-8') as f: + f.write(code) + comp = subprocess.run([cfg['compiler'], src, "-o", exe], capture_output=True, text=True, timeout=timeout) if comp.returncode: return {"status": "incorrect", "message": "Compilation failed", "compiler_output": comp.stderr} + + if tests is None: + res = subprocess.run([exe], capture_output=True, text=True, timeout=timeout) + return {"status": "success", "stdout": res.stdout, "stderr": res.stderr} + return _run_tests([exe], tests, timeout) finally: for f in [src, exe]: if os.path.exists(f): os.remove(f) - -def _run_java(code, tests, timeout): - """Compile and run Java code.""" +def _run_java(code, tests=None, timeout=5): + """Compile and execute Java code (requires 'public class Main').""" cfg = COMPILERS["java"] fd, src = tempfile.mkstemp(suffix=".java") os.close(fd) d = os.path.dirname(src) try: - open(src, 'w', encoding='utf-8').write(code) + with open(src, 'w', encoding='utf-8') as f: + f.write(code) + if not code.strip().startswith("public class Main"): - return {"status": "error", "message": "Java code must use 'public class Main'."} + return {"status": "error", "message": "Java code must utilize 'public class Main'."} + comp = subprocess.run([cfg['compiler'], src, "-d", d], capture_output=True, text=True, timeout=timeout) if comp.returncode: return {"status": "incorrect", "message": "Compilation failed", "compiler_output": comp.stderr} - return _run_tests(["java", "-cp", d, "Main"], tests, timeout) + + cmd = ["java", "-cp", d, "Main"] + if tests is None: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return {"status": "success", "stdout": res.stdout, "stderr": res.stderr} + + return _run_tests(cmd, tests, timeout) finally: for f in [src, os.path.join(d, "Main.class")]: if os.path.exists(f): os.remove(f) - -def _run_interpreted(code, lang, tests, timeout): - """Run interpreted languages like Python and JavaScript.""" +def _run_interpreted(code, lang, tests=None, timeout=5): + """Execute interpreted languages (e.g., Python, JavaScript).""" cfg = COMPILERS[lang] fd, src = tempfile.mkstemp(suffix=cfg['extension']) os.close(fd) try: - open(src, 'w', encoding='utf-8').write(code) - return _run_tests([cfg['interpreter'], src], tests, timeout) + with open(src, 'w', encoding='utf-8') as f: + f.write(code) + + cmd = [cfg['interpreter'], src] + if tests is None: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return {"status": "success", "stdout": res.stdout, "stderr": res.stderr} + + return _run_tests(cmd, tests, timeout) finally: if os.path.exists(src): os.remove(src) - def _run_tests(cmd_base, tests, timeout): - """Run the compiled/interpreted code against test cases.""" + """Batch execute test cases against the generated command.""" results, status, msg = [], "correct", "All test cases passed!" for t in tests: try: r = subprocess.run(cmd_base, input=t['input'], capture_output=True, text=True, timeout=timeout) out, err = r.stdout.strip(), r.stderr.strip() s, m = "passed", "Test passed." + if r.returncode or out != t['expected_output'].strip(): s, m, status, msg = "failed", f"Expected '{t['expected_output'].strip()}', got '{out}'", "incorrect", "Some test cases failed." + results.append({ "case": int(t['test_number']), "status": s, @@ -111,4 +134,5 @@ def _run_tests(cmd_base, tests, timeout): }) status, msg = "timeout", "Time Limit Exceeded" break + return {"status": status, "msg": msg, "tests": results} diff --git a/src/infrastructure/__init__.py b/src/infrastructure/__init__.py index 9dbd11f..0eb03b7 100644 --- a/src/infrastructure/__init__.py +++ b/src/infrastructure/__init__.py @@ -1 +1 @@ -from .database import init_db +from .database import engine, SessionLocal, get_session diff --git a/src/infrastructure/database.py b/src/infrastructure/database.py index 98328e4..ff133e3 100644 --- a/src/infrastructure/database.py +++ b/src/infrastructure/database.py @@ -1,27 +1,16 @@ import os import logging -from sqlmodel import create_engine, Session, SQLModel +from sqlmodel import create_engine, Session from sqlalchemy.orm import sessionmaker -# Database configuration DATABASE_URL = os.environ.get('DATABASE_URL') -# SQLModel engine setup -engine = None -SessionLocal = None +engine = create_engine(DATABASE_URL, echo=False) if DATABASE_URL else None +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=Session) if engine else None -if DATABASE_URL: - engine = create_engine(DATABASE_URL, echo=False) - SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=Session) +if engine: logging.info("SQLModel engine and sessionmaker initialized.") -def init_db(): - """Perform database initialization tasks like seeding.""" - try: - pass # Seeding moved to entrypoint - except Exception: - logging.exception("Error during database initialization/seeding") - def get_session(): """Dependency for getting a database session.""" if SessionLocal: @@ -29,9 +18,3 @@ def get_session(): yield session else: yield None - -# Legacy helpers for raw SQL if absolutely needed during transition -def get_db_connection(): - # Deprecated: use SQLModel Session instead - import psycopg2 - return psycopg2.connect(DATABASE_URL) diff --git a/src/models/base.py b/src/models/base.py index 5d4cbad..6dea531 100644 --- a/src/models/base.py +++ b/src/models/base.py @@ -20,7 +20,6 @@ class Problem(SQLModel, table=True): difficulty: str config: Optional[dict] = Field(default_factory=dict, sa_column=Column(JSON)) - # Relationships test_cases: List["TestCase"] = Relationship(back_populates="problem", sa_relationship_kwargs={"cascade": "all, delete-orphan"}) categories: List["Category"] = Relationship(link_model=ProblemCategoryLink) tags: List["Tag"] = Relationship(link_model=ProblemTagLink) @@ -44,5 +43,4 @@ class TestCase(SQLModel, table=True): is_hidden: bool = Field(default=True) sort_order: int - # Relationships - problem: Problem = Relationship(back_populates="test_cases") + problem: "Problem" = Relationship(back_populates="test_cases") diff --git a/src/repositories/problem_repository.py b/src/repositories/problem_repository.py index 8d9080b..a09deff 100644 --- a/src/repositories/problem_repository.py +++ b/src/repositories/problem_repository.py @@ -1,6 +1,6 @@ -from sqlmodel import select, or_ -from sqlalchemy.orm import selectinload, joinedload -from infrastructure.database import SessionLocal +from sqlmodel import select +from sqlalchemy.orm import joinedload +from infrastructure import SessionLocal from models import Problem, Category, Tag class ProblemRepository: @@ -11,18 +11,18 @@ def _get_session(self): return self._session if self._session else SessionLocal() def find_all(self): - """Retrieve all problems with basic info.""" + """Retrieve all problems with basic information.""" with self._get_session() as session: statement = select(Problem).order_by(Problem.id) return [p.model_dump(exclude={"config", "description"}) for p in session.exec(statement).all()] def find_by_id(self, problem_id): - """Retrieve a single problem by UUID (Internal use).""" + """Internal helper to fetch a Problem model by UUID.""" with self._get_session() as session: return session.get(Problem, problem_id) def find_details_by_id(self, problem_id): - """Retrieve a single problem by UUID with categories and tags hydrated (Dict).""" + """Fetch full problem details with hydrated relationships for API response.""" with self._get_session() as session: statement = select(Problem).where(Problem.id == problem_id).options( joinedload(Problem.categories), @@ -32,20 +32,19 @@ def find_details_by_id(self, problem_id): if not problem: return None - # Hydrate while session is active p_dict = problem.model_dump() p_dict['categories'] = [c.name for c in problem.categories] p_dict['tags'] = [t.name for t in problem.tags] return p_dict def find_by_category(self, category_name): - """Retrieve problems filtered by category name (case-insensitive).""" + """Filter problems by category name (case-insensitive).""" with self._get_session() as session: statement = select(Problem).join(Problem.categories).where(Category.name.ilike(category_name)) return [p.model_dump(exclude={"config", "description"}) for p in session.exec(statement).all()] def find_by_tag(self, tag_name): - """Retrieve problems filtered by tag name (case-insensitive).""" + """Filter problems by tag name (case-insensitive).""" with self._get_session() as session: statement = select(Problem).join(Problem.tags).where(Tag.name.ilike(tag_name)) return [p.model_dump(exclude={"config", "description"}) for p in session.exec(statement).all()] diff --git a/src/repositories/test_case_repository.py b/src/repositories/test_case_repository.py index feaa765..cdf5ad8 100644 --- a/src/repositories/test_case_repository.py +++ b/src/repositories/test_case_repository.py @@ -1,5 +1,5 @@ from sqlmodel import select -from infrastructure.database import SessionLocal +from infrastructure import SessionLocal from models import TestCase class TestCaseRepository: @@ -10,15 +10,14 @@ def _get_session(self): return self._session if self._session else SessionLocal() def find_all_by_problem(self, problem_id): - """Fetch all test cases for a problem (internal/execution use).""" + """Fetch all test cases for execution, mapped to engine format.""" with self._get_session() as session: statement = select(TestCase).where(TestCase.problem_id == problem_id).order_by(TestCase.sort_order) results = session.exec(statement).all() - # Map to expected format for execution engine return [{"input": tc.input, "expected_output": tc.output, "test_number": tc.sort_order} for tc in results] def find_public_by_problem(self, problem_id): - """Fetch only non-hidden test cases (public description use).""" + """Fetch non-hidden test cases for public documentation.""" with self._get_session() as session: statement = select(TestCase).where( TestCase.problem_id == problem_id, diff --git a/src/seed.py b/src/seed.py index d1d5420..a9b3b59 100644 --- a/src/seed.py +++ b/src/seed.py @@ -1,31 +1,44 @@ import logging import uuid from sqlmodel import Session, select -from infrastructure.database import engine +from infrastructure import engine from models import Problem, Category, Tag, TestCase +# Deterministic UUID Namespace +NAMESPACE = uuid.NAMESPACE_DNS + +def get_uuid(name: str) -> uuid.UUID: + """Generate a deterministic UUID based on a string name.""" + return uuid.uuid5(NAMESPACE, name) + def seed_data(): + if not engine: + logging.error("No database engine found. Skipping seeding.") + return + with Session(engine) as session: # Check if we already have data if session.exec(select(Problem)).first(): logging.info("Database already seeded. Skipping.") return - logging.info("Seeding initial data...") + logging.info("Seeding initial data with deterministic UUIDs...") def get_or_create_category(name): - cat = session.exec(select(Category).where(Category.name == name)).first() + cat_id = get_uuid(f"cat_{name}") + cat = session.exec(select(Category).where(Category.id == cat_id)).first() if not cat: - cat = Category(name=name) + cat = Category(id=cat_id, name=name) session.add(cat) session.commit() session.refresh(cat) return cat def get_or_create_tag(name): - tag = session.exec(select(Tag).where(Tag.name == name)).first() + tag_id = get_uuid(f"tag_{name}") + tag = session.exec(select(Tag).where(Tag.id == tag_id)).first() if not tag: - tag = Tag(name=name) + tag = Tag(id=tag_id, name=name) session.add(tag) session.commit() session.refresh(tag) @@ -33,7 +46,6 @@ def get_or_create_tag(name): # 1. Categories math = get_or_create_category("Math") - logic = get_or_create_category("Logic") string_cat = get_or_create_category("String") algorithms = get_or_create_category("Algorithms") @@ -42,76 +54,148 @@ def get_or_create_tag(name): medium = get_or_create_tag("Medium") basic = get_or_create_tag("Basic") array = get_or_create_tag("Array") - hash_table = get_or_create_tag("Hash Table") - # 3. Create Problems - p1 = Problem( + # 3. Helper for Problems and Test Cases + def add_problem(title, description, difficulty, category_list, tag_list, config=None, test_cases=None): + p_id = get_uuid(f"prob_{title}") + p = Problem( + id=p_id, + title=title, + description=description, + difficulty=difficulty, + config=config or {"timeout": 5} + ) + p.categories = category_list + p.tags = tag_list + session.add(p) + + if test_cases: + for i, tc_data in enumerate(test_cases): + tc_id = get_uuid(f"tc_{title}_{i}") + tc = TestCase( + id=tc_id, + problem_id=p_id, + input=tc_data['input'], + output=tc_data['output'], + is_hidden=tc_data.get('is_hidden', True), + sort_order=i + 1 + ) + session.add(tc) + + session.commit() + return p + + # 4. Seed Problems + + # Multiply Two Numbers (New) + add_problem( + title="Multiply Two Numbers", + description="Read two space-separated integers from stdin and print their product.", + difficulty="Easy", + category_list=[math], + tag_list=[easy, basic], + config={ + "timeout": 2, + "templates": { + "python": "num1, num2 = map(int, input().split())\nprint(num1 * num2)" + } + }, + test_cases=[ + {"input": "2 3", "output": "6", "is_hidden": False}, + {"input": "5 10", "output": "50", "is_hidden": False}, + {"input": "0 100", "output": "0", "is_hidden": True}, + {"input": "-5 5", "output": "-25", "is_hidden": True}, + {"input": "12 12", "output": "144", "is_hidden": True}, + ] + ) + + # Two Sum + add_problem( title="Two Sum", description="Given an array of integers `nums` and an integer `target`, return indices of the two numbers such that they add up to `target`.", difficulty="Easy", + category_list=[math, algorithms], + tag_list=[easy, array], config={ "timeout": 5, "templates": { - "python": "def two_sum(nums, target):\n # Write your code here\n pass", - "javascript": "function twoSum(nums, target) {\n // Write your code here\n}" + "python": "def two_sum(nums, target):\n # Write your code here\n pass" } - } + }, + test_cases=[ + {"input": "[2, 7, 11, 15], 9", "output": "[0, 1]", "is_hidden": False}, + {"input": "[3, 2, 4], 6", "output": "[1, 2]", "is_hidden": False}, + {"input": "[3, 3], 6", "output": "[0, 1]", "is_hidden": True}, + {"input": "[1, 5, 8], 13", "output": "[1, 2]", "is_hidden": True}, + {"input": "[-1, -2, -3, -4, -5], -8", "output": "[2, 4]", "is_hidden": True}, + ] ) - p1.categories = [math, algorithms] - p1.tags = [easy, array, hash_table] - p2 = Problem( + # Palindrome Check + add_problem( title="Palindrome Check", description="Check if a given string is a palindrome.", difficulty="Easy", - config={"timeout": 2} + category_list=[string_cat], + tag_list=[easy, basic], + test_cases=[ + {"input": "'racecar'", "output": "True", "is_hidden": False}, + {"input": "'hello'", "output": "False", "is_hidden": False}, + {"input": "'a'", "output": "True", "is_hidden": True}, + {"input": "''", "output": "True", "is_hidden": True}, + {"input": "'Was it a car or a cat I saw?'", "output": "False", "is_hidden": True}, # Case sensitive or space issues depending on logic + ] ) - p2.categories = [string_cat] - p2.tags = [easy, basic] - p3 = Problem( + # Factorial + add_problem( title="Factorial", description="Calculate the factorial of a non-negative integer `n`.", difficulty="Easy", - config={"timeout": 2} + category_list=[math], + tag_list=[easy, basic], + test_cases=[ + {"input": "5", "output": "120", "is_hidden": False}, + {"input": "0", "output": "1", "is_hidden": False}, + {"input": "1", "output": "1", "is_hidden": True}, + {"input": "10", "output": "3628800", "is_hidden": True}, + {"input": "3", "output": "6", "is_hidden": True}, + ] ) - p3.categories = [math] - p3.tags = [easy, basic] - p4 = Problem( + # Fibonacci Number + add_problem( title="Fibonacci Number", description="Find the `n`-th Fibonacci number.", difficulty="Medium", - config={"timeout": 3} + category_list=[math, algorithms], + tag_list=[medium], + test_cases=[ + {"input": "10", "output": "55", "is_hidden": False}, + {"input": "1", "output": "1", "is_hidden": False}, + {"input": "0", "output": "0", "is_hidden": True}, + {"input": "2", "output": "1", "is_hidden": True}, + {"input": "20", "output": "6765", "is_hidden": True}, + ] ) - p4.categories = [math, algorithms] - p4.tags = [medium] - p5 = Problem( + # Valid Anagram + add_problem( title="Valid Anagram", description="Given two strings `s` and `t`, return `true` if `t` is an anagram of `s`, and `false` otherwise.", difficulty="Easy", - config={"timeout": 2} + category_list=[string_cat], + tag_list=[easy], + test_cases=[ + {"input": "'anagram', 'nagaram'", "output": "True", "is_hidden": False}, + {"input": "'rat', 'car'", "output": "False", "is_hidden": False}, + {"input": "'a', 'ab'", "output": "False", "is_hidden": True}, + {"input": "'debug', 'bugged'", "output": "False", "is_hidden": True}, + {"input": "'listen', 'silent'", "output": "True", "is_hidden": True}, + ] ) - p5.categories = [string_cat] - p5.tags = [easy, hash_table] - - session.add_all([p1, p2, p3, p4, p5]) - session.commit() - - # 4. Create Test Cases - session.add_all([ - TestCase(problem_id=p1.id, input="[2, 7, 11, 15], 9", output="[0, 1]", is_hidden=False, sort_order=1), - TestCase(problem_id=p2.id, input="'racecar'", output="True", is_hidden=False, sort_order=1), - TestCase(problem_id=p3.id, input="5", output="120", is_hidden=False, sort_order=1), - TestCase(problem_id=p4.id, input="10", output="55", is_hidden=False, sort_order=1), - TestCase(problem_id=p5.id, input="'anagram', 'nagaram'", output="True", is_hidden=False, sort_order=1), - ]) - session.commit() - - logging.info("Successfully seeded 5 problems.") + + logging.info("Successfully seeded 6 problems with 5 test cases each.") if __name__ == "__main__": - from infrastructure.database import init_db - init_db() seed_data() diff --git a/src/services/execution_service.py b/src/services/execution_service.py index 5c85d3a..a007545 100644 --- a/src/services/execution_service.py +++ b/src/services/execution_service.py @@ -7,13 +7,13 @@ def __init__(self): self.problem_repo = ProblemRepository() def run_problem_code(self, problem_id, code, lang): - """Execute code against stored test cases for a problem.""" + """Execute provided code against all test cases for a specific problem.""" problem = self.problem_repo.find_by_id(problem_id) if not problem: return {"status": "error", "message": "Problem not found"} test_cases = self.test_case_repo.find_all_by_problem(problem_id) - cfg = problem.get('config', {}) + cfg = problem.config if hasattr(problem, 'config') else {} return core_execute( code=code, diff --git a/src/services/problem_service.py b/src/services/problem_service.py index b2de02c..2bdc89f 100644 --- a/src/services/problem_service.py +++ b/src/services/problem_service.py @@ -6,16 +6,16 @@ def __init__(self): self.test_case_repo = TestCaseRepository() def list_all_problems(self): - """Get summary list of problems.""" + """Retrieve a list of all problems with basic information.""" return self.problem_repo.find_all() def get_problem_details(self, problem_id): - """Get full problem details for the API response.""" + """Fetch full problem details, including config and public test cases.""" problem_dict = self.problem_repo.find_details_by_id(problem_id) if not problem_dict: return None - # Filter config (Security/Business logic) + # Filter configuration for API response allowed_keys = {'timeout', 'templates'} problem_dict['config'] = {k: v for k, v in problem_dict.get('config', {}).items() if k in allowed_keys} @@ -25,9 +25,9 @@ def get_problem_details(self, problem_id): return problem_dict def list_problems_by_category(self, category_name): - """Get problems filtered by category.""" + """Filter problems by category.""" return self.problem_repo.find_by_category(category_name) def list_problems_by_tag(self, tag_name): - """Get problems filtered by tag.""" + """Filter problems by tag.""" return self.problem_repo.find_by_tag(tag_name) From ac843a0775fa715d7f0e8020fc2ae9ed9399898e Mon Sep 17 00:00:00 2001 From: Pathompum Jirakarnpaisan <107536914+Saannddy@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:32:38 +0700 Subject: [PATCH 14/14] refactor: Move seed script to `src/scripts` directory, add basic logging configuration, and update related project files. --- .dockerignore | 2 -- README.md | 2 +- docker-compose.yml | 2 +- src/requirements.txt | 1 - src/scripts/__init__.py | 1 + src/{ => scripts}/seed.py | 3 ++- 6 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 src/scripts/__init__.py rename src/{ => scripts}/seed.py (99%) diff --git a/.dockerignore b/.dockerignore index 144419e..23bbc11 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,4 @@ README.md docker-compose.yml Dockerfile postgres/ -yoyo.ini -yoyo.ini.lock scripts/ diff --git a/README.md b/README.md index 049be2f..88623cf 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ If you modify the models in `src/models/`, use these commands to sync the databa ### ๐ŸŒฑ Data Seeding If you need to re-seed or reset the initial data: -- **Run Seeder**: `docker compose exec code-api python3 seed.py` +- **Run Seeder**: `docker compose exec code-api python3 -m scripts.seed` *(The seeder is idempotent and will skip problems that already exist!)* --- diff --git a/docker-compose.yml b/docker-compose.yml index 9a34331..e1ac6dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: environment: - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-code_executor} command: > - sh -c "python3 -m alembic upgrade head && python3 seed.py && gunicorn --bind 0.0.0.0:3000 --access-logfile - app:app" + sh -c "python3 -m alembic upgrade head && python3 -m scripts.seed && gunicorn --bind 0.0.0.0:3000 --access-logfile - app:app" volumes: postgres_data: diff --git a/src/requirements.txt b/src/requirements.txt index 8a9454d..cf7f922 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,6 +1,5 @@ Flask==3.0.3 gunicorn==23.0.0 psycopg2-binary==2.9.9 -yoyo-migrations==8.2.0 sqlmodel==0.0.22 alembic==1.13.1 \ No newline at end of file diff --git a/src/scripts/__init__.py b/src/scripts/__init__.py new file mode 100644 index 0000000..679fff5 --- /dev/null +++ b/src/scripts/__init__.py @@ -0,0 +1 @@ +from .seed import seed_data diff --git a/src/seed.py b/src/scripts/seed.py similarity index 99% rename from src/seed.py rename to src/scripts/seed.py index a9b3b59..e00ef6b 100644 --- a/src/seed.py +++ b/src/scripts/seed.py @@ -4,7 +4,8 @@ from infrastructure import engine from models import Problem, Category, Tag, TestCase -# Deterministic UUID Namespace +logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') + NAMESPACE = uuid.NAMESPACE_DNS def get_uuid(name: str) -> uuid.UUID: