diff --git a/.devcontainer/Dockerfile_dev b/.devcontainer/Dockerfile_dev new file mode 100644 index 0000000..cbbfdf5 --- /dev/null +++ b/.devcontainer/Dockerfile_dev @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/devcontainers/python:3.12-bullseye + +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get clean -y && rm -rf /var/lib/apt/lists/* + + +COPY requirements-dev.txt requirements-dev.txt +COPY src/requirements.txt src/requirements.txt +RUN python -m pip install --upgrade pip +RUN python -m pip install -r requirements-dev.txt diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..60917fd --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,51 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose +{ + "name": "flask_api_sqlite_db", + + // Update the 'dockerComposeFile' list if you have more compose files or use different names. + // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. + "dockerComposeFile": "docker-compose_dev.yml", + // The 'service' property is the name of the service for the container that VS Code should + // use. Update this value and .devcontainer/docker-compose.yml to the real service name. + "service": "app", + "shutdownAction": "stopCompose", + "workspaceFolder": "/workspace", + "forwardPorts": [5000, 3306], + "portsAttributes": { + "5000": {"label": "Web port", "onAutoForward": "notify"} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-azuretools.vscode-bicep", + "charliermarsh.ruff", + "ms-python.python", + "bierner.github-markdown-preview" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "files.exclude": { + ".coverage": true, + ".pytest_cache": true, + "__pycache__": true, + ".ruff_cache": true + }, + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": true, + "source.fixAll": true + } + } + } + } + }, + "features": { + "ghcr.io/azure/azure-dev/azd:latest": {} + }, + "postCreateCommand": "pip install -e src && python3 -m flask --app src/flaskapp db upgrade --directory src/flaskapp/migrations" +} diff --git a/.devcontainer/docker-compose_dev.yml b/.devcontainer/docker-compose_dev.yml new file mode 100644 index 0000000..7d1473b --- /dev/null +++ b/.devcontainer/docker-compose_dev.yml @@ -0,0 +1,8 @@ +version: '3' +services: + app: + build: + context: .. + dockerfile: ./.devcontainer/Dockerfile_dev + + command: sleep infinity diff --git a/.github/workflows/devcontaienr-ci.yml b/.github/workflows/devcontaienr-ci.yml new file mode 100644 index 0000000..a0db723 --- /dev/null +++ b/.github/workflows/devcontaienr-ci.yml @@ -0,0 +1,30 @@ +name: Check Dev Container + +on: + push: + branches: [ 'main', 'improved-api'] + paths: + - ".devcontainer/**" + - ".github/workflows/devcontainer-ci.yaml" + pull_request: + branches: [ 'main', 'improved-api' ] + paths: + - ".devcontainer/**" + - ".github/workflows/devcontainer-ci.yaml" + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 20.x + uses: actions/setup-node@v3 + with: + node-version: 20.x + - run: npm install -g @devcontainers/cli + - run: devcontainer build --config ./.devcontainer/devcontainer.json --workspace-folder "$(pwd)" \ No newline at end of file diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..541de39 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,32 @@ +name: Run Python linter and formatter + +on: + push: + branches: [ 'main' ] + paths: + - '**.py' + + pull_request: + branches: [ 'main' ] + paths: + - '**.py' + +jobs: + checks-format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.12 + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Check linting with ruff + run: | + ruff check . + - name: Check formatting with black + run: | + black . --verbose diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5691076 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,45 @@ +name: Run Python tests + +on: + push: + branches: [ 'main' ] + paths: + - '**.py' + + pull_request: + branches: [ 'main' ] + paths: + - '**.py' + +jobs: + test_package: + + name: Test ${{ matrix.os }} Python ${{ matrix.python_version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ["ubuntu-20.04"] + python_version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v3 + - name: Setup python + uses: actions/setup-python@v2 + with: + + python-version: ${{ matrix.python_version }} + architecture: x64 + - name: Install dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements-dev.txt + python3 -m pip install -e src + - name: Run the migrations + run: | + python3 -m flask --app src.flaskapp db upgrade --directory src/flaskapp/migrations + env: + DATABASE_FILENAME: testdb.db + - name: Run tests + run: python3 -m pytest + env: + DATABASE_FILENAME: testdb.db diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cca9034 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +exclude: '^tests/snapshots/' +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.14 + hooks: + - id: ruff +- repo: https://github.com/psf/black + rev: 24.1.0 + hooks: + - id: black +- repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + types_or: [css, javascript, ts, tsx, html] diff --git a/README.md b/README.md index 996a239..1f10e9f 100644 --- a/README.md +++ b/README.md @@ -12,40 +12,58 @@ To run the Flask application, follow these steps: cd flask-api-sqlite-db ``` -1. **Install, initialize and activate a virtualenv using:** +1. **Initialize and activate a virtualenv using:** ```bash - pip install virtualenv - python -m virtualenv venv - source venv/bin/activate + python3 -m venv .venv + source .venv/bin/activate ``` >**Note** - In Windows, the `venv` does not have a `bin` directory. Therefore, you'd use the analogous command shown below: ```bash - source venv\Scripts\activate + source .venv\Scripts\activate ``` -1. **Install the dependencies:** +1. **Install the app as an editable package:** ```bash - pip install -r requirements.txt + python3 -m pip install -e src ``` -1. **Execute the following command in your terminal to start the flask app** +1. **Execute the following command to add the database name and apply the migrations:** ```bash export DATABASE_FILENAME=testdb.db - export FLASK_APP=src.app - export FLASK_ENV=development - flask run --reload + python3 -m flask --app src.flaskapp db upgrade --directory src/flaskapp/migrations ``` -### Run the tests -1. **Inside your virtual environment, execute the following command to run the tests** +1. **Execute the following command to run the flask application:** ```bash - python flask_test.py + python3 -m flask --app src.flaskapp run --reload + ``` + +### Development + +1. **Inside your virtual environment, execute the following command to install the development requirements:** + + ```bash + pip install -r requirements-dev.txt + ``` + +1. **Execute the following command to install the pre commit hooks:** + + ```bash + pre-commit install + ``` + +### Testing + +1. **Execute the following command to run the tests** + + ```bash + pytest ``` ## API Documentation @@ -208,5 +226,3 @@ The API will return these error types when the request fails: "total_executions": 10 } ``` - - diff --git a/flask_test.py b/flask_test.py deleted file mode 100644 index 26203c7..0000000 --- a/flask_test.py +++ /dev/null @@ -1,149 +0,0 @@ -import json -import os -import unittest - -from src.app import create_app - - -class TestCaseManagementTestCase(unittest.TestCase): - """This class represents the trivia test case""" - - def setUp(self): - """Define test variables and initialize app.""" - config_override = {"TESTING": True} - os.environ["DATABASE_FILENAME"] = "testdb.db" - self.app = create_app(config_override) - self.client = self.app.test_client - - self.new_test_case = {"name": "New Test Case", "description": "New Test Case Description"} - - self.new_execution = {"asset_id": "1", "test_case_id": "1", "status": True, "details": "Success"} - - def tearDown(self): - """Executed after each test""" - pass - - def test_retrieve_tests(self): - """Test retrieve tests""" - res = self.client().get("/tests") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertTrue(data["test_cases"]) - - def test_405_using_wrong_method_to_retrieve_tests(self): - """Test 405 using wrong method to retrieve tests""" - res = self.client().patch("/tests") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 405) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_create_new_test(self): - """test create new test""" - res = self.client().post("/tests", json=self.new_test_case) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - - def test_400_create_new_test_without_name(self): - """test create new test without providing name""" - res = self.client().post("/tests", json={"testing": "xxx"}) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 400) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_405_creation_not_allowed(self): - """test 405 creation not allowed""" - res = self.client().post("/tests/45", json=self.new_test_case) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 405) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_get_specific_test(self): - """Test get specific test with id""" - res = self.client().get("/tests/1") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertTrue(len(data["test_case"])) - - def test_get_nonexistent_test(self): - """Test get non existent test""" - res = self.client().get("/tests/10000") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 404) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_update_test(self): - """Test update test""" - res = self.client().patch("/tests/1", json={"name": "Updated Test Case"}) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertTrue(data["test_case"]) - - def test_update_test_without_name(self): - """Test update test without providing name""" - res = self.client().patch("/tests/1", json={"testing": "Updated Test Case"}) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 400) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_delete_test_case(self): - """Test delete test case""" - res = self.client().delete("/tests/3") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertEqual(data["deleted_test_case_id"], 5) - self.assertTrue(data["total_test_cases"]) - - def test_404_delete_nonexistent_test(self): - """test 404 delete nonexistent test""" - res = self.client().delete("/tests/10000") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 404) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_get_execution_results(self): - """Test get execution results""" - res = self.client().get("/executions/1") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertTrue(data["executions"]) - self.assertTrue(data["asset"]) - self.assertTrue(data["total_executions"]) - - def test_add_execution_results(self): - """Test add execution result""" - res = self.client().post("/executions", json=self.new_execution) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertTrue(data["execution"]) - self.assertTrue(data["total_executions"]) - - -# Make the tests conveniently executable -if __name__ == "__main__": - unittest.main() diff --git a/pyproject.toml b/pyproject.toml index 3c0020c..f4b519d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,22 @@ [tool.ruff] line-length = 120 -select = ["E", "F", "I", "UP"] -ignore = ["D203"] +lint.select = ["E", "F", "I", "UP"] +target-version = "py312" +extend-exclude = ["src/flaskapp/migrations/"] +src = ["src"] -[tool.ruff.isort] -known-first-party = ["src"] \ No newline at end of file +[tool.ruff.lint.isort] +known-first-party = ["flaskapp"] + +[tool.black] +line-length = 120 +target-version = ["py312"] +extend-exclude = "src/flaskapp/migrations/" + +[tool.pytest.ini_options] +addopts = "-ra --cov" +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.coverage.report] +show_missing = true \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7baff0d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,16 @@ +-r src/requirements.txt + +# Testing Tools +pytest +ephemeral-port-reserve +pytest-playwright +coverage +pytest-cov +axe-playwright-python + +pre-commit +pip-tools + +# Linters +ruff +black \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index efdc915..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -SQLAlchemy==2.0.27 -Flask==3.0.2 -Flask-SQLAlchemy==3.1.1 \ No newline at end of file diff --git a/src/database/__init__.py b/src/database/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/entrypoint.sh b/src/entrypoint.sh new file mode 100644 index 0000000..a15ade2 --- /dev/null +++ b/src/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e +python -m pip install --upgrade pip +python -m pip install -e . +python -m flask --app flaskapp db upgrade --directory flaskapp/migrations +python -m gunicorn flaskapp \ No newline at end of file diff --git a/src/flaskapp/__init__.py b/src/flaskapp/__init__.py new file mode 100644 index 0000000..9a6f2ea --- /dev/null +++ b/src/flaskapp/__init__.py @@ -0,0 +1,3 @@ +from flaskapp.app import create_app + +app = create_app() diff --git a/src/app.py b/src/flaskapp/app.py similarity index 98% rename from src/app.py rename to src/flaskapp/app.py index 0e7f17c..2ef2fa2 100644 --- a/src/app.py +++ b/src/flaskapp/app.py @@ -3,7 +3,7 @@ from flask import Flask, abort, jsonify, request -from src.database.models import ( +from flaskapp.database.models import ( Asset, Execution, TestCase, @@ -32,13 +32,15 @@ def create_app(test_config=None): is_prod_env = "RUNNING_IN_PRODUCTION" in os.environ if not is_prod_env: logging.info("Loading config.development.") - app.config.from_object("src.config.development") + app.config.from_object("flaskapp.config.development") setup_db(app) + # db_drop_and_create_all(app) else: logging.info("Loading config.production.") - app.config.from_object("src.config.production") + app.config.from_object("flaskapp.config.production") setup_db(app) + # db_drop_and_create_all(app) # ----------------------------------------------------------------------------# diff --git a/src/__init__.py b/src/flaskapp/config/__init__.py similarity index 100% rename from src/__init__.py rename to src/flaskapp/config/__init__.py diff --git a/src/config/development.py b/src/flaskapp/config/development.py similarity index 76% rename from src/config/development.py rename to src/flaskapp/config/development.py index b13a393..4f2219c 100644 --- a/src/config/development.py +++ b/src/flaskapp/config/development.py @@ -3,7 +3,7 @@ DEBUG = True -database_filename = os.environ["DATABASE_FILENAME"] +database_filename = os.environ.get("DATABASE_FILENAME", "testdb.db") BASE_DIR = Path(__file__).resolve().parent.parent database_dir = os.path.join(BASE_DIR, "database") DATABASE_URI = f"sqlite:///{os.path.join(database_dir, database_filename)}" diff --git a/src/config/__init__.py b/src/flaskapp/database/__init__.py similarity index 100% rename from src/config/__init__.py rename to src/flaskapp/database/__init__.py diff --git a/src/database/models.py b/src/flaskapp/database/models.py similarity index 88% rename from src/database/models.py rename to src/flaskapp/database/models.py index 4a82912..f4b1b06 100644 --- a/src/database/models.py +++ b/src/flaskapp/database/models.py @@ -1,10 +1,12 @@ """ -Models for MySQL +Models for SQLite database """ + from datetime import datetime -from typing import List +from typing import List # noqa +from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from sqlalchemy import Boolean, DateTime, ForeignKey, String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -15,6 +17,7 @@ class Base(DeclarativeBase): db = SQLAlchemy(model_class=Base) +migrate = Migrate() """ setup_db(app) @@ -27,6 +30,7 @@ def setup_db(app): app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.app = app db.init_app(app) + migrate.init_app(app, db) with app.app_context(): db.create_all() @@ -55,7 +59,9 @@ class TestCase(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(String(500), nullable=True) - executions: Mapped[List["Execution"]] = relationship("Execution", back_populates="test_case") + executions: Mapped[List["Execution"]] = relationship( # noqa + "Execution", back_populates="test_case" + ) def __init__(self, name, description): self.name = name @@ -81,7 +87,9 @@ class Asset(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) - executions: Mapped[List["Execution"]] = relationship("Execution", back_populates="asset") + executions: Mapped[List["Execution"]] = relationship( # noqa + "Execution", back_populates="asset" + ) def __init__(self, name): self.name = name @@ -106,7 +114,9 @@ class Execution(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) test_case_id: Mapped[int] = mapped_column(ForeignKey("test_case.id")) - test_case: Mapped["TestCase"] = relationship("TestCase", back_populates="executions") + test_case: Mapped["TestCase"] = relationship( + "TestCase", back_populates="executions" + ) asset_id: Mapped[int] = mapped_column(ForeignKey("asset.id")) asset: Mapped["Asset"] = relationship("Asset", back_populates="executions") timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/src/database/testdb.db b/src/flaskapp/database/testdb.db similarity index 62% rename from src/database/testdb.db rename to src/flaskapp/database/testdb.db index 331b4a4..49afe37 100644 Binary files a/src/database/testdb.db and b/src/flaskapp/database/testdb.db differ diff --git a/src/flaskapp/migrations/README b/src/flaskapp/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/src/flaskapp/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/src/flaskapp/migrations/alembic.ini b/src/flaskapp/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/src/flaskapp/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[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/src/flaskapp/migrations/env.py b/src/flaskapp/migrations/env.py new file mode 100644 index 0000000..bd7d3e4 --- /dev/null +++ b/src/flaskapp/migrations/env.py @@ -0,0 +1,106 @@ +import logging +from logging.config import fileConfig + +from alembic import context +from flask import current_app + +# 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. +fileConfig(config.config_file_name) +logger = logging.getLogger("alembic.env") + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions["migrate"].db.get_engine() + except TypeError: + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions["migrate"].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace("%", "%%") + except AttributeError: + return str(get_engine().url).replace("%", "%%") + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option("sqlalchemy.url", get_engine_url()) +target_db = current_app.extensions["migrate"].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, "metadatas"): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=get_metadata(), literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, "autogenerate", False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info("No changes in schema detected.") + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + process_revision_directives=process_revision_directives, + **current_app.extensions["migrate"].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/flaskapp/migrations/script.py.mako b/src/flaskapp/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/src/flaskapp/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/src/gunicorn.conf.py b/src/gunicorn.conf.py new file mode 100644 index 0000000..7bfb7a9 --- /dev/null +++ b/src/gunicorn.conf.py @@ -0,0 +1,11 @@ +import multiprocessing + +max_requests = 1000 +max_requests_jitter = 50 +log_file = "-" +bind = "0.0.0.0:5000" +workers = (multiprocessing.cpu_count() * 2) + 1 + +threads = workers + +timeout = 600 diff --git a/src/pyproject.toml b/src/pyproject.toml new file mode 100644 index 0000000..84bb8e4 --- /dev/null +++ b/src/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "flaskapp" +version = "1.0.0" +description = "API for Managing test cases and their execution results across multiple test assets, with data stored in a SQLite database." +dependencies = [ + "flask", + "sqlalchemy", + "flask-migrate", + "flask-sqlalchemy", + "gunicorn" + ] + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..17aa7e3 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,52 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --output-file=requirements.txt pyproject.toml +# +alembic==1.13.1 + # via flask-migrate +blinker==1.7.0 + # via flask +click==8.1.7 + # via flask +flask==3.0.2 + # via + # flask-migrate + # flask-sqlalchemy + # flaskapp (pyproject.toml) +flask-migrate==4.0.7 + # via flaskapp (pyproject.toml) +flask-sqlalchemy==3.1.1 + # via + # flask-migrate + # flaskapp (pyproject.toml) +greenlet==3.0.3 + # via sqlalchemy +gunicorn==20.1.0 + # via flaskapp (pyproject.toml) +itsdangerous==2.1.2 + # via flask +jinja2==3.1.3 + # via flask +mako==1.3.2 + # via alembic +markupsafe==2.1.5 + # via + # jinja2 + # mako + # werkzeug +sqlalchemy==2.0.28 + # via + # alembic + # flask-sqlalchemy + # flaskapp (pyproject.toml) +typing-extensions==4.10.0 + # via + # alembic + # sqlalchemy +werkzeug==3.0.1 + # via flask + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0a52c21 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +import os + +import pytest + +from flaskapp import create_app +from flaskapp.database.models import db + + +@pytest.fixture(scope="session") +def app_with_db(): + """Session-wide test `Flask` application.""" + config_override = { + "TESTING": True, + # Allows for override of database to separate test from dev environments + "SQLALCHEMY_DATABASE_URI": os.environ.get("TEST_DATABASE_URL", os.environ.get("DATABASE_FILENAME")), + } + app = create_app(config_override) + + with app.app_context(): + engines = db.engines + + engine_cleanup = [] + + for key, engine in engines.items(): + connection = engine.connect() + transaction = connection.begin() + engines[key] = connection + engine_cleanup.append((key, engine, connection, transaction)) + + yield app + + for key, engine, connection, transaction in engine_cleanup: + try: + transaction.rollback() + connection.close() + except Exception: + connection.close() + engines[key] = engine diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..1b000e1 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,158 @@ +import pytest + + +@pytest.fixture +def client(app_with_db): + return app_with_db.test_client() + + +def test_index(client): + """Test index page""" + response = client.get("/") + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert body["message"] == "Welcome to the test case management API" + + +def test_retrieve_tests(client): + """Test retrieve tests""" + response = client.get("/tests") + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert body["test_cases"] + + +def test_405_using_wrong_method_to_retrieve_tests(client): + """Test 405 using wrong method to retrieve tests""" + response = client.patch("/tests") + body = response.get_json() + + assert response.status_code == 405 + assert body["success"] is False + assert body["message"] + + +def test_create_new_test(client): + """test create new test""" + response = client.post( + "/tests", + json={ + "name": "New Test Case", + "description": "New Test Case Description", + }, + ) + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + + +def test_400_create_new_test_without_name(client): + """test create new test without providing name""" + response = client.post("/tests", json={"testing": "xxx"}) + body = response.get_json() + + assert response.status_code == 400 + assert body["success"] is False + assert body["message"] + + +def test_405_creation_not_allowed(client): + """test 405 creation not allowed""" + response = client.post( + "/tests/45", + json={ + "name": "New Test Case", + "description": "New Test Case Description", + }, + ) + body = response.get_json() + + assert response.status_code == 405 + assert body["success"] is False + assert body["message"] + + +def test_get_specific_test(client): + """Test get specific test with id""" + response = client.get("/tests/1") + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert len(body["test_case"]) + + +def test_get_nonexistent_test(client): + """Test get non existent test""" + response = client.get("/tests/10000") + body = response.get_json() + + assert response.status_code == 404 + assert body["success"] is False + assert body["message"] + + +def test_update_test(client): + """Test update test""" + response = client.patch("/tests/1", json={"name": "Updated Test Case"}) + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert body["test_case"] + + +def test_update_test_without_name(client): + """Test update test without providing name""" + response = client.patch("/tests/1", json={"testing": "Updated Test Case"}) + body = response.get_json() + + assert response.status_code == 400 + assert body["success"] is False + assert body["message"] + + +def test_404_delete_nonexistent_test(client): + """test 404 delete nonexistent test""" + response = client.delete("/tests/10000") + body = response.get_json() + + assert response.status_code == 404 + assert body["success"] is False + assert body["message"] + + +def test_get_execution_results(client): + """Test get execution results""" + response = client.get("/executions/1") + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert body["executions"] + assert body["asset"] + assert body["total_executions"] + + +def test_add_execution_results(client): + """Test add execution result""" + response = client.post( + "/executions", + json={ + "asset_id": "1", + "test_case_id": "1", + "status": True, + "details": "Success", + }, + ) + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert body["execution"] + assert body["total_executions"] diff --git a/tests/test_gunicorn.py b/tests/test_gunicorn.py new file mode 100644 index 0000000..f5ae570 --- /dev/null +++ b/tests/test_gunicorn.py @@ -0,0 +1,17 @@ +import sys +from unittest import mock + +import pytest + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows doesn't have what it takes.") +def test_config_imports(): + from gunicorn.app.wsgiapp import run + + argv = ["gunicorn", "--check-config", "flaskapp:create_app()", "-c", "src/gunicorn.conf.py"] + + with mock.patch.object(sys, "argv", argv): + with pytest.raises(SystemExit) as excinfo: + run() + + assert excinfo.value.args[0] == 0