diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5daa91d --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +FLASK_APP=main.py +FLASK_ENV=development \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a2137a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*__pycache__ +.idea +venv +env +.env +db.sqlite +.pytest_cache \ No newline at end of file diff --git a/Readme_backend.md b/Readme_backend.md new file mode 100644 index 0000000..a17a328 --- /dev/null +++ b/Readme_backend.md @@ -0,0 +1,97 @@ +## Installation +Python 3.8 version is required. +```bash +python3 --version +``` + +Create virtual environment and install packages +```bash +virtualenv venv +source ./venv/bin/activate +pip install -r requirements.txt +``` +*** + +## Usage +Create `.env` file and fill with similar data like `.env.example`. +Then run application: +```bash +flask run +``` +*** + +## Testing +Run tests +```bash +pytest +``` +*** + +### Useful commands: +Add new package +```bash +pip install NAME_OF_PACKAGE +``` +Save new added package to requirements +```bash +pip freeze > requirements.txt +``` +*** + +#### Flask commands: +Create the application structure directories +```bash +flask startapp NAME_OF_APP +``` +Above command create package structure: +\ +Create serializer in `__schemas__.py` file: +```python3 +class ExampleSchema(ma.SQLAlchemyAutoSchema): + class Meta: + model = MODEL_NAME +``` +\ +Create models in `__models__.py` file: +```python3 +class Example(db.Model): + __tablename__ = "examples" + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + title = db.Column(db.String(255), unique=True) +``` +\ +Create api routes in ```__routes__.py``` file. You only have to register blueprint in `app/__init__.py`. +```python3 +from flask import Blueprint, make_response, jsonify, Response + + +bp = Blueprint("{name}", __name__, url_prefix="/{name}") + + +@bp.route("/", methods=["GET"]) +def index() -> Response: + return make_response(jsonify({{"hello": "{name}"}}), 200) + +``` +\ +Print all your routes +```bash +flask routes +``` +Create migration, but first you have to create model +```bash +flask db migrate +``` +Apply your changes to database, use after migration command +```bash +flask db upgrade +``` +If you want back changes in your databases, use below command +```bash +flask db downgrade +``` +Create empty migration, you can write manually migration here. +```bash +flask db revision +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..293ce43 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,34 @@ +import os + +from flask import Flask +from flask_migrate import Migrate + +from .config import config +from .dependencies import db, ma +from .commands.startapp import startapp +from .utils import import_models + + +def create_app(config_name: str = None) -> Flask: + if config_name is None: + config_name = os.environ.get("FLASK_ENV") + + app = Flask(__name__) + app.config.from_object(config.get(config_name) or config.get("development")) + + db.init_app(app) + ma.init_app(app) + + if config_name != "production": + with app.app_context(): + import_models() + + Migrate(app, db) + + # register blueprints + # app.register_blueprint(blueprint) + + # register commands + app.cli.add_command(startapp) + + return app diff --git a/app/commands/__init__.py b/app/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/commands/startapp.py b/app/commands/startapp.py new file mode 100644 index 0000000..fba829e --- /dev/null +++ b/app/commands/startapp.py @@ -0,0 +1,68 @@ +import os +import re + +from flask import current_app +from click import command, argument +from click.exceptions import ClickException +from flask.cli import with_appcontext + + +@command("startapp") +@argument("name") +@with_appcontext +def startapp(name: str) -> None: + """Create the application structure""" + + if not re.match("^[a-zA-Z].*$", name): + raise ClickException(f"The name argument must be type of string!") + + app_dir = current_app.config["SRC_DIR"] / name + if os.path.exists(app_dir): + raise ClickException("Directory {name} has already exists!") + + os.mkdir(app_dir) + + schema_content = """\'\'\' +Below you write a schema of model +Example: + +class ExampleSchema(ma.SQLAlchemyAutoSchema): + class Meta: + model = MODEL_NAME +\'\'\' + +from ..dependencies import ma +""" + + model_content = """\'\'\' +Below you write a model. +Example: + +class Example(db.Model): + __tablename__ = "examples" + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + title = db.Column(db.String(255), unique=True) + +\'\'\' + +from ..dependencies import db +""" + + route_content = f"""from flask import Blueprint, make_response, jsonify, Response + + +bp = Blueprint("{name}", __name__, url_prefix="/{name}") + + +@bp.route("/", methods=["GET"]) +def index() -> Response: + return make_response(jsonify({{"hello": "{name}"}}), 200) +""" + + filenames = ["__init__.py", "schemas.py", "models.py", "routes.py"] + contents = ["", schema_content, model_content, route_content] + + for filename, content in zip(filenames, contents): + with open(app_dir / filename, "w") as f: + f.write(content) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..fcceb8a --- /dev/null +++ b/app/config.py @@ -0,0 +1,32 @@ +from pathlib import Path + + +class Config: + TESTING = False + DB_SERVER = "localhost" + BASE_DIR = Path(__file__).resolve().parent.parent + SRC_DIR = BASE_DIR / "app" + EXCLUDED_DIRS = ["__pycache__", "commands"] + + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_DATABASE_URI = f'sqlite:///{BASE_DIR / "db.sqlite"}' + + +class ProductionConfig(Config): + DB_SERVER = "0.0.0.0" + + +class DevelopmentConfig(Config): + pass + + +class TestingConfig(Config): + TESTING = True + SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" + + +config = { + "development": DevelopmentConfig, + "production": ProductionConfig, + "testing": TestingConfig, +} diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..b474658 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,7 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_marshmallow import Marshmallow + + +ma = Marshmallow() + +db = SQLAlchemy() diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..a7de7c2 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,28 @@ +import os +import importlib +import warnings + +from flask import current_app + + +def get_app_directories() -> list: + directories = [] + src_dir = current_app.config['SRC_DIR'] + excluded_dirs = current_app.config['EXCLUDED_DIRS'] + + for dirname in os.listdir(src_dir): + path = src_dir / dirname + if os.path.isdir(path) and dirname not in excluded_dirs: + directories.append(dirname) + return directories + + +def import_models() -> None: + directories = get_app_directories() + + models_module = "models" + for dirname in directories: + try: + importlib.import_module(f"app.{dirname}.{models_module}") + except ModuleNotFoundError: + warnings.warn(f"Not found module {models_module}.py in package {dirname}") diff --git a/main.py b/main.py new file mode 100644 index 0000000..238d77b --- /dev/null +++ b/main.py @@ -0,0 +1,7 @@ +from dotenv import load_dotenv +from app import create_app + +if __name__ == "__main__": + load_dotenv() + app = create_app() + app.run() diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/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/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..68feded --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,91 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# 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') + +# 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', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True + ) + + 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 = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_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/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f703253 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,32 @@ +alembic==1.7.7 +attrs==21.4.0 +click==8.1.3 +Flask==2.1.2 +flask-marshmallow==0.14.0 +Flask-Migrate==3.1.0 +Flask-SQLAlchemy==2.5.1 +greenlet==1.1.2 +importlib-metadata==4.11.3 +importlib-resources==5.7.1 +iniconfig==1.1.1 +itsdangerous==2.1.2 +Jinja2==3.1.2 +Mako==1.2.0 +MarkupSafe==2.1.1 +marshmallow==3.15.0 +marshmallow-sqlalchemy==0.28.0 +mccabe==0.6.1 +packaging==21.3 +pluggy==1.0.0 +py==1.11.0 +pycodestyle==2.8.0 +pyflakes==2.4.0 +pyparsing==3.0.9 +pytest==7.1.2 +pytest-flask==1.2.0 +python-dotenv==0.20.0 +six==1.16.0 +SQLAlchemy==1.4.36 +tomli==2.0.1 +Werkzeug==2.1.2 +zipp==3.8.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2252e7e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +from typing import Generator + +import pytest +from flask import Flask +from flask.testing import FlaskClient + +from app import create_app + + +@pytest.fixture() +def app() -> Generator[Flask, None, None]: + app = create_app("testing") + yield app + + +@pytest.fixture() +def client(app: Flask) -> FlaskClient: + return app.test_client() diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..34c3416 --- /dev/null +++ b/tests/test_example.py @@ -0,0 +1,2 @@ +def test_comparing_two_number(): + assert 1 == 1