diff --git a/README.md b/README.md index 275111e..84d1d0d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ Run the entire stack (API + PostgreSQL DB) using Docker Compose: docker compose --profile local up -d --build ## FOR PRODUCTION docker compose --profile remote up -d --build + +## FOR REBUILD WHILE DOCKER STILL RUNNING +docker compose --profile local up -d --build --force-recreate ``` - `-d`: Runs containers in the background (**detached mode**). diff --git a/alembic/versions/12d232b53016_add_chunks_tables.py b/alembic/versions/12d232b53016_add_chunks_tables.py new file mode 100644 index 0000000..513f514 --- /dev/null +++ b/alembic/versions/12d232b53016_add_chunks_tables.py @@ -0,0 +1,74 @@ +"""Add chunks tables + +Revision ID: 12d232b53016 +Revises: 526810671b5e +Create Date: 2026-02-28 05:35:17.727901 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '12d232b53016' +down_revision: Union[str, Sequence[str], None] = '526810671b5e' +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('chunk_templates', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('template_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('chunks', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('template_id', sa.Uuid(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('difficulty', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['template_id'], ['chunk_templates.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('chunks_categories', + sa.Column('chunk_id', sa.Uuid(), nullable=False), + sa.Column('category_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), + sa.ForeignKeyConstraint(['chunk_id'], ['chunks.id'], ), + sa.PrimaryKeyConstraint('chunk_id', 'category_id') + ) + op.create_table('chunks_tags', + sa.Column('chunk_id', sa.Uuid(), nullable=False), + sa.Column('tag_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['chunk_id'], ['chunks.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('chunk_id', 'tag_id') + ) + op.create_table('snippets', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('snippet_id', sa.Uuid(), nullable=False), + sa.Column('placeholder_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('code_content', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint(['snippet_id'], ['chunks.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('snippets') + op.drop_table('chunks_tags') + op.drop_table('chunks_categories') + op.drop_table('chunks') + op.drop_table('chunk_templates') + # ### end Alembic commands ### diff --git a/alembic/versions/195da80e49b0_rename_chunktestcase_to_expectation.py b/alembic/versions/195da80e49b0_rename_chunktestcase_to_expectation.py new file mode 100644 index 0000000..1889adc --- /dev/null +++ b/alembic/versions/195da80e49b0_rename_chunktestcase_to_expectation.py @@ -0,0 +1,28 @@ +"""Rename ChunkTestCase to Expectation + +Revision ID: 195da80e49b0 +Revises: bef4f254435d +Create Date: 2026-02-28 06:57:58.901505 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '195da80e49b0' +down_revision: Union[str, Sequence[str], None] = 'bef4f254435d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.rename_table('chunk_test_cases', 'expectations') + +def downgrade() -> None: + """Downgrade schema.""" + op.rename_table('expectations', 'chunk_test_cases') diff --git a/alembic/versions/7d97a9392f2b_add_chunk_testcase.py b/alembic/versions/7d97a9392f2b_add_chunk_testcase.py new file mode 100644 index 0000000..a2313a5 --- /dev/null +++ b/alembic/versions/7d97a9392f2b_add_chunk_testcase.py @@ -0,0 +1,35 @@ +"""Add chunk testcase + +Revision ID: 7d97a9392f2b +Revises: 12d232b53016 +Create Date: 2026-02-28 06:33:32.364820 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '7d97a9392f2b' +down_revision: Union[str, Sequence[str], None] = '12d232b53016' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column('chunks', sa.Column('test_case_input', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('chunks', sa.Column('test_case_output', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + # Make them not nullable if required, but default="" + op.execute("UPDATE chunks SET test_case_input = '', test_case_output = ''") + op.alter_column('chunks', 'test_case_input', nullable=False) + op.alter_column('chunks', 'test_case_output', nullable=False) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column('chunks', 'test_case_output') + op.drop_column('chunks', 'test_case_input') diff --git a/alembic/versions/922b6da97d7e_redesign_chunks_for_multi_language_.py b/alembic/versions/922b6da97d7e_redesign_chunks_for_multi_language_.py new file mode 100644 index 0000000..d51d5f3 --- /dev/null +++ b/alembic/versions/922b6da97d7e_redesign_chunks_for_multi_language_.py @@ -0,0 +1,54 @@ +"""Redesign chunks for multi-language support + +Revision ID: 922b6da97d7e +Revises: 195da80e49b0 +Create Date: 2026-02-28 07:21:08.426698 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '922b6da97d7e' +down_revision: Union[str, Sequence[str], None] = '195da80e49b0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # 1. Update chunk_templates + op.add_column('chunk_templates', sa.Column('chunk_id', sa.UUID(), nullable=True)) + op.add_column('chunk_templates', sa.Column('language', sa.String(), nullable=True)) + op.create_foreign_key('fk_chunk_templates_chunk_id', 'chunk_templates', 'chunks', ['chunk_id'], ['id']) + + # 2. Update chunks + op.drop_constraint('chunks_template_id_fkey', 'chunks', type_='foreignkey') # Might name differ, usually table_column_fkey + op.drop_column('chunks', 'template_id') + + # 3. Update snippets + op.add_column('snippets', sa.Column('template_id', sa.UUID(), nullable=True)) + op.create_foreign_key('fk_snippets_template_id', 'snippets', 'chunk_templates', ['template_id'], ['id']) + op.drop_constraint('snippets_snippet_id_fkey', 'snippets', type_='foreignkey') + op.drop_column('snippets', 'snippet_id') + +def downgrade() -> None: + """Downgrade schema.""" + # Reverse snippets + op.add_column('snippets', sa.Column('snippet_id', sa.UUID(), nullable=True)) + op.create_foreign_key('snippets_snippet_id_fkey', 'snippets', 'chunks', ['snippet_id'], ['id']) + op.drop_constraint('fk_snippets_template_id', 'snippets', type_='foreignkey') + op.drop_column('snippets', 'template_id') + + # Reverse chunks + op.add_column('chunks', sa.Column('template_id', sa.UUID(), nullable=True)) + op.create_foreign_key('chunks_template_id_fkey', 'chunks', 'chunk_templates', ['template_id'], ['id']) + + # Reverse chunk_templates + op.drop_constraint('fk_chunk_templates_chunk_id', 'chunk_templates', type_='foreignkey') + op.drop_column('chunk_templates', 'language') + op.drop_column('chunk_templates', 'chunk_id') diff --git a/alembic/versions/bef4f254435d_add_chunk_test_cases.py b/alembic/versions/bef4f254435d_add_chunk_test_cases.py new file mode 100644 index 0000000..9940e34 --- /dev/null +++ b/alembic/versions/bef4f254435d_add_chunk_test_cases.py @@ -0,0 +1,44 @@ +"""Add chunk test cases + +Revision ID: bef4f254435d +Revises: 7d97a9392f2b +Create Date: 2026-02-28 06:44:40.393036 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'bef4f254435d' +down_revision: Union[str, Sequence[str], None] = '7d97a9392f2b' +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('chunk_test_cases', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('chunk_id', sa.Uuid(), nullable=False), + sa.Column('input', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('output', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint(['chunk_id'], ['chunks.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.drop_column('chunks', 'test_case_input') + op.drop_column('chunks', 'test_case_output') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('chunks', sa.Column('test_case_output', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('chunks', sa.Column('test_case_input', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.drop_table('chunk_test_cases') + # ### end Alembic commands ### diff --git a/bruno/environments/code-exec.bru b/bruno/environments/code-exec.bru index a0c1789..cce1cbe 100644 --- a/bruno/environments/code-exec.bru +++ b/bruno/environments/code-exec.bru @@ -1,3 +1,6 @@ +vars { + base_url: http://localhost:3000 +} vars:secret [ code-exec-url, zip-path diff --git a/bruno/local/Chunk/Execute Complex JS Chunk.bru b/bruno/local/Chunk/Execute Complex JS Chunk.bru new file mode 100644 index 0000000..a9e320a --- /dev/null +++ b/bruno/local/Chunk/Execute Complex JS Chunk.bru @@ -0,0 +1,28 @@ +meta { + name: Execute Complex JS Chunk + type: http + seq: 6 +} + +post { + url: http://localhost:3000/chunk/execute/4c80ce4e-38e0-42ba-a72c-e3103dcaa766?lang=javascript + body: json + auth: none +} + +params:query { + lang: javascript +} + +body:json { + { + "snippets": { + "imports": "// No imports needed", + "setup": "const OFFSET = 100;\nfunction getOffset() { return OFFSET; }", + "args": "dataList, multiplier", + "validation": "if (!Array.isArray(dataList)) return null;", + "logic": "const result = dataList.map(x => utility(x) * multiplier + getOffset());\nreturn result;", + "test": "const res = solution([1, 2, 3], 5); console.log('[' + res.join(', ') + ']');" + } + } +} diff --git a/bruno/local/Chunk/Execute Complex Python Chunk.bru b/bruno/local/Chunk/Execute Complex Python Chunk.bru new file mode 100644 index 0000000..bd3ba0e --- /dev/null +++ b/bruno/local/Chunk/Execute Complex Python Chunk.bru @@ -0,0 +1,28 @@ +meta { + name: Execute Complex Python Chunk + type: http + seq: 5 +} + +post { + url: http://localhost:3000/chunk/execute/4c80ce4e-38e0-42ba-a72c-e3103dcaa766?lang=python + body: json + auth: none +} + +params:query { + lang: python +} + +body:json { + { + "snippets": { + "imports": "import math", + "setup": "OFFSET = 100\ndef get_offset(): return OFFSET", + "args": "data_list, multiplier", + "validation": "if not isinstance(data_list, list): return None", + "logic": "result = [utility(x) * multiplier + get_offset() for x in data_list]\nreturn result", + "test": "print(solution([1, 2, 3], 5))" + } + } +} diff --git a/bruno/local/Chunk/Execute JS Chunk.bru b/bruno/local/Chunk/Execute JS Chunk.bru new file mode 100644 index 0000000..023f66e --- /dev/null +++ b/bruno/local/Chunk/Execute JS Chunk.bru @@ -0,0 +1,23 @@ +meta { + name: Execute JS Chunk + type: http + seq: 5 +} + +post { + url: http://localhost:3000/chunk/execute/9cb40096-ac06-4294-8d29-835eb30bfdce?lang=javascript + body: json + auth: none +} + +query { + lang: javascript +} + +body:json { + { + "snippets": { + "logic": "return x * y;" + } + } +} diff --git a/bruno/local/Chunk/Execute Python Chunk.bru b/bruno/local/Chunk/Execute Python Chunk.bru new file mode 100644 index 0000000..dcb24f6 --- /dev/null +++ b/bruno/local/Chunk/Execute Python Chunk.bru @@ -0,0 +1,23 @@ +meta { + name: Execute Python Chunk + type: http + seq: 4 +} + +post { + url: http://localhost:3000/chunk/execute/22e04d50-3aea-43ac-9a89-32daea0cc9f8?lang=python + body: json + auth: none +} + +params:query { + lang: python +} + +body:json { + { + "snippets": { + "logic": "return x * y" + } + } +} diff --git a/bruno/local/Chunk/Get All Chunks.bru b/bruno/local/Chunk/Get All Chunks.bru new file mode 100644 index 0000000..89396f3 --- /dev/null +++ b/bruno/local/Chunk/Get All Chunks.bru @@ -0,0 +1,17 @@ +meta { + name: Get All Chunks + type: http + seq: 1 +} + +get { + url: http://localhost:3000/chunk/?page=1&limit=10&lang=python + body: none + auth: none +} + +query { + page: 1 + limit: 10 + lang: python +} diff --git a/bruno/local/Chunk/Get Chunk Details.bru b/bruno/local/Chunk/Get Chunk Details.bru new file mode 100644 index 0000000..1ff7f5b --- /dev/null +++ b/bruno/local/Chunk/Get Chunk Details.bru @@ -0,0 +1,15 @@ +meta { + name: Get Chunk Details + type: http + seq: 3 +} + +get { + url: http://localhost:3000/chunk/9cb40096-ac06-4294-8d29-835eb30bfdce?lang=python + body: none + auth: none +} + +query { + lang: python +} diff --git a/bruno/local/Chunk/Get Random Chunks.bru b/bruno/local/Chunk/Get Random Chunks.bru new file mode 100644 index 0000000..879c693 --- /dev/null +++ b/bruno/local/Chunk/Get Random Chunks.bru @@ -0,0 +1,16 @@ +meta { + name: Get Random Chunks + type: http + seq: 2 +} + +get { + url: {{base_url}}/chunk/random + body: none + auth: none +} + +params:query { + limit: 2 + lang: javascript +} diff --git a/bruno/local/Chunk/folder.bru b/bruno/local/Chunk/folder.bru new file mode 100644 index 0000000..ba8d1af --- /dev/null +++ b/bruno/local/Chunk/folder.bru @@ -0,0 +1,3 @@ +meta { + name: Chunk +} diff --git a/bruno/local/Code/jav/reject.bru b/bruno/local/Code/jav/reject.bru index b28b720..cf6a60f 100644 --- a/bruno/local/Code/jav/reject.bru +++ b/bruno/local/Code/jav/reject.bru @@ -5,7 +5,7 @@ meta { } post { - url: http://localhost:3000/code/2a3380b4-fbc7-517d-9583-01d41d1cbb80?lang=java + url: {{base_url}}/code/2a3380b4-fbc7-517d-9583-01d41d1cbb80?lang=java body: json auth: inherit } diff --git a/bruno/local/Code/jav/send code fail.bru b/bruno/local/Code/jav/send code fail.bru index d669d7a..4a64c59 100644 --- a/bruno/local/Code/jav/send code fail.bru +++ b/bruno/local/Code/jav/send code fail.bru @@ -5,7 +5,7 @@ meta { } post { - url: http://localhost:3000/code/2a3380b4-fbc7-517d-9583-01d41d1cbb80?lang=java + url: {{base_url}}/code/2a3380b4-fbc7-517d-9583-01d41d1cbb80?lang=java body: json auth: inherit } diff --git a/bruno/local/Code/jav/send code pass.bru b/bruno/local/Code/jav/send code pass.bru index 7df2401..ce72f4f 100644 --- a/bruno/local/Code/jav/send code pass.bru +++ b/bruno/local/Code/jav/send code pass.bru @@ -5,7 +5,7 @@ meta { } post { - url: http://localhost:3000/code/2a3380b4-fbc7-517d-9583-01d41d1cbb80?lang=java + url: {{base_url}}/code/2a3380b4-fbc7-517d-9583-01d41d1cbb80?lang=java body: json auth: inherit } diff --git a/bruno/local/Code/python/send code failed.bru b/bruno/local/Code/python/send code failed.bru index 031b367..07b7470 100644 --- a/bruno/local/Code/python/send code failed.bru +++ b/bruno/local/Code/python/send code failed.bru @@ -16,7 +16,7 @@ params:query { body:json { { - "code": "num1, num2 = map(int, input().split())\nprint(num1 * num2)" + "code": "num1 = int(input())\nprint(num1)" } } diff --git a/bruno/local/Problem/POST add testcase.bru b/bruno/local/Problem/POST add testcase.bru index fa99f59..4c2fbca 100644 --- a/bruno/local/Problem/POST add testcase.bru +++ b/bruno/local/Problem/POST add testcase.bru @@ -5,7 +5,7 @@ meta { } post { - url: http://localhost:3000/problem/176dbf00-7afd-5250-9442-11d464d7278b/testcases + url: {{base_url}}/problem/176dbf00-7afd-5250-9442-11d464d7278b/testcases body: json auth: inherit } diff --git a/bruno/local/Problem/POST import testcase zip.bru b/bruno/local/Problem/POST import testcase zip.bru index 5c76c42..f250856 100644 --- a/bruno/local/Problem/POST import testcase zip.bru +++ b/bruno/local/Problem/POST import testcase zip.bru @@ -5,7 +5,7 @@ meta { } post { - url: http://localhost:3000/problem/176dbf00-7afd-5250-9442-11d464d7278b/testcases/import + url: {{base_url}}/problem/176dbf00-7afd-5250-9442-11d464d7278b/testcases/import body: file auth: inherit } diff --git a/src/api/__init__.py b/src/api/__init__.py index 018aad9..db5451f 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -4,6 +4,7 @@ from .routes.question_routes import question_bp from .routes.riddle_routes import riddle_bp from .routes.execution_routes import execution_bp +from .routes.chunk_routes import chunk_bp api_bp = Blueprint('api', __name__) @@ -12,4 +13,5 @@ api_bp.register_blueprint(problem_bp, url_prefix='/problem') api_bp.register_blueprint(question_bp, url_prefix='/question') api_bp.register_blueprint(riddle_bp, url_prefix='/riddle') +api_bp.register_blueprint(chunk_bp, url_prefix='/chunk') api_bp.register_blueprint(execution_bp) # execution handles its own prefixes (/code, /run) diff --git a/src/api/routes/chunk_routes.py b/src/api/routes/chunk_routes.py new file mode 100644 index 0000000..6aeb1c1 --- /dev/null +++ b/src/api/routes/chunk_routes.py @@ -0,0 +1,17 @@ +from flask import Blueprint +from handlers.chunk_handler import ChunkHandler + +chunk_bp = Blueprint('chunk', __name__) +handler = ChunkHandler() + +@chunk_bp.route('/', methods=['GET']) +def get_all_chunks(): + return handler.get_all_chunks() + +@chunk_bp.route('/random', methods=['GET']) +def get_random_chunks(): + return handler.get_random_chunks() + +@chunk_bp.route('/', methods=['GET']) +def get_chunk(chunk_id): + return handler.get_chunk(chunk_id) diff --git a/src/api/routes/execution_routes.py b/src/api/routes/execution_routes.py index ea46143..1ea55f8 100644 --- a/src/api/routes/execution_routes.py +++ b/src/api/routes/execution_routes.py @@ -13,3 +13,8 @@ def execute_problem_code(problem_id): def custom_code_executor(): """Execute arbitrary code without test cases.""" return execution_handler.custom_code_executor() + +@execution_bp.post('/chunk/execute/') +def execute_chunk_code(chunk_id): + """Execute code against stored test cases for a chunk.""" + return execution_handler.execute_chunk_code(chunk_id) diff --git a/src/app.py b/src/app.py index 4830ff3..ff31b0c 100644 --- a/src/app.py +++ b/src/app.py @@ -1,6 +1,9 @@ +import logging from flask import Flask from api import api_bp +logging.basicConfig(level=logging.INFO) + def create_app(): app = Flask(__name__, static_folder='html') diff --git a/src/handlers/__init__.py b/src/handlers/__init__.py index 0d5a2f9..9d32fad 100644 --- a/src/handlers/__init__.py +++ b/src/handlers/__init__.py @@ -2,4 +2,5 @@ from .problem_handler import ProblemHandler from .question_handler import QuestionHandler from .riddle_handler import RiddleHandler -from .execution_handler import ExecutionHandler \ No newline at end of file +from .execution_handler import ExecutionHandler +from .chunk_handler import ChunkHandler \ No newline at end of file diff --git a/src/handlers/chunk_handler.py b/src/handlers/chunk_handler.py new file mode 100644 index 0000000..e9bb5ef --- /dev/null +++ b/src/handlers/chunk_handler.py @@ -0,0 +1,28 @@ +from flask import jsonify, request +from repositories.chunk_repository import ChunkRepository + +class ChunkHandler: + def __init__(self): + self.repo = ChunkRepository() + + def get_all_chunks(self): + page = request.args.get('page', default=1, type=int) + limit = request.args.get('limit', default=10, type=int) + lang = request.args.get('lang') + + chunks = self.repo.find_all(page=page, limit=limit, lang=lang) + return jsonify(status="success", data=chunks), 200 + + def get_chunk(self, chunk_id): + lang = request.args.get('lang') + chunk = self.repo.get_details(chunk_id, lang=lang) + if not chunk: + return jsonify(status="error", message="Chunk not found"), 404 + return jsonify(status="success", data=chunk), 200 + + def get_random_chunks(self): + limit = request.args.get('limit', default=1, type=int) + lang = request.args.get('lang') + + chunks = self.repo.find_random(limit=limit, lang=lang) + return jsonify(status="success", data=chunks), 200 diff --git a/src/handlers/execution_handler.py b/src/handlers/execution_handler.py index 605be0a..e4a3514 100644 --- a/src/handlers/execution_handler.py +++ b/src/handlers/execution_handler.py @@ -33,3 +33,15 @@ def custom_code_executor(self): res = execute_custom_code(code, lang) return jsonify(res), (500 if res.get("status") == "error" else 200) + + def execute_chunk_code(self, chunk_id): + """Execute chunk code from template and provided snippets against chunk's testcases.""" + 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() + snippets = data.get('snippets', {}) + + res = self.execution_service.run_chunk_code(chunk_id, snippets, lang) + return jsonify(res), (500 if res.get("status") == "error" else 200) diff --git a/src/models/__init__.py b/src/models/__init__.py index 4f645ee..c3a5a13 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -2,5 +2,7 @@ Problem, Category, Tag, TestCase, ProblemCategoryLink, ProblemTagLink, Riddle, Question, Choice, - RiddleTagLink, QuestionTagLink, QuestionCategoryLink + RiddleTagLink, QuestionTagLink, QuestionCategoryLink, + ChunkTemplate, Chunk, Snippet, Expectation, + ChunkCategoryLink, ChunkTagLink ) diff --git a/src/models/base.py b/src/models/base.py index fa12ca6..abe581a 100644 --- a/src/models/base.py +++ b/src/models/base.py @@ -61,6 +61,15 @@ class TestCase(SQLModel, table=True): problem: "Problem" = Relationship(back_populates="test_cases") +class Expectation(SQLModel, table=True): + __tablename__ = "expectations" + id: UUID = Field(default_factory=uuid4, primary_key=True) + chunk_id: UUID = Field(foreign_key="chunks.id") + input: str + output: str + + chunk: "Chunk" = Relationship(back_populates="expectations") + class Riddle(SQLModel, table=True): __tablename__ = "riddles" id: UUID = Field(default_factory=uuid4, primary_key=True) @@ -92,3 +101,47 @@ class Choice(SQLModel, table=True): question: "Question" = Relationship(back_populates="choices") + +class ChunkCategoryLink(SQLModel, table=True): + __tablename__ = "chunks_categories" + chunk_id: UUID = Field(foreign_key="chunks.id", primary_key=True) + category_id: UUID = Field(foreign_key="categories.id", primary_key=True) + +class ChunkTagLink(SQLModel, table=True): + __tablename__ = "chunks_tags" + chunk_id: UUID = Field(foreign_key="chunks.id", primary_key=True) + tag_id: UUID = Field(foreign_key="tags.id", primary_key=True) + +class ChunkTemplate(SQLModel, table=True): + __tablename__ = "chunk_templates" + id: UUID = Field(default_factory=uuid4, primary_key=True) + chunk_id: UUID = Field(foreign_key="chunks.id") + language: str + name: str # e.g. "Python Implementation" + template_code: str + description: str + + chunk: "Chunk" = Relationship(back_populates="templates") + snippets: List["Snippet"] = Relationship(back_populates="template", sa_relationship_kwargs={"cascade": "all, delete-orphan"}) + +class Chunk(SQLModel, table=True): + __tablename__ = "chunks" + id: UUID = Field(default_factory=uuid4, primary_key=True) + # template_id moved to ChunkTemplate as chunk_id + title: str + difficulty: str + created_at: datetime = Field(default_factory=datetime.utcnow) + + templates: List["ChunkTemplate"] = Relationship(back_populates="chunk", sa_relationship_kwargs={"cascade": "all, delete-orphan"}) + categories: List["Category"] = Relationship(link_model=ChunkCategoryLink) + tags: List["Tag"] = Relationship(link_model=ChunkTagLink) + expectations: List["Expectation"] = Relationship(back_populates="chunk", sa_relationship_kwargs={"cascade": "all, delete-orphan"}) + +class Snippet(SQLModel, table=True): + __tablename__ = "snippets" + id: UUID = Field(default_factory=uuid4, primary_key=True) + template_id: UUID = Field(foreign_key="chunk_templates.id") + placeholder_key: str + code_content: str + + template: "ChunkTemplate" = Relationship(back_populates="snippets") diff --git a/src/openapi.yaml b/src/openapi.yaml index b8e53d9..53ae2fd 100644 --- a/src/openapi.yaml +++ b/src/openapi.yaml @@ -505,3 +505,98 @@ paths: description: Riddle updated '404': description: Riddle not found + + /chunk/: + get: + summary: List all chunks + parameters: + - name: page + in: query + schema: + type: integer + default: 1 + - name: limit + in: query + schema: + type: integer + default: 10 + - name: lang + in: query + description: Filter templates by language + schema: + type: string + responses: + '200': + description: A list of code chunks + + /chunk/random: + get: + summary: Get random chunks + parameters: + - name: limit + in: query + schema: + type: integer + default: 1 + - name: lang + in: query + description: Filter random chunks by language availability + schema: + type: string + responses: + '200': + description: One or more random chunks + + /chunk/{chunk_id}: + get: + summary: Get chunk details + parameters: + - name: chunk_id + in: path + required: true + schema: + type: string + format: uuid + - name: lang + in: query + description: Filter templates to this specific language + schema: + type: string + responses: + '200': + description: Detailed chunk information with templates and snippets + '404': + description: Chunk not found + + /chunk/execute/{chunk_id}: + post: + summary: Execute code for a chunk using templates and snippets + parameters: + - name: chunk_id + in: path + required: true + schema: + type: string + format: uuid + - name: lang + in: query + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + snippets: + type: object + additionalProperties: + type: string + example: { "logic": "return x * y" } + responses: + '200': + description: Execution results with test case statuses + '404': + description: Chunk or template not found diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py index 9114006..3c90708 100644 --- a/src/repositories/__init__.py +++ b/src/repositories/__init__.py @@ -1,2 +1,3 @@ from .problem_repository import ProblemRepository from .test_case_repository import TestCaseRepository +from .chunk_repository import ChunkRepository diff --git a/src/repositories/chunk_repository.py b/src/repositories/chunk_repository.py new file mode 100644 index 0000000..f4a4d8b --- /dev/null +++ b/src/repositories/chunk_repository.py @@ -0,0 +1,93 @@ +from sqlmodel import select +from sqlalchemy.orm import joinedload +from sqlalchemy import func +from infrastructure import SessionLocal +from models import Chunk, Snippet, ChunkTemplate + +class ChunkRepository: + def __init__(self, session=None): + self._session = session + + def _get_session(self): + return self._session if self._session else SessionLocal() + + def find_all(self, page=1, limit=10, lang=None): + """Retrieve all chunks with their templates and nested snippets. Supports pagination and language filtering.""" + with self._get_session() as session: + # Base query + statement = select(Chunk).options( + joinedload(Chunk.templates).joinedload(ChunkTemplate.snippets) + ) + + # If language is specified, filter chunks that HAVE at least one template in that language + if lang: + statement = statement.join(Chunk.templates).where(ChunkTemplate.language == lang) + + statement = statement.order_by(Chunk.id).offset((page - 1) * limit).limit(limit) + + results = session.exec(statement).unique().all() + chunks = [] + for chunk in results: + c_dict = self._serialize_chunk(chunk, lang) + chunks.append(c_dict) + return chunks + + def _serialize_chunk(self, chunk, lang=None): + """Helper to serialize a chunk and optionally filter its templates by language.""" + c_dict = chunk.model_dump() + c_dict["templates"] = [] + for t in chunk.templates: + # Skip if language filter is active and doesn't match + if lang and t.language != lang: + continue + + t_dict = { + "id": str(t.id), + "language": t.language, + "name": t.name, + "template_code": t.template_code, + "description": t.description, + "snippets": [{"placeholder_key": s.placeholder_key, "code_content": s.code_content} for s in t.snippets] + } + c_dict["templates"].append(t_dict) + return c_dict + + def find_by_id(self, chunk_id): + """Internal helper to fetch a Chunk model by UUID with its implementation details.""" + with self._get_session() as session: + statement = select(Chunk).where(Chunk.id == chunk_id).options( + joinedload(Chunk.templates).joinedload(ChunkTemplate.snippets), + joinedload(Chunk.expectations) + ) + return session.exec(statement).unique().first() + + def get_details(self, chunk_id, lang=None): + """Fetch chunk details with templates and snippets, optionally filtered by language.""" + chunk = self.find_by_id(chunk_id) + if not chunk: + return None + + return self._serialize_chunk(chunk, lang) + + def find_random(self, limit=1, lang=None): + """Fetch random N chunks with their implementation details. Filters by language if provided.""" + with self._get_session() as session: + statement = select(Chunk).options( + joinedload(Chunk.templates).joinedload(ChunkTemplate.snippets) + ) + + if lang: + # Require that the chunk HAS a template in that language + statement = statement.join(Chunk.templates).where(ChunkTemplate.language == lang) + + statement = statement.order_by(func.random()).limit(limit) + + results = session.exec(statement).unique().all() + if not results: + return [] + + chunks = [] + for chunk in results: + chunks.append(self._serialize_chunk(chunk, lang)) + + return chunks diff --git a/src/repositories/test_case_repository.py b/src/repositories/test_case_repository.py index 49d8ba3..31f3595 100644 --- a/src/repositories/test_case_repository.py +++ b/src/repositories/test_case_repository.py @@ -14,8 +14,10 @@ def find_all_by_problem(self, 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() - return [{"input": tc.input, "expected_output": tc.output, "test_number": tc.sort_order} for tc in results] - + return [ + {"input": tc.input, "expected_output": tc.output, "test_number": i} + for i, tc in enumerate(results, 1) + ] def find_public_by_problem(self, problem_id, limit=None): """Fetch non-hidden test cases for public documentation. @@ -51,4 +53,14 @@ def create_test_case(self, testcase_data): session.add(testcase) session.commit() session.refresh(testcase) - return testcase.model_dump() \ No newline at end of file + return testcase.model_dump() + + def exists_test_case(self, problem_id, input_data, output_data): + """ Check if a test case already exists """ + with self._get_session() as session: + statement = select(TestCase).where( + TestCase.problem_id == problem_id, + TestCase.input == input_data, + TestCase.output == output_data + ) + return session.exec(statement).first() is not None \ No newline at end of file diff --git a/src/requirements.txt b/src/requirements.txt index 05408df..6c74785 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -8,3 +8,5 @@ pydantic-settings==2.4.0 esprima==4.0.1 javalang==0.13.0 pytest-cov==6.0.0 +psycopg2-binary==2.9.6 +pybars3==0.9.7 \ No newline at end of file diff --git a/src/scripts/seed.py b/src/scripts/seed.py index bf9917a..ca252ce 100644 --- a/src/scripts/seed.py +++ b/src/scripts/seed.py @@ -6,7 +6,11 @@ from sqlalchemy import text from sqlmodel import Session, select from infrastructure import engine -from models import Problem, Category, Tag, TestCase, Riddle, Question, Choice, RiddleTagLink, QuestionTagLink, QuestionCategoryLink +from models import ( + Problem, Category, Tag, TestCase, Riddle, Question, Choice, + RiddleTagLink, QuestionTagLink, QuestionCategoryLink, + ChunkTemplate, Chunk, Snippet, ChunkCategoryLink, ChunkTagLink, Expectation +) logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') @@ -269,6 +273,143 @@ def add_problem(title, description, difficulty, category_list, tag_list, config= session.commit() logging.info("Successfully seeded 10 questions with choices.") + # 7. Seed Chunks + logging.info("Checking for chunks to seed...") + + chunks_to_seed = [ + { + "title": "Sample Chunk 1", + "difficulty": "Medium", + "templates": [ + { + "lang": "python", + "name": "Python Implementation", + "code": "def solution({{args}}):\n {{logic}}\n\n# Test here\n{{test}}", + "snippets": [("args", "x, y"), ("logic", "return x * y"), ("test", "print(solution(1, 2))")] + }, + { + "lang": "javascript", + "name": "JavaScript Implementation", + "code": "function solution({{{args}}}) {\n{{{indent logic}}}\n}\n\n// Test here\n{{{test}}}", + "snippets": [("args", "x, y"), ("logic", "return x * y;"), ("test", "console.log(solution(1, 2))")] + } + ], + "expectation": {"input": "1 2", "output": "2"} + }, + { + "title": "Sample Chunk 2", + "difficulty": "Easy", + "templates": [ + { + "lang": "python", + "name": "Python Implementation", + "code": "def solution({{{args}}}):\n{{{indent logic}}}\n\n# Test here\n{{{test}}}", + "snippets": [("args", "x, y"), ("logic", "return x + y"), ("test", "print(solution(2, 4))")] + }, + { + "lang": "javascript", + "name": "JavaScript Implementation", + "code": "function solution({{{args}}}) {\n{{{indent logic}}}\n}\n\n// Test here\n{{{test}}}", + "snippets": [("args", "x, y"), ("logic", "return x + y;"), ("test", "console.log(solution(2, 4))")] + } + ], + "expectation": {"input": "2 4", "output": "6"} + }, + { + "title": "Sample Chunk 3", + "difficulty": "Hard", + "templates": [ + { + "lang": "python", + "name": "Python Implementation", + "code": "def solution({{{args}}}):\n{{{indent logic}}}\n\n# Test here\n{{{test}}}", + "snippets": [("args", "x, y"), ("logic", "return x * y"), ("test", "print(solution(3, 6))")] + }, + { + "lang": "javascript", + "name": "JavaScript Implementation", + "code": "function solution({{{args}}}) {\n{{{indent logic}}}\n}\n\n// Test here\n{{{test}}}", + "snippets": [("args", "x, y"), ("logic", "return x * y;"), ("test", "console.log(solution(3, 6))")] + } + ], + "expectation": {"input": "3 6", "output": "18"} + }, + { + "title": "Complex Logic Builder", + "difficulty": "Hard", + "category": "Advanced", + "templates": [ + { + "lang": "python", + "name": "Complex Python Implementation", + "code": "{{{imports}}}\n\n{{{setup}}}\n\ndef utility(v):\n return v * 2\n\ndef solution({{{args}}}):\n{{{indent validation}}}\n{{{indent logic}}}\n\n# Test here\n{{{test}}}", + "snippets": [ + ("imports", "import math\nimport random"), + ("setup", "OFFSET = 100\ndef get_offset(): return OFFSET"), + ("args", "data_list, multiplier"), + ("validation", "if not isinstance(data_list, list): return None"), + ("logic", "result = [utility(x) * multiplier + get_offset() for x in data_list]\nreturn result"), + ("test", "print(solution([1, 2, 3], 5))") + ] + }, + { + "lang": "javascript", + "name": "Complex JavaScript Implementation", + "code": "{{{imports}}}\n\n{{{setup}}}\n\nfunction utility(v) {\n return v * 2;\n}\n\nfunction solution({{{args}}}) {\n{{{indent validation}}}\n{{{indent logic}}}\n}\n\n// Test here\n{{{test}}}", + "snippets": [ + ("imports", "// No imports needed"), + ("setup", "const OFFSET = 100;\nfunction getOffset() { return OFFSET; }"), + ("args", "dataList, multiplier"), + ("validation", "if (!Array.isArray(dataList)) return null;"), + ("logic", "const result = dataList.map(x => utility(x) * multiplier + getOffset());\nreturn result;"), + ("test", "const res = solution([1, 2, 3], 5); console.log('[' + res.join(', ') + ']');") + ] + } + ], + "expectation": {"input": "[1, 2, 3] 5", "output": "[110, 120, 130]"} + } + ] + + for c_data in chunks_to_seed: + existing = session.exec(select(Chunk).where(Chunk.title == c_data["title"])).first() + if existing: + continue + + logging.info(f"Seeding chunk: {c_data['title']}") + chunk = Chunk( + title=c_data["title"], + difficulty=c_data["difficulty"], + created_at=datetime.now(timezone.utc) + ) + chunk.categories = [get_or_create_category(c_data.get("category", "Basics"))] + session.add(chunk) + session.flush() + + for t_data in c_data["templates"]: + template = ChunkTemplate( + chunk_id=chunk.id, + language=t_data["lang"], + name=t_data["name"], + template_code=t_data["code"], + description=f"Standard {t_data['lang']} boilerplate" + ) + session.add(template) + session.flush() + + for key, content in t_data["snippets"]: + s = Snippet(template_id=template.id, placeholder_key=key, code_content=content) + session.add(s) + + if "expectation" in c_data: + ex = Expectation( + chunk_id=chunk.id, + input=c_data["expectation"]["input"], + output=c_data["expectation"]["output"] + ) + session.add(ex) + + session.commit() + logging.info("Chunk seeding process completed.") if __name__ == "__main__": seed_data() diff --git a/src/services/execution_service.py b/src/services/execution_service.py index a007545..c0731a2 100644 --- a/src/services/execution_service.py +++ b/src/services/execution_service.py @@ -1,10 +1,14 @@ -from repositories import ProblemRepository, TestCaseRepository +import logging +from repositories import ProblemRepository, TestCaseRepository, ChunkRepository from core import execute_code as core_execute +from pybars import Compiler class ExecutionService: def __init__(self): self.test_case_repo = TestCaseRepository() self.problem_repo = ProblemRepository() + self.chunk_repo = ChunkRepository() + self.compiler = Compiler() def run_problem_code(self, problem_id, code, lang): """Execute provided code against all test cases for a specific problem.""" @@ -23,3 +27,49 @@ def run_problem_code(self, problem_id, code, lang): templates=cfg.get("templates", {}), rules=cfg.get("rules", {}) ) + + def run_chunk_code(self, chunk_id, snippets_payload, lang): + """Execute chunk by combining template code with provided snippets against chunk's expectations.""" + chunk = self.chunk_repo.find_by_id(chunk_id) + if not chunk: + return {"status": "error", "message": "Chunk not found"} + + # Find implementation for the requested language + template = next((t for t in chunk.templates if t.language == lang), None) + if not template: + return {"status": "error", "message": f"Implementation for language '{lang}' not found for this chunk"} + + # Build map of default snippets from the template + snippet_map = {s.placeholder_key: s.code_content for s in template.snippets} + + # Update with provided snippets. Payload can be a dict key->content or list of dicts. + if isinstance(snippets_payload, list): + for s in snippets_payload: + if 'placeholder_key' in s and 'code_content' in s: + snippet_map[s['placeholder_key']] = s['code_content'] + elif isinstance(snippets_payload, dict): + snippet_map.update(snippets_payload) + + # Build final code from template using Handlebars (pybars) + def indent_helper(this, text, spaces=4): + if not text: + return "" + import re + ind = " " * int(spaces) + # Use regex to replace start of each line with the indentation + return re.sub(r'^', ind, str(text), flags=re.MULTILINE) + + compiled = self.compiler.compile(template.template_code) + final_code = compiled(snippet_map, helpers={'indent': indent_helper}) + + test_cases = [{"input": tc.input, "expected_output": tc.output, "test_number": i} for i, tc in enumerate(chunk.expectations, 1)] + + # chunks don't have config right now, using default timeout 5 + return core_execute( + code=final_code, + lang=lang, + tests=test_cases, + timeout=5, + templates={}, + rules={} + ) diff --git a/src/services/problem_service.py b/src/services/problem_service.py index e8e58f4..33064e2 100644 --- a/src/services/problem_service.py +++ b/src/services/problem_service.py @@ -75,16 +75,23 @@ def add_test_cases(self, problem_id, testcases): return {'status': 'error', 'message': 'Problem not found'} created_testcases = [] + skip_testcases = [] + for test in testcases: if 'input' not in test or 'output' not in test: return {'status': 'error', 'message': 'Testcase must have input and output'} + testcase_data = { 'problem_id': problem_id, - 'input': test['input'], - 'output': test['output'], + 'input': test['input'].strip(), + 'output': test['output'].strip(), 'is_hidden': test.get('isHidden', False) } + if self.test_case_repo.exists_test_case(problem_id, testcase_data['input'], testcase_data['output']): + skip_testcases.append(testcase_data) + continue + created = self.test_case_repo.create_test_case(testcase_data) if created: @@ -92,7 +99,8 @@ def add_test_cases(self, problem_id, testcases): return {'status': 'success', 'data': { 'created_count': len(created_testcases), - 'testcases': created_testcases + 'testcases': created_testcases, + 'skip_testcases': skip_testcases }} def import_test_cases(self, problem_id, zip_file): @@ -117,6 +125,7 @@ def import_test_cases(self, problem_id, zip_file): is_shown = set(map(int, content.split(','))) testcases = [] + skip_testcases = [] input_files = [name for name in zip_data.namelist() if name.startswith('in/') and name.endswith('.in')] @@ -150,6 +159,10 @@ def import_test_cases(self, problem_id, zip_file): 'is_hidden': test.get('is_hidden', False), 'sort_order': test.get('sort_order') } + + if self.test_case_repo.exists_test_case(problem_id, testcase_data['input'], testcase_data['output']): + skip_testcases.append(testcase_data) + continue created = self.test_case_repo.create_test_case(testcase_data) @@ -158,7 +171,8 @@ def import_test_cases(self, problem_id, zip_file): return {'status': 'success', 'data': { 'created_count': len(created_testcases), - 'testcases': created_testcases + 'testcases': created_testcases, + 'skip_testcases': skip_testcases }} except Exception as e: