├── {{cookiecutter.repo_name}} ├── src │ ├── __init__.py │ ├── domain │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── base_model.py │ │ │ └── todo.py │ │ ├── constants.py │ │ ├── __init__.py │ │ └── exceptions.py │ ├── api │ │ ├── schemas │ │ │ ├── __init__.py │ │ │ └── todo.py │ │ ├── controllers │ │ │ ├── __init__.py │ │ │ └── todo.py │ │ ├── __init__.py │ │ ├── responses.py │ │ ├── middleware.py │ │ └── requests.py │ ├── cors.py │ ├── infrastructure │ │ ├── databases │ │ │ ├── __init__.py │ │ │ └── sql_alchemy.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── sql_todo.py │ │ │ └── model_extension.py │ │ ├── repositories │ │ │ ├── __init__.py │ │ │ ├── todo_repository.py │ │ │ └── repository.py │ │ └── __init__.py │ ├── services │ │ ├── __init__.py │ │ ├── todo_service.py │ │ └── repository_service.py │ ├── management.py │ ├── app.py │ ├── dependency_container.py │ ├── config.py │ ├── create_app.py │ ├── logging.py │ └── error_handler.py ├── tests │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ └── controllers │ │ │ ├── __init__.py │ │ │ └── todo │ │ │ ├── __init__.py │ │ │ ├── test_delete.py │ │ │ ├── test_get.py │ │ │ ├── test_patch.py │ │ │ └── test_post.py │ ├── domain │ │ └── __init__.py │ ├── services │ │ └── __init__.py │ └── resources │ │ ├── __init__.py │ │ └── app_test_base.py ├── requirements-test.txt ├── wsgi.py ├── requirements.txt ├── scripts │ └── run_postgres.sh ├── Dockerfile ├── .gitignore └── README.md ├── cookiecutter.json ├── docs ├── images │ └── maintenance_window_sequence.jpg ├── onion-architecture-article.md └── maintenance_window_database_migration_strategy.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── SUPPORT.md ├── SECURITY.md ├── .gitignore └── README.md /{{cookiecutter.repo_name}}/src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "repo_name": "Your Project Name" 3 | } -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/api/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/api/controllers/todo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/requirements-test.txt: -------------------------------------------------------------------------------- 1 | Flask-Testing==0.8.1 2 | testcontainers==3.7.0 3 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/domain/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .todo import Todo 2 | 3 | __all__ = ["Todo"] 4 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/api/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .todo import TodoSchema 2 | 3 | __all__ = ['TodoSchema'] 4 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from .app_test_base import AppTestBase 2 | 3 | __all__ = ["AppTestBase"] 4 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/cors.py: -------------------------------------------------------------------------------- 1 | from flask_cors import CORS 2 | 3 | 4 | def setup_cors(app): 5 | CORS(app, supports_credentials=True) 6 | return app 7 | -------------------------------------------------------------------------------- /docs/images/maintenance_window_sequence.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/cookiecutter-python-flask-clean-architecture/HEAD/docs/images/maintenance_window_sequence.jpg -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/infrastructure/databases/__init__.py: -------------------------------------------------------------------------------- 1 | from .sql_alchemy import sqlalchemy_db, setup_sqlalchemy 2 | 3 | __all__ = ["setup_sqlalchemy", "sqlalchemy_db"] 4 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/infrastructure/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .model_extension import SQLModelExtension 2 | from .sql_todo import SQLTodo 3 | 4 | __all__ = ['SQLModelExtension', 'SQLTodo'] 5 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .repository_service import RepositoryService 2 | from .todo_service import TodoService 3 | 4 | __all__ = ["RepositoryService", "TodoService"] 5 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/wsgi.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from src.app import app 5 | 6 | logging.basicConfig(stream=sys.stderr) 7 | 8 | if __name__ == "__main__": 9 | app.run() 10 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/infrastructure/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | from .repository import Repository 2 | from .todo_repository import SQLTodoRepository 3 | 4 | __all__ = ["Repository", "SQLTodoRepository"] 5 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/api/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from .todo import blueprint as todo_blueprint 2 | 3 | 4 | def setup_blueprints(app) -> None: 5 | app.register_blueprint(todo_blueprint, url_prefix="/v1") 6 | return app 7 | 8 | 9 | __all__ = ['setup_blueprints'] 10 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=2.2.2 2 | Flask-SQLAlchemy>=3.0.3 3 | Flask-Migrate>=2.6.0 4 | Flask-Cors>=3.0.8 5 | psycopg2>=2.9.5 6 | itsdangerous>=1.1.0 7 | alembic~=1.8.1 8 | gunicorn>=20.1.0 9 | dependency-injector>=4.40.0 10 | python-dotenv>=0.21.0 11 | marshmallow>=3.5.0 12 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | from .databases import sqlalchemy_db, setup_sqlalchemy 2 | from .repositories import Repository, SQLTodoRepository 3 | 4 | __all__ = [ 5 | "setup_sqlalchemy", 6 | "sqlalchemy_db", 7 | "Repository", 8 | "SQLTodoRepository", 9 | ] 10 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/domain/constants.py: -------------------------------------------------------------------------------- 1 | SQLALCHEMY_DATABASE_URI = 'SQLALCHEMY_DATABASE_URI' 2 | LOG_LEVEL = 'LOG_LEVEL' 3 | DEFAULT_PER_PAGE_VALUE = 10 4 | DEFAULT_PAGE_VALUE = 1 5 | ITEMIZE = 'itemize' 6 | ITEMIZED = 'itemized' 7 | PAGE = 'page' 8 | PER_PAGE = 'per_page' 9 | SERVICE_PREFIX = "SERVICE_PREFIX" 10 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/scripts/run_postgres.sh: -------------------------------------------------------------------------------- 1 | docker pull postgres:14-alpine 2 | docker run --rm -P -p 127.0.0.1:5432:5432 -e POSTGRES_PASSWORD="postgres_pw" -e POSTGRES_USER="test_user" -d --name flask_postgres postgres:14-alpine 3 | echo SQLALCHEMY_DATABASE_URI=postgresql://test_user:postgres_pw@127.0.0.1:5432/postgres >> .env 4 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | RUN apt-get -qq update 4 | RUN pip install --upgrade pip && pip install pip-tools 5 | RUN apt-get install -y --no-install-recommends g++ 6 | 7 | WORKDIR /app 8 | COPY . . 9 | 10 | RUN pip install -r requirements.txt 11 | 12 | EXPOSE 7000 13 | CMD gunicorn wsgi:app -b 0.0.0.0:7000 --workers=1 --preload 14 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/api/schemas/todo.py: -------------------------------------------------------------------------------- 1 | from marshmallow import Schema, fields 2 | 3 | 4 | class TodoSchema(Schema): 5 | id = fields.Int(dump_only=True) 6 | title = fields.Str() 7 | description = fields.Str() 8 | status = fields.Str() 9 | created_at = fields.DateTime() 10 | updated_at = fields.DateTime() 11 | completed = fields.Bool() 12 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .requests import get_query_param 2 | from .middleware import setup_prefix_middleware, post_data_required 3 | from .responses import create_response 4 | from .controllers import setup_blueprints 5 | 6 | __all__ = [ 7 | 'get_query_param', 8 | 'setup_prefix_middleware', 9 | 'post_data_required', 10 | 'setup_blueprints' 11 | ] 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/services/todo_service.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from .repository_service import RepositoryService 4 | 5 | class TodoService(RepositoryService): 6 | 7 | def create(self, data): 8 | data["created_at"] = datetime.now(tz=timezone.utc) 9 | return self.repository.create(data) 10 | 11 | def update(self, object_id, data): 12 | data["updated_at"] = datetime.now(tz=timezone.utc) 13 | return self.repository.update(object_id, data) 14 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/management.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask_migrate import Migrate 4 | from src.infrastructure import sqlalchemy_db as db 5 | 6 | migrate = Migrate() 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def setup_management(app): 11 | migrate.init_app(app, db) 12 | 13 | @app.cli.command("show_db_tables") 14 | def show_db_tables(): 15 | print(db.engine.table_names()) 16 | 17 | @app.cli.command("print_config") 18 | def print_config(): 19 | print(app.config) 20 | 21 | return app 22 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from src.create_app import create_app 5 | from src.config import Config 6 | from src import api 7 | 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | app = create_app(Config, dependency_container_packages=[api]) 12 | 13 | 14 | if __name__ == '__main__': 15 | app_env = os.getenv('FLASK_ENV', 'development') 16 | port = os.getenv('PORT', 7000) 17 | debug = True 18 | 19 | if app_env != 'development': 20 | debug = True 21 | 22 | app.run(debug=debug, host='0.0.0.0', port=port) 23 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/infrastructure/repositories/todo_repository.py: -------------------------------------------------------------------------------- 1 | from .repository import Repository 2 | from ..models import SQLTodo 3 | 4 | 5 | class SQLTodoRepository(Repository): 6 | DEFAULT_NOT_FOUND_MESSAGE = "The requested todo was not found" 7 | base_class = SQLTodo 8 | 9 | def apply_query_params(self, query, query_params): 10 | title_query_param = self.get_query_param("title", query_params) 11 | 12 | if title_query_param is not None: 13 | query = query.filter(SQLTodo.title == title_query_param) 14 | 15 | return query 16 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/api/responses.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | import inspect 3 | 4 | 5 | def create_response(item, serializer, status_code=200): 6 | 7 | if item is None or item == {}: 8 | return jsonify({}), status_code 9 | 10 | if inspect.isclass(serializer): 11 | serializer = serializer() 12 | 13 | if isinstance(item, dict): 14 | item_selection = item["items"] 15 | item["items"] = serializer.dump(item_selection, many=True) 16 | return item, status_code 17 | else: 18 | return jsonify(serializer.dump(item)), status_code 19 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/infrastructure/models/sql_todo.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, String, DateTime, Boolean, Integer 2 | 3 | from .model_extension import SQLModelExtension 4 | from src.domain import Todo 5 | from src.infrastructure.databases import sqlalchemy_db as db 6 | 7 | 8 | 9 | class SQLTodo(db.Model, Todo, SQLModelExtension): 10 | id = Column(Integer, primary_key=True, unique=True) 11 | title = Column(String(255), nullable=False) 12 | description = Column(String(255), nullable=True) 13 | completed = Column(Boolean, default=False) 14 | created_at = Column(DateTime, nullable=False) 15 | updated_at = Column(DateTime, nullable=True) 16 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/api/controllers/todo/test_delete.py: -------------------------------------------------------------------------------- 1 | from tests.resources import AppTestBase 2 | 3 | 4 | class Test(AppTestBase): 5 | 6 | def test_delete(self): 7 | todo_service = self.app.container.todo_service() 8 | todo = todo_service.create({ 9 | "title": "test", 10 | "description": "test" 11 | }) 12 | self.assertEqual( 13 | 1, len(todo_service.get_all({'itemized': True})["items"]) 14 | ) 15 | response = self.client.delete(f'/v1/todo/{todo.id}') 16 | self.assertEqual(204, response.status_code) 17 | self.assertEqual( 18 | 0, len(todo_service.get_all({"itemized": True})["items"]) 19 | ) 20 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/domain/__init__.py: -------------------------------------------------------------------------------- 1 | from .constants import SQLALCHEMY_DATABASE_URI, LOG_LEVEL, \ 2 | DEFAULT_PER_PAGE_VALUE, DEFAULT_PAGE_VALUE, ITEMIZE, ITEMIZED, PAGE, \ 3 | PER_PAGE, SERVICE_PREFIX 4 | from .exceptions import OperationalException, ApiException, \ 5 | NoDataProvidedApiException, ClientException 6 | from .models import Todo 7 | 8 | 9 | __all__ = [ 10 | 'SQLALCHEMY_DATABASE_URI', 11 | 'LOG_LEVEL', 12 | 'OperationalException', 13 | 'ApiException', 14 | 'NoDataProvidedApiException', 15 | 'ClientException', 16 | 'DEFAULT_PER_PAGE_VALUE', 17 | 'DEFAULT_PAGE_VALUE', 18 | 'ITEMIZE', 19 | 'ITEMIZED', 20 | 'PAGE', 21 | 'PER_PAGE', 22 | 'SERVICE_PREFIX', 23 | 'Todo' 24 | ] 25 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/dependency_container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from src.infrastructure import SQLTodoRepository 4 | from src.services import TodoService 5 | 6 | 7 | def setup_dependency_container(app, modules=None, packages=None): 8 | container = DependencyContainer() 9 | app.container = container 10 | app.container.wire(modules=modules, packages=packages) 11 | return app 12 | 13 | 14 | class DependencyContainer(containers.DeclarativeContainer): 15 | config = providers.Configuration() 16 | wiring_config = containers.WiringConfiguration() 17 | todo_repository = providers.Factory(SQLTodoRepository) 18 | todo_service = providers.Factory( 19 | TodoService, repository=todo_repository, 20 | ) 21 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/domain/models/base_model.py: -------------------------------------------------------------------------------- 1 | class BaseModel: 2 | 3 | def repr(self, **fields) -> str: 4 | """ 5 | Helper for __repr__ 6 | """ 7 | 8 | field_strings = [] 9 | at_least_one_attached_attribute = False 10 | 11 | for key, field in fields.items(): 12 | field_strings.append(f'{key}={field!r}') 13 | at_least_one_attached_attribute = True 14 | 15 | if at_least_one_attached_attribute: 16 | return f"<{self.__class__.__name__}({','.join(field_strings)})>" 17 | 18 | return f"<{self.__class__.__name__} {id(self)}>" 19 | 20 | @staticmethod 21 | def from_dict(data): 22 | instance = BaseModel() 23 | instance.update(data) 24 | return instance 25 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/domain/models/todo.py: -------------------------------------------------------------------------------- 1 | from .base_model import BaseModel 2 | 3 | class Todo(BaseModel): 4 | 5 | def __init__( 6 | self, 7 | title, 8 | description, 9 | created_at=None, 10 | updated_at=None, 11 | completed=False 12 | ): 13 | self.title = title 14 | self.description = description 15 | self.completed = completed 16 | self.created_at = created_at 17 | self.updated_at = updated_at 18 | 19 | def __repr__(self): 20 | return self.repr( 21 | title=self.title, 22 | description=self.description, 23 | completed=self.completed, 24 | created_at=self.created_at, 25 | updated_at=self.updated_at 26 | ) 27 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/api/controllers/todo/test_get.py: -------------------------------------------------------------------------------- 1 | from tests.resources import AppTestBase 2 | 3 | 4 | class Test(AppTestBase): 5 | 6 | def test_get(self): 7 | todo_service = self.app.container.todo_service() 8 | todo = todo_service.create({ 9 | "title": "test", 10 | "description": "test" 11 | }) 12 | self.assertEqual( 13 | 1, len(todo_service.get_all({"itemized": True})["items"]) 14 | ) 15 | response = self.client.get(f'/v1/todo/{todo.id}') 16 | self.assertEqual(200, response.status_code) 17 | self.assertEqual(todo.id, response.json['id']) 18 | self.assertEqual(todo.title, response.json['title']) 19 | self.assertEqual(todo.description, response.json['description']) 20 | self.assertEqual(todo.completed, response.json['completed']) -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/infrastructure/databases/sql_alchemy.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | from src.domain import SQLALCHEMY_DATABASE_URI, OperationalException 4 | 5 | sqlalchemy_db = SQLAlchemy() 6 | 7 | 8 | class SQLAlchemyAdapter: 9 | 10 | def __init__(self, app): 11 | 12 | if app.config[SQLALCHEMY_DATABASE_URI] is not None: 13 | sqlalchemy_db.init_app(app) 14 | elif not app.config["TESTING"]: 15 | raise OperationalException( 16 | "SQLALCHEMY_DATABASE_URI not set in config, please make sure" + 17 | "SQLALCHEMY_DATABASE_URI is set in the config file or in the" + 18 | "environment variables" 19 | ) 20 | 21 | 22 | def setup_sqlalchemy(app, throw_exception_if_not_set=True): 23 | 24 | try: 25 | SQLAlchemyAdapter(app) 26 | except OperationalException as e: 27 | if throw_exception_if_not_set: 28 | raise e 29 | 30 | return app 31 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/services/repository_service.py: -------------------------------------------------------------------------------- 1 | class RepositoryService: 2 | 3 | def __init__(self, repository): 4 | self.repository = repository 5 | 6 | def create(self, data): 7 | return self.repository.create(data) 8 | 9 | def get(self, object_id): 10 | return self.repository.get(object_id) 11 | 12 | def get_all(self, query_params=None): 13 | return self.repository.get_all(query_params) 14 | 15 | def update(self, object_id, data): 16 | return self.repository.update(object_id, data) 17 | 18 | def delete(self, object_id): 19 | return self.repository.delete(object_id) 20 | 21 | def delete_all(self, query_params): 22 | return self.repository.delete_all(query_params) 23 | 24 | def find(self, query_params): 25 | return self.repository.find(query_params) 26 | 27 | def count(self, query_params=None): 28 | return self.repository.count(query_params) 29 | 30 | def exists(self, query_params): 31 | return self.repository.exists(query_params) 32 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/api/controllers/todo/test_patch.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from tests.resources import AppTestBase 4 | 5 | 6 | class Test(AppTestBase): 7 | 8 | def test_update_todo(self): 9 | todo_service = self.app.container.todo_service() 10 | todo = todo_service.create({ 11 | "title": "test", 12 | "description": "test" 13 | }) 14 | self.assertEqual( 15 | 1, len(todo_service.get_all({'itemized': True})["items"]) 16 | ) 17 | response = self.client.patch( 18 | f'/v1/todo/{todo.id}', 19 | data=json.dumps({"completed": True}), 20 | content_type='application/json' 21 | ) 22 | self.assertEqual(200, response.status_code) 23 | self.assertEqual(todo.id, response.json['id']) 24 | self.assertEqual(todo.title, response.json['title']) 25 | self.assertEqual(todo.description, response.json['description']) 26 | self.assertEqual(True, response.json['completed']) 27 | todo = todo_service.get(todo.id) 28 | self.assertEqual(True, todo.completed) 29 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/api/controllers/todo/test_post.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from tests.resources import AppTestBase 4 | 5 | 6 | class Test(AppTestBase): 7 | 8 | def test_create_todo(self): 9 | todo_service = self.app.container.todo_service() 10 | self.assertEqual( 11 | 0, len(todo_service.get_all({'itemized': True})["items"]) 12 | ) 13 | response = self.client.post( 14 | f'/v1/todo', 15 | data=json.dumps( 16 | { 17 | "title": "test", 18 | "description": "test" 19 | } 20 | ), 21 | content_type='application/json' 22 | ) 23 | self.assertEqual(201, response.status_code) 24 | todo = todo_service.get(response.json['id']) 25 | self.assertEqual(todo.id, response.json['id']) 26 | self.assertEqual(todo.title, response.json['title']) 27 | self.assertEqual(todo.description, response.json['description']) 28 | self.assertEqual(False, response.json['completed']) 29 | todo = todo_service.get(todo.id) 30 | self.assertEqual(False, todo.completed) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/domain/exceptions.py: -------------------------------------------------------------------------------- 1 | class ApiException(Exception): 2 | 3 | def __init__(self, message: str = None, status_code: int = 400): 4 | super(ApiException, self).__init__(message) 5 | self._message = message 6 | self._status_code = status_code 7 | 8 | @property 9 | def error_message(self): 10 | 11 | if self._message is None: 12 | return "An error occurred" 13 | 14 | return self._message 15 | 16 | @property 17 | def status_code(self): 18 | 19 | if self._status_code is None: 20 | return 500 21 | 22 | return self._status_code 23 | 24 | 25 | class OperationalException(Exception): 26 | 27 | def __init__(self, message: str = None): 28 | super(OperationalException, self).__init__(message) 29 | 30 | 31 | class NoDataProvidedApiException(ApiException): 32 | 33 | def __init__(self): 34 | super(NoDataProvidedApiException, self).__init__( 35 | message="No data provided", status_code=400 36 | ) 37 | 38 | 39 | class ClientException(Exception): 40 | 41 | def __init__(self, message: str = None): 42 | super(ClientException, self).__init__(message) 43 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from .domain import SQLALCHEMY_DATABASE_URI, LOG_LEVEL, SERVICE_PREFIX 5 | from dotenv import load_dotenv 6 | 7 | PROJECT_ROOT = str(Path(__file__).parent.parent) 8 | load_dotenv() 9 | 10 | 11 | class Config(object): 12 | LOG_LEVEL = os.environ.get(LOG_LEVEL, 'INFO') 13 | APP_DIR = os.path.abspath(os.path.dirname(__file__)) 14 | PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, os.pardir)) 15 | DEBUG_TB_INTERCEPT_REDIRECTS = False 16 | CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc. 17 | CSRF_ENABLED = True, 18 | CORS_ORIGIN_WHITELIST = [ 19 | 'http://0.0.0.0:4100', 20 | 'http://localhost:4100', 21 | 'http://0.0.0.0:8000', 22 | 'http://localhost:8000', 23 | 'http://0.0.0.0:4200', 24 | 'http://localhost:4200', 25 | 'http://0.0.0.0:4000', 26 | 'http://localhost:4000', 27 | ] 28 | SQLALCHEMY_TRACK_MODIFICATIONS = False 29 | SQLALCHEMY_DATABASE_URI = os.environ.get(SQLALCHEMY_DATABASE_URI) 30 | SERVICE_PREFIX = os.environ.get(SERVICE_PREFIX, '') 31 | 32 | def __setitem__(self, key, item): 33 | self.__dict__[key] = item 34 | 35 | def __getitem__(self, key): 36 | return self.__dict__[key] -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # TODO: The maintainer of this repo has not yet edited this file 2 | 3 | **REPO OWNER**: Do you want Customer Service & Support (CSS) support for this product/project? 4 | 5 | - **No CSS support:** Fill out this template with information about how to file issues and get help. 6 | - **Yes CSS support:** Fill out an intake form at [aka.ms/onboardsupport](https://aka.ms/onboardsupport). CSS will work with/help you to determine next steps. 7 | - **Not sure?** Fill out an intake as though the answer were "Yes". CSS will help you decide. 8 | 9 | *Then remove this first heading from this SUPPORT.MD file before publishing your repo.* 10 | 11 | # Support 12 | 13 | ## How to file issues and get help 14 | 15 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 16 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 17 | feature request as a new Issue. 18 | 19 | For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE 20 | FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER 21 | CHANNEL. WHERE WILL YOU HELP PEOPLE?**. 22 | 23 | ## Microsoft Support Policy 24 | 25 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above. 26 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/infrastructure/models/model_extension.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm.exc import DetachedInstanceError 2 | 3 | 4 | class SQLModelExtension: 5 | 6 | def update(self, db, data, commit=True, **kwargs): 7 | 8 | for attr, value in data.items(): 9 | setattr(self, attr, value) 10 | 11 | if commit: 12 | db.session.commit() 13 | 14 | def save(self, db, commit=True): 15 | db.session.add(self) 16 | 17 | if commit: 18 | db.session.commit() 19 | 20 | def delete(self, db, commit=True): 21 | db.session.delete(self) 22 | 23 | if commit: 24 | db.session.commit() 25 | 26 | def repr(self, **fields) -> str: 27 | """ 28 | Helper for __repr__ 29 | """ 30 | 31 | field_strings = [] 32 | at_least_one_attached_attribute = False 33 | 34 | for key, field in fields.items(): 35 | 36 | try: 37 | field_strings.append(f'{key}={field!r}') 38 | except DetachedInstanceError: 39 | field_strings.append(f'{key}=DetachedInstanceError') 40 | else: 41 | at_least_one_attached_attribute = True 42 | 43 | if at_least_one_attached_attribute: 44 | return f"<{self.__class__.__name__}({','.join(field_strings)})>" 45 | 46 | return f"<{self.__class__.__name__} {id(self)}>" 47 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/api/middleware.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from flask import request 4 | 5 | from src.domain import NoDataProvidedApiException, SERVICE_PREFIX 6 | 7 | 8 | def setup_prefix_middleware(app, prefix): 9 | 10 | if not app.config["TESTING"]: 11 | app.wsgi_app = PrefixMiddleware(app, prefix=prefix) 12 | 13 | return app 14 | 15 | 16 | class PrefixMiddleware(object): 17 | ROUTE_NOT_FOUND_MESSAGE = "This url does not belong to the app." 18 | 19 | def __init__(self, app, prefix=''): 20 | self.app = app 21 | self.wsgi_app = app.wsgi_app 22 | self.prefix = prefix 23 | 24 | def __call__(self, environ, start_response): 25 | 26 | if environ['PATH_INFO'].startswith(self.prefix): 27 | environ['PATH_INFO'] = environ['PATH_INFO'][len(self.prefix):] 28 | environ['SCRIPT_NAME'] = self.prefix 29 | return self.wsgi_app(environ, start_response) 30 | else: 31 | start_response('404', [('Content-Type', 'text/plain')]) 32 | return [self.ROUTE_NOT_FOUND_MESSAGE.encode()] 33 | 34 | 35 | def post_data_required(f): 36 | @wraps(f) 37 | def wrapped(*args, **kwargs): 38 | json_data = request.get_json() 39 | if json_data is None or json_data == {}: 40 | raise NoDataProvidedApiException() 41 | else: 42 | return f(json_data, *args, **kwargs) 43 | return wrapped 44 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/api/requests.py: -------------------------------------------------------------------------------- 1 | def normalize_query_param(value): 2 | """ 3 | Given a non-flattened query parameter value, 4 | and if the value is a list only containing 1 item, 5 | then the value is flattened. 6 | 7 | :param value: a value from a query parameter 8 | :return: a normalized query parameter value 9 | """ 10 | 11 | if len(value) == 1 and value[0].lower() in ["true", "false"]: 12 | 13 | if value[0].lower() == "true": 14 | return True 15 | return False 16 | 17 | return value if len(value) > 1 else value[0] 18 | 19 | 20 | def normalize_query(params): 21 | """ 22 | Converts query parameters from only containing one value for 23 | each parameter, to include parameters with multiple values as lists. 24 | 25 | :param params: a flask query parameters data structure 26 | :return: a dict of normalized query parameters 27 | """ 28 | params_non_flat = params.to_dict(flat=False) 29 | return {k: normalize_query_param(v) for k, v in params_non_flat.items()} 30 | 31 | 32 | def get_query_param(key, params, default=None, many=False): 33 | query_params = normalize_query(params) 34 | selection = query_params.get(key, default) 35 | 36 | if isinstance(selection, list) and len(selection) > 1 and not many: 37 | return selection[0] 38 | 39 | if many and not isinstance(selection, list): 40 | selection = [selection] 41 | 42 | return selection 43 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/create_app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | from src import api 4 | from src.api import setup_prefix_middleware, setup_blueprints 5 | from src.cors import setup_cors 6 | from src.dependency_container import setup_dependency_container 7 | from src.error_handler import setup_error_handler 8 | from src.infrastructure import setup_sqlalchemy 9 | from src.logging import setup_logging 10 | from src.domain import SERVICE_PREFIX 11 | from src.management import setup_management 12 | 13 | 14 | def create_app( 15 | config, 16 | dependency_container_packages=None, 17 | dependency_container_modules=None, 18 | setup_sqlalchemy=True 19 | ): 20 | app = Flask(__name__.split('.')[0]) 21 | app = setup_logging(app) 22 | app.config.from_object(config) 23 | app = setup_dependency_container(app) 24 | app.container.wire(packages=[api]) 25 | app = setup_cors(app) 26 | app.url_map.strict_slashes = False 27 | app = setup_prefix_middleware(app, prefix=app.config[SERVICE_PREFIX]) 28 | app = setup_blueprints(app) 29 | 30 | if setup_sqlalchemy: 31 | app = setup_sqlalchemy(app) 32 | 33 | app = setup_error_handler(app) 34 | app = setup_management(app) 35 | 36 | # Dependency injection container initialization should be done last 37 | app = setup_dependency_container( 38 | app, 39 | packages=dependency_container_packages, 40 | modules=dependency_container_modules 41 | ) 42 | return app 43 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/logging.py: -------------------------------------------------------------------------------- 1 | import logging.config 2 | 3 | from flask import request 4 | 5 | from src.domain import LOG_LEVEL 6 | 7 | 8 | def setup_logging(app): 9 | log_level = app.config.get(LOG_LEVEL) 10 | logging_config = { 11 | 'version': 1, 12 | 'disable_existing_loggers': False, 13 | 'formatters': { 14 | 'standard': { 15 | 'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s' 16 | }, 17 | }, 18 | 'handlers': { 19 | 'console': { 20 | 'level': log_level, 21 | 'formatter': 'standard', 22 | 'class': 'logging.StreamHandler', 23 | 'stream': 'ext://sys.stdout', # Default is stderr 24 | }, 25 | }, 26 | 'loggers': { 27 | '': { # root logger 28 | 'handlers': ['console'], 29 | 'level': log_level, 30 | 'propagate': False 31 | }, 32 | } 33 | } 34 | 35 | @app.after_request 36 | def after_request(response): 37 | """ Logging after every request. """ 38 | logger = logging.getLogger("app.access") 39 | logger.info( 40 | "%s %s %s %s %s", 41 | request.method, 42 | request.path, 43 | response.status, 44 | request.referrer, 45 | request.user_agent, 46 | ) 47 | return response 48 | 49 | logging.config.dictConfig(logging_config) 50 | return app 51 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/tests/resources/app_test_base.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from flask_testing import TestCase 4 | from testcontainers.postgres import PostgresContainer 5 | 6 | from src import api 7 | from src.config import Config 8 | from src.create_app import create_app 9 | from src.domain import SQLALCHEMY_DATABASE_URI 10 | 11 | from src.infrastructure import sqlalchemy_db as db, setup_sqlalchemy 12 | 13 | 14 | PROJECT_ROOT = str(Path(__file__).parent.parent.parent) 15 | 16 | 17 | class AppTestBase(TestCase): 18 | postgres_container = None 19 | 20 | def teardown_database(self): 21 | 22 | if self.postgres_container is not None: 23 | db.session.commit() 24 | db.session.close() 25 | self.postgres_container.stop() 26 | 27 | def initialize_database(self): 28 | 29 | with self.app.app_context(): 30 | db.drop_all() 31 | db.create_all() 32 | 33 | def create_app(self): 34 | self.postgres_container = PostgresContainer(image="postgres:14") 35 | self.postgres_container.start() 36 | 37 | config = Config() 38 | config["TESTING"] = True 39 | config[SQLALCHEMY_DATABASE_URI] = \ 40 | self.postgres_container.get_connection_url() 41 | 42 | self.app = create_app( 43 | config, 44 | dependency_container_packages=[api], 45 | setup_sqlalchemy=False 46 | ) 47 | setup_sqlalchemy(self.app) 48 | self.initialize_database() 49 | return self.app 50 | 51 | def tearDown(self) -> None: 52 | self.teardown_database() 53 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/api/controllers/todo.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from dependency_injector.wiring import inject, Provide 4 | from flask import Blueprint, request 5 | 6 | from src.api.middleware import post_data_required 7 | from src.api.responses import create_response 8 | from src.api.schemas import TodoSchema 9 | from src.dependency_container import DependencyContainer 10 | 11 | logger = logging.getLogger(__name__) 12 | blueprint = Blueprint('todo', __name__) 13 | 14 | 15 | @blueprint.route('/todo', methods=['POST']) 16 | @post_data_required 17 | @inject 18 | def create_todo( 19 | json_data, 20 | todo_service=Provide[DependencyContainer.todo_service] 21 | ): 22 | validated_data = TodoSchema().load(json_data) 23 | todo = todo_service.create(validated_data) 24 | return create_response(todo, TodoSchema, status_code=201) 25 | 26 | @blueprint.route('/todo/', methods=['PATCH']) 27 | @post_data_required 28 | @inject 29 | def update_todo( 30 | json_data, 31 | id, 32 | todo_service=Provide[DependencyContainer.todo_service] 33 | ): 34 | validated_data = TodoSchema().load(json_data) 35 | todo = todo_service.update(id, validated_data) 36 | return create_response(todo, TodoSchema) 37 | 38 | 39 | @blueprint.route('/todo', methods=['GET']) 40 | @inject 41 | def get_todo( 42 | todo_service=Provide[DependencyContainer.todo_service] 43 | ): 44 | query_params = request.args.to_dict() 45 | todos = todo_service.get_all(query_params) 46 | return create_response(todos, TodoSchema) 47 | 48 | 49 | @blueprint.route('/todo/', methods=['DELETE']) 50 | @inject 51 | def delete_todo( 52 | id, 53 | todo_service=Provide[DependencyContainer.todo_service] 54 | ): 55 | todo_service.delete(id) 56 | return create_response({}, TodoSchema, status_code=204) 57 | 58 | 59 | @blueprint.route('/todo/', methods=['GET']) 60 | @inject 61 | def retrieve_todo( 62 | id, 63 | todo_service=Provide[DependencyContainer.todo_service] 64 | ): 65 | todo = todo_service.get(id) 66 | return create_response(todo, TodoSchema) -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/error_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, List 3 | 4 | import marshmallow.exceptions as marshmallow_exceptions 5 | from flask import jsonify 6 | from werkzeug.exceptions import HTTPException 7 | 8 | from src.domain import ClientException, ApiException 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def setup_error_handler(app) -> None: 14 | """ 15 | Function that will register all the specified error handlers for the app 16 | """ 17 | 18 | def create_error_response(error_message, status_code: int = 400): 19 | 20 | # Remove the default 404 not found message if it exists 21 | if not isinstance(error_message, Dict): 22 | error_message = error_message.replace("404 Not Found: ", '') 23 | 24 | response = jsonify({"error_message": error_message}) 25 | response.status_code = status_code 26 | return response 27 | 28 | def format_marshmallow_validation_error(errors: Dict): 29 | errors_message = {} 30 | 31 | for key in errors: 32 | 33 | if isinstance(errors[key], Dict): 34 | errors_message[key] = \ 35 | format_marshmallow_validation_error(errors[key]) 36 | 37 | if isinstance(errors[key], List): 38 | errors_message[key] = errors[key][0].lower() 39 | return errors_message 40 | 41 | def error_handler(error): 42 | logger.error("exception of type {} occurred".format(type(error))) 43 | logger.exception(error) 44 | 45 | if isinstance(error, HTTPException): 46 | return create_error_response(str(error), error.code) 47 | elif isinstance(error, ClientException): 48 | return create_error_response( 49 | "Currently a dependent service is not available, " 50 | "please try again later", 503 51 | ) 52 | elif isinstance(error, ApiException): 53 | return create_error_response( 54 | error.error_message, error.status_code 55 | ) 56 | elif isinstance(error, marshmallow_exceptions.ValidationError): 57 | error_message = format_marshmallow_validation_error(error.messages) 58 | return create_error_response(error_message) 59 | else: 60 | # Internal error happened that was unknown 61 | return "Internal server error", 500 62 | 63 | app.errorhandler(Exception)(error_handler) 64 | return app 65 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | venv 3 | .venv 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # UV 102 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | #uv.lock 106 | 107 | # poetry 108 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 109 | # This is especially recommended for binary packages to ensure reproducibility, and is more 110 | # commonly ignored for libraries. 111 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 112 | #poetry.lock 113 | 114 | # pdm 115 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 116 | #pdm.lock 117 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 118 | # in version control. 119 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 120 | .pdm.toml 121 | .pdm-python 122 | .pdm-build/ 123 | 124 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 125 | __pypackages__/ 126 | 127 | # Celery stuff 128 | celerybeat-schedule 129 | celerybeat.pid 130 | 131 | # SageMath parsed files 132 | *.sage.py 133 | 134 | # Environments 135 | .env 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | .spyproject 146 | 147 | # Rope project settings 148 | .ropeproject 149 | 150 | # mkdocs documentation 151 | /site 152 | 153 | # mypy 154 | .mypy_cache/ 155 | .dmypy.json 156 | dmypy.json 157 | 158 | # Pyre type checker 159 | .pyre/ 160 | 161 | # pytype static type analyzer 162 | .pytype/ 163 | 164 | # Cython debug symbols 165 | cython_debug/ 166 | 167 | # PyCharm 168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 170 | # and can be added to the global gitignore or merged into this file. For a more nuclear 171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 172 | #.idea/ 173 | 174 | # Ruff stuff: 175 | .ruff_cache/ 176 | 177 | # PyPI configuration file 178 | .pypirc -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | .idea 3 | .venv 4 | .env 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # UV 103 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | #uv.lock 107 | 108 | # poetry 109 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 110 | # This is especially recommended for binary packages to ensure reproducibility, and is more 111 | # commonly ignored for libraries. 112 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 113 | #poetry.lock 114 | 115 | # pdm 116 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 117 | #pdm.lock 118 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 119 | # in version control. 120 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 121 | .pdm.toml 122 | .pdm-python 123 | .pdm-build/ 124 | 125 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 126 | __pypackages__/ 127 | 128 | # Celery stuff 129 | celerybeat-schedule 130 | celerybeat.pid 131 | 132 | # SageMath parsed files 133 | *.sage.py 134 | 135 | # Environments 136 | .env 137 | .venv 138 | env/ 139 | venv/ 140 | ENV/ 141 | env.bak/ 142 | venv.bak/ 143 | 144 | # Spyder project settings 145 | .spyderproject 146 | .spyproject 147 | 148 | # Rope project settings 149 | .ropeproject 150 | 151 | # mkdocs documentation 152 | /site 153 | 154 | # mypy 155 | .mypy_cache/ 156 | .dmypy.json 157 | dmypy.json 158 | 159 | # Pyre type checker 160 | .pyre/ 161 | 162 | # pytype static type analyzer 163 | .pytype/ 164 | 165 | # Cython debug symbols 166 | cython_debug/ 167 | 168 | # PyCharm 169 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 170 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 171 | # and can be added to the global gitignore or merged into this file. For a more nuclear 172 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 173 | #.idea/ 174 | 175 | # Ruff stuff: 176 | .ruff_cache/ 177 | 178 | # PyPI configuration file 179 | .pypirc -------------------------------------------------------------------------------- /docs/onion-architecture-article.md: -------------------------------------------------------------------------------- 1 | # Python onion architecture 2 | Onion architecture is a design pattern that organizes the codebase 3 | of a software application into multiple layers, where the innermost layer 4 | is the domain layer and the outermost layer is the application layer. 5 | Each layer depends only on the layers inside of it and not on the layers outside of it, 6 | creating a separation of concerns, allowing for a more maintainable and scalable codebase. 7 | The primary goal of this architecture is to make it easy 8 | to add new functionality or make changes to existing functionality 9 | without affecting the rest of the application. 10 | 11 | In this article, we will show how to implement an onion architecture 12 | with Python, SQLAlchemy. We will start by creating a new Rust project, 13 | and then add the necessary dependencies. Next, we will create the core 14 | business logic layer, which will be responsible for handling the business 15 | logic of the application. We will then create the web layer, which will handle 16 | the web-specific logic, such as handling requests and responses. Finally, we will 17 | create the database layer, which will handle the database-specific logic, 18 | such as inserting, updating, and querying data. 19 | 20 | ## Layers 21 | The onion architecture in this article consists of four layers: 22 | 23 | drawing 24 | 25 | ## Architecture 26 | The application follows the Onion Architecture pattern. 27 | This architecture is a design pattern that organizes the codebase 28 | of a software application into multiple layers, where the innermost layer 29 | is the domain layer and the outermost layer is the application layer. 30 | Each layer depends only on the layers inside of it and not on the layers outside of it, 31 | creating a separation of concerns, allowing for a more maintainable and scalable codebase. 32 | 33 | This layered architecture can be seen translated to an application in the following diagram: 34 | 35 | ``` 36 | . 37 | ├── migrations 38 | ├── scripts 39 | │ └── run_postgres.sh 40 | ├── src 41 | │ ├── api 42 | │ │ ├── controllers 43 | │ │ │ └── ... # controllers for the api 44 | │ │ ├── schemas 45 | │ │ │ └── ... # Marshmallow schemas 46 | │ │ ├── middleware.py 47 | │ │ ├── responses.py 48 | │ │ └── requests.py 49 | │ ├── infrastructure 50 | │ │ ├── services 51 | │ │ │ └── ... # Services that use third party libraries or services (e.g. email service) 52 | │ │ ├── databases 53 | │ │ │ └── ... # Database adapaters and initialization 54 | │ │ ├── repositories 55 | │ │ │ └── ... # Repositories for interacting with the databases 56 | │ │ └── models 57 | │ │ │ └── ... # Database models 58 | │ ├── domain 59 | │ │ ├── constants.py 60 | │ │ ├── exceptions.py 61 | │ │ ├── models 62 | │ │ │ └── ... # Business logic models 63 | │ ├── services 64 | │ │ └── ... # Services for interacting with the domain (business logic) 65 | │ ├── app.py 66 | │ ├── config.py 67 | │ ├── cors.py 68 | │ ├── create_app.py 69 | │ ├── dependency_container.py 70 | │ ├── error_handler.py 71 | │ └── logging.py 72 | ``` 73 | The application is structured with the following components: 74 | 75 | * api (app) module: The outermost layer that contains the controllers and the endpoints definition, serialization and deserialization of the data, validation and error handling. 76 | * infrastructure: Layer that typically include database connections, external APIs calls, logging and configuration management. 77 | * services: Layer that contains the application's services, which encapsulate the core business logic and provide a higher-level abstraction for the application to interact with the domain entities. 78 | * domain: The innermost layer that contains the core business logic and entities of the application. 79 | * migrations: Alembic's migration scripts are stored here. 80 | * scripts: contains the application's configuration settings. 81 | 82 | ## Domain Layer 83 | The domain layer is one of the main components of the onion architecture, it is 84 | responsible for representing the core business entities and their related business rules and logic. 85 | It is the core of the application and it should be independent of any external dependencies such as the 86 | database or the web framework. It's the most important layer as it defines the business entities and the 87 | behavior of the system. Typically, the domain layer contains classes and interfaces that define the business entities, 88 | their properties, and their behavior. It also contains classes and interfaces that define the business rules and logic 89 | that are used to manipulate these entities. It should be easy to test and shouldn't have any dependencies on external 90 | systems, this way the domain layer is able to evolve and change independently of the other layers. 91 | 92 | ## Services Layer 93 | The services layer is another key component of the onion architecture. It sits 94 | between the domain and the infrastructure layers and provides an interface for 95 | the domain layer to interact with the infrastructure. The services layer 96 | contains the application's business logic, where it coordinates the use of 97 | domain entities and defines the overall flow of the application. It implements 98 | use cases, which are the entry points to the application's functionality and it 99 | acts as a bridge between the domain and the infrastructure layers. The 100 | services layer is responsible for validating inputs, handling transactions, 101 | handling security, and triggering domain events. It should not contain any 102 | \logic related to the UI or the persistence, this way it can be reused in 103 | different contexts. Services should be easy to test and should have no dependencies 104 | on external systems, this way the services layer can evolve and change 105 | independently of the other layers. 106 | 107 | An example of a service is the following: 108 | 109 | 110 | ## Infrastructure Layer 111 | The infrastructure layer is the outermost layer of the onion architecture. 112 | It is responsible for interacting with external systems such as databases, 113 | file systems, and web services. It provides the necessary functionality for 114 | the services layer to interact with these external systems. The infrastructure 115 | layer contains classes and interfaces that define the communication with 116 | external systems and handle the persistence of data. It also includes 117 | libraries and frameworks such as ORM, REST or WebSockets clients, and other 118 | libraries that interact with external systems. The infrastructure layer 119 | should be independent of the domain and the services layers, this way it can 120 | evolve and change independently of the other layers. The infrastructure layer 121 | should be easy to test and should have no dependencies on the domain and services 122 | layers, this way changes in the infrastructure does not affect the domain 123 | and services layers. -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/src/infrastructure/repositories/repository.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC 3 | from typing import Callable 4 | 5 | from sqlalchemy.exc import SQLAlchemyError 6 | from werkzeug.datastructures import MultiDict 7 | 8 | from src.domain import ApiException, ITEMIZE, ITEMIZED, PAGE, PER_PAGE, \ 9 | DEFAULT_PAGE_VALUE, DEFAULT_PER_PAGE_VALUE 10 | from src.infrastructure import sqlalchemy_db as db 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Repository(ABC): 16 | base_class: Callable 17 | DEFAULT_NOT_FOUND_MESSAGE = "The requested resource was not found" 18 | DEFAULT_PER_PAGE = DEFAULT_PER_PAGE_VALUE 19 | DEFAULT_PAGE = DEFAULT_PAGE_VALUE 20 | 21 | def create(self, data): 22 | try: 23 | created_object = self.base_class(**data) 24 | db.session.add(created_object) 25 | db.session.commit() 26 | return created_object 27 | except SQLAlchemyError as e: 28 | logger.error(e) 29 | db.session.rollback() 30 | raise ApiException("Error creating object") 31 | 32 | def update(self, object_id, data): 33 | try: 34 | update_object = self.get(object_id) 35 | update_object.update(db=db, data=data) 36 | return update_object 37 | except SQLAlchemyError as e: 38 | logger.error(e) 39 | db.session.rollback() 40 | raise ApiException("Error updating object") 41 | 42 | def delete(self, object_id): 43 | try: 44 | delete_object = self.get(object_id) 45 | delete_object.delete(db) 46 | return delete_object 47 | except SQLAlchemyError as e: 48 | logger.error(e) 49 | db.session.rollback() 50 | raise ApiException("Error deleting object") 51 | 52 | def delete_all(self, query_params): 53 | 54 | if query_params is None: 55 | raise ApiException("No parameters are required") 56 | 57 | try: 58 | query_set = self.base_class.query 59 | query_set = self.apply_query_params(query_set, query_params) 60 | query_set.delete() 61 | db.session.commit() 62 | return query_set 63 | except SQLAlchemyError as e: 64 | logger.error(e) 65 | db.session.rollback() 66 | raise ApiException("Error deleting all objects") 67 | 68 | def get_all(self, query_params=None): 69 | try: 70 | query_set = self.base_class.query 71 | query_set = self.apply_query_params(query_set, query_params) 72 | 73 | if self.is_itemized(query_params): 74 | return self.create_itemization(query_set) 75 | 76 | return self.create_pagination(query_params, query_set) 77 | except SQLAlchemyError as e: 78 | logger.error(e) 79 | raise ApiException("Error getting all objects") 80 | 81 | def get(self, object_id): 82 | return self.base_class.query.filter_by(id=object_id) \ 83 | .first_or_404(self.DEFAULT_NOT_FOUND_MESSAGE) 84 | 85 | def _apply_query_params(self, query, query_params): 86 | return query 87 | 88 | def apply_query_params(self, query, query_params): 89 | 90 | if query_params is not None: 91 | query = self._apply_query_params(query, query_params) 92 | 93 | return query 94 | 95 | def exists(self, query_params): 96 | try: 97 | query = self.base_class.query 98 | query = self.apply_query_params(query, query_params) 99 | return query.first() is not None 100 | except SQLAlchemyError as e: 101 | logger.error(e) 102 | raise ApiException("Error checking if object exists") 103 | 104 | def find(self, query_params): 105 | try: 106 | query = self.base_class.query 107 | query = self.apply_query_params(query, query_params) 108 | return query.first_or_404(self.DEFAULT_NOT_FOUND_MESSAGE) 109 | except SQLAlchemyError as e: 110 | logger.error(e) 111 | raise ApiException("Error finding object") 112 | 113 | def count(self, query_params=None): 114 | try: 115 | query = self.base_class.query 116 | query = self.apply_query_params(query, query_params) 117 | return query.count() 118 | except SQLAlchemyError as e: 119 | logger.error(e) 120 | raise ApiException("Error counting objects") 121 | 122 | def normalize_query_param(self, value): 123 | """ 124 | Given a non-flattened query parameter value, 125 | and if the value is a list only containing 1 item, 126 | then the value is flattened. 127 | 128 | :param value: a value from a query parameter 129 | :return: a normalized query parameter value 130 | """ 131 | 132 | if len(value) == 1 and value[0].lower() in ["true", "false"]: 133 | 134 | if value[0].lower() == "true": 135 | return True 136 | return False 137 | 138 | return value if len(value) > 1 else value[0] 139 | 140 | def is_query_param_present(self, key, params, throw_exception=False): 141 | query_params = self.normalize_query(params) 142 | 143 | if key not in query_params: 144 | 145 | if not throw_exception: 146 | return False 147 | 148 | raise ApiException(f"{key} is not specified") 149 | else: 150 | return True 151 | 152 | def normalize_query(self, params): 153 | """ 154 | Converts query parameters from only containing one value for 155 | each parameter, to include parameters with multiple values as lists. 156 | 157 | :param params: a flask query parameters data structure 158 | :return: a dict of normalized query parameters 159 | """ 160 | if isinstance(params, MultiDict): 161 | params = params.to_dict(flat=False) 162 | 163 | return {k: self.normalize_query_param(v) for k, v in params.items()} 164 | 165 | def get_query_param(self, key: str, params, default=None, many=False): 166 | boolean_array = ["true", "false"] 167 | 168 | if params is None: 169 | return default 170 | 171 | if not isinstance(params, dict): 172 | params = self.normalize_query(params) 173 | 174 | selection = params.get(key, default) 175 | 176 | if not isinstance(selection, list): 177 | 178 | if selection is None: 179 | selection = [] 180 | else: 181 | selection = [selection] 182 | 183 | new_selection = [] 184 | 185 | for index, selected in enumerate(selection): 186 | 187 | if isinstance(selected, str) and selected.lower() in boolean_array: 188 | new_selection.append(selected.lower() == "true") 189 | else: 190 | new_selection.append(selected) 191 | 192 | if not many: 193 | 194 | if len(new_selection) == 0: 195 | return None 196 | 197 | return new_selection[0] 198 | 199 | return new_selection 200 | 201 | def is_itemized(self, query_params): 202 | 203 | if query_params is None: 204 | return False 205 | 206 | itemized = self.get_query_param(ITEMIZED, query_params, False) 207 | itemize = self.get_query_param(ITEMIZE, query_params, False) 208 | return itemized or itemize 209 | 210 | def create_pagination(self, query_params, query_set): 211 | page = self.get_query_param(PAGE, query_params, self.DEFAULT_PAGE) 212 | per_page = self.get_query_param( 213 | PER_PAGE, query_params, self.DEFAULT_PER_PAGE 214 | ) 215 | 216 | try: 217 | page = int(page) 218 | except ValueError: 219 | page = self.DEFAULT_PAGE 220 | 221 | try: 222 | per_page = int(per_page) 223 | except ValueError: 224 | per_page = self.DEFAULT_PER_PAGE 225 | 226 | paginated = query_set.paginate(page=int(page), per_page=int(per_page)) 227 | return { 228 | 'total': paginated.total, 229 | 'page': paginated.page, 230 | 'per_page': paginated.per_page, 231 | 'items': paginated.items, 232 | } 233 | 234 | def create_itemization(self, query_set): 235 | return { 236 | 'items': query_set.all() 237 | } 238 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cookiecutter flask clean architecture 2 | This is a reusable Python Flask template. The project is based on Flask in combination with SQLAlchemy ORM. 3 | 4 | Complete list of features the template provides: 5 | * [Onion architecture](#onion-architecture) 6 | * [Maintenance window support](#maintenance-window-support) 7 | * [SQLAlchemy ORM](#sqlalchemy-orm) 8 | * [Alembic Database migrations](#alembic-database-migrations) 9 | * [Local postgres database docker support](#local-postgres-database-docker-support) 10 | * [Tests and test containers integration](#tests-and-test-containers-integration) 11 | * [Service prefix](#service-prefix) 12 | * [Dependency injection](#dependency-injection) 13 | * [Service-repository design pattern](#service-repository-design-pattern) 14 | 15 | ## Getting started 16 | To start a new project, run the following command: 17 | ```bash 18 | cookiecutter https://github.com/microsoft/cookiecutter-python-flask-clean-architecture 19 | ``` 20 | This will prompt you for some information about your project. The information 21 | you provide will be used to populate the files in the new project directory. 22 | 23 | ### Running the application locally 24 | To run the application locally, you need to have a Postgres database running. 25 | You can use the `run_postgres.sh` script in the `scripts` directory to run a Postgres container. 26 | ```bash 27 | ./scripts/run_postgres.sh 28 | ``` 29 | You can then run the application with flask: 30 | ```bash 31 | flask --app src/app run 32 | ``` 33 | or with gunicorn: 34 | ```bash 35 | gunicorn wsgi:app -b 0.0.0.0:7000 --workers=1 --preload 36 | ``` 37 | 38 | ## Onion Architecture 39 | The application follows the Onion Architecture pattern. An article is written 40 | about our experience integrating an onion architecture with flask in combination with 41 | SQL Alchemy ORM that can be found [here](./docs/onion-architecture-article.md). 42 | 43 | This architecture is a design pattern that organizes the codebase of a software application into multiple layers, where the innermost layer 44 | is the domain layer and the outermost layer is the application layer. Each layer depends only on the layers inside of it and not on the layers outside of it, 45 | creating a separation of concerns, allowing for a more maintainable and scalable codebase. 46 | 47 | For this template we suggest using a service-repository design pattern. This template also provides 48 | a set of abc meta classes that you can use to create your repositories and services. 49 | For example implementations you can have a look at [Service-repository design pattern](#service-repository-design-pattern). 50 | 51 | ## Maintenance window support 52 | This template provides you with a maintenance window mode. To learn more about 53 | maintenance windows in your service you can read this article [here](https://devblogs.microsoft.com/cse/2023/02/08/maintenance_window_db_migrations/) 54 | 55 | During maintenance mode, clients will receive an http 503 status code. 56 | 57 | ### Activating maintenance mode 58 | You can activate maintenance mode in the following ways: 59 | ```bash 60 | curl -X PATCH http://localhost:7000//v1/service-context -d '{"maintenance": true}' -H 'Content-Type: application/json' 61 | ``` 62 | or via the command line: 63 | ```bash 64 | flask activate_maintenance_mode 65 | ``` 66 | 67 | ### Deactivating maintenance mode 68 | You can deactivate maintenance mode in the following ways: 69 | ```bash 70 | curl -X PATCH http://localhost:7000//v1/service-context -d '{"maintenance": false}' -H 'Content-Type: application/json' 71 | ``` 72 | or via the command line: 73 | ```bash 74 | flask deactivate_maintenance_mode 75 | ``` 76 | 77 | ## SQLAlchemy ORM 78 | The template uses SQLAlchemy ORM for its database connection and database models 79 | integration. Its is currently setup with postgres, however you can 80 | change it to any other database that is supported by SQLAlchemy. For other databases 81 | have a look at the official Flask SQLAlchemy documentation 82 | that can be found [here](https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/) 83 | 84 | This template provides you with a model base class that you can use to create your models. 85 | 86 | ```python 87 | from src.infrastructure.models.model_extension import ModelExtension 88 | 89 | class User(ModelExtension): 90 | __tablename__ = 'users' 91 | id = Column(Integer, primary_key=True) 92 | attribute_a = Column(String(50), nullable=False) 93 | attribute_b = Column(String(50), nullable=False) 94 | 95 | def __repr__(self): 96 | return self.repr(id=self.id, attribute_a=self.attribute_a, attribute_b=self.attribute_b) 97 | ``` 98 | 99 | ## Alembic database migrations 100 | > Note: The application uses a postgres database. Make sure you have a postgres 101 | > database running before running the following commands. For local development, 102 | > you can use the run_postgres.sh script to run a postgres container locally. 103 | 104 | 1) Make sure you have the diesel cli installed. You can install it with the following command: 105 | ```bash 106 | sh ./scripts/run_postgres.sh 107 | ``` 108 | 2) Create a database migration 109 | ```bash 110 | flask db migrate -m 111 | ``` 112 | 3) Apply the database migration: 113 | ```bash 114 | flask db upgrade 115 | ``` 116 | 117 | ## Local postgres database docker support 118 | You can run a local postgres docker database by using the following script: 119 | ```bash 120 | sh ./scripts/run_postgres.sh 121 | ``` 122 | 123 | This will run a postgres docker container on port 5432. Also it will create a 124 | .env file in the root directory of the project. This file contains the database 125 | connection string. The service will read this connection string from the .env file 126 | and use it to connect to the database. 127 | 128 | ## Tests and test containers integration 129 | All tests are can be found under the `tests` folder. When using the template 130 | you can place all you tests in this folder. 131 | 132 | The service uses [python unittest](https://docs.python.org/3/library/unittest.html) in combination 133 | with [flask testing] 134 | 135 | To run the tests, you can use the following command: 136 | ```bash 137 | python -m unittest discover -s tests 138 | ``` 139 | 140 | You can use the test containers library to run your tests against a postgres database. 141 | You do this by ```setup_database()``` in your test class. This will create a postgres container 142 | and run your tests against it. After the tests are done, the container will be destroyed. 143 | 144 | If you want to run your tests against a different database, you can change the 145 | setyp_database method in the test class to use a different database container. 146 | 147 | ```python 148 | class Test(AppTestBase): 149 | 150 | def setUp(self) -> None: 151 | super(Test, self).setUp() 152 | self.setup_database() 153 | ``` 154 | 155 | ## Service prefix 156 | The application can use a service prefix for the endpoints. 157 | The service prefix is defined in the config.py file. Given a service prefix 158 | of 'example', endpoints will be prefixed with '/example/v1/service-context'. 159 | 160 | ```python 161 | # config.py 162 | SERVICE_PREFIX = os.environ.get(SERVICE_PREFIX, '') 163 | 164 | # middleware 165 | class PrefixMiddleware(object): 166 | ROUTE_NOT_FOUND_MESSAGE = "This url does not belong to the app." 167 | 168 | def __init__(self, app, prefix=''): 169 | self.app = app 170 | self.wsgi_app = app.wsgi_app 171 | self.prefix = prefix 172 | 173 | def __call__(self, environ, start_response): 174 | 175 | if environ['PATH_INFO'].startswith(self.prefix): 176 | environ['PATH_INFO'] = environ['PATH_INFO'][len(self.prefix):] 177 | environ['SCRIPT_NAME'] = self.prefix 178 | return self.wsgi_app(environ, start_response) 179 | else: 180 | start_response('404', [('Content-Type', 'text/plain')]) 181 | return [self.ROUTE_NOT_FOUND_MESSAGE.encode()] 182 | ``` 183 | 184 | During testing the service prefix is not applied. This allows you 185 | to test the endpoints without having to add the service prefix to the 186 | endpoint. 187 | 188 | If the service prefix is not set, the service will not use a service prefix. 189 | 190 | 191 | ## Dependency injection 192 | This template uses the [dependency_injector](https://pypi.org/project/dependency-injector/) library 193 | for dependency injection. The template provides you with a container class that you can use to 194 | register your dependencies. The container class is located in the `src/dependency_container.py` file. 195 | 196 | You can add your dependencies to the container and use them 197 | in your routers, services and repositories. 198 | 199 | ## Service repository design pattern 200 | This template provides you with a repository-service pattern. There are two base classes 201 | that you can use to create your repositories and services. These base classes are located 202 | in the `src/services/repository_service.py` and `src/infrastructure/repositories/repository.py`. 203 | 204 | ### Repository example 205 | A repository example can be seen below, this repository is used to query the MyModel model. 206 | For custom query params support override the `_apply_query_params` method. 207 | 208 | ```python 209 | from infrastructure.repositories import Repository 210 | from infrastructure.models import MyModel 211 | 212 | class MyExampleRepository(Repository): 213 | base_class = MyModel 214 | DEFAULT_NOT_FOUND_MESSAGE = "MyModel was not found" 215 | 216 | def _apply_query_params(self, query, query_params): 217 | name_query_param = self.get_query_param("name", query_params) 218 | 219 | if name_query_param: 220 | query = query.filter_by(name=name_query_param) 221 | 222 | return query 223 | ``` 224 | 225 | ### Service example 226 | A Service example can be seen below, this service expect a repository to be injected in its contuctor. 227 | 228 | ```python 229 | from services.repository_service import RepositoryService 230 | 231 | class MyExampleService(RepositoryService): 232 | # The RepositoryService gives you access to crud repository operations by the inheritance 233 | # RepositoryService. 234 | pass 235 | 236 | # Can be instantiate by injecting the repository 237 | my_example_service = MyExampleService(my_example_repository) 238 | ``` 239 | -------------------------------------------------------------------------------- /{{cookiecutter.repo_name}}/README.md: -------------------------------------------------------------------------------- 1 | # Cookiecutter flask clean architecture 2 | This is a reusable Python Flask template. The project is based on Flask in combination with SQLAlchemy ORM. 3 | 4 | Complete list of features the template provides: 5 | * [Onion architecture](#onion-architecture) 6 | * [Maintenance window support](#maintenance-window-support) 7 | * [SQLAlchemy ORM](#sqlalchemy-orm) 8 | * [Alembic Database migrations](#alembic-database-migrations) 9 | * [Local postgres database docker support](#local-postgres-database-docker-support) 10 | * [Tests and test containers integration](#tests-and-test-containers-integration) 11 | * [Service prefix](#service-prefix) 12 | * [Dependency injection](#dependency-injection) 13 | * [Service-repository design pattern](#service-repository-design-pattern) 14 | 15 | ## Getting started 16 | To start a new project, run the following command: 17 | ```bash 18 | cookiecutter -c v1 https://github.com/microsoft/cookiecutter-python-flask-clean-architecture 19 | ``` 20 | This will prompt you for some information about your project. The information 21 | you provide will be used to populate the files in the new project directory. 22 | 23 | ### Running the application locally 24 | To run the application locally, you need to have a Postgres database running. 25 | You can use the `run_postgres.sh` script in the `scripts` directory to run a Postgres container. 26 | ```bash 27 | ./scripts/run_postgres.sh 28 | ``` 29 | You can then run the application with flask: 30 | ```bash 31 | flask --app src/app run 32 | ``` 33 | or with gunicorn: 34 | ```bash 35 | gunicorn wsgi:app -b 0.0.0.0:7000 --workers=1 --preload 36 | ``` 37 | 38 | ## Onion Architecture 39 | The application follows the Onion Architecture pattern. An article is written 40 | about our experience integrating an onion architecture with flask in combination with 41 | SQL Alchemy ORM that can be found [here](./docs/onion-architecture-article.md). 42 | 43 | This architecture is a design pattern that organizes the codebase of a software application into multiple layers, where the innermost layer 44 | is the domain layer and the outermost layer is the application layer. Each layer depends only on the layers inside of it and not on the layers outside of it, 45 | creating a separation of concerns, allowing for a more maintainable and scalable codebase. 46 | 47 | For this template we suggest using a service-repository design pattern. This template also provides 48 | a set of abc meta classes that you can use to create your repositories and services. 49 | For example implementations you can have a look at [Service-repository design pattern](#service-repository-design-pattern). 50 | 51 | ## Maintenance window support 52 | This template provides you with a maintenance window mode. To learn more about 53 | maintenance windows in your service you can read this article [here](https://devblogs.microsoft.com/cse/2023/02/08/maintenance_window_db_migrations/) 54 | 55 | During maintenance mode, clients will receive an http 503 status code. 56 | 57 | ### Activating maintenance mode 58 | You can activate maintenance mode in the following ways: 59 | ```bash 60 | curl -X PATCH http://localhost:7000//v1/service-context -d '{"maintenance": true}' -H 'Content-Type: application/json' 61 | ``` 62 | or via the command line: 63 | ```bash 64 | flask activate_maintenance_mode 65 | ``` 66 | 67 | ### Deactivating maintenance mode 68 | You can deactivate maintenance mode in the following ways: 69 | ```bash 70 | curl -X PATCH http://localhost:7000//v1/service-context -d '{"maintenance": false}' -H 'Content-Type: application/json' 71 | ``` 72 | or via the command line: 73 | ```bash 74 | flask deactivate_maintenance_mode 75 | ``` 76 | 77 | ## SQLAlchemy ORM 78 | The template uses SQLAlchemy ORM for its database connection and database models 79 | integration. Its is currently setup with postgres, however you can 80 | change it to any other database that is supported by SQLAlchemy. For other databases 81 | have a look at the official Flask SQLAlchemy documentation 82 | that can be found [here](https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/) 83 | 84 | This template provides you with a model base class that you can use to create your models. 85 | 86 | ```python 87 | from src.infrastructure.models.model_extension import ModelExtension 88 | 89 | class User(ModelExtension): 90 | __tablename__ = 'users' 91 | id = Column(Integer, primary_key=True) 92 | attribute_a = Column(String(50), nullable=False) 93 | attribute_b = Column(String(50), nullable=False) 94 | 95 | def __repr__(self): 96 | return self.repr(id=self.id, attribute_a=self.attribute_a, attribute_b=self.attribute_b) 97 | ``` 98 | 99 | ## Alembic database migrations 100 | > Note: The application uses a postgres database. Make sure you have a postgres 101 | > database running before running the following commands. For local development, 102 | > you can use the run_postgres.sh script to run a postgres container locally. 103 | 104 | 1) Make sure you have the diesel cli installed. You can install it with the following command: 105 | ```bash 106 | sh ./scripts/run_postgres.sh 107 | ``` 108 | 2) Create a database migration 109 | ```bash 110 | flask db migrate -m 111 | ``` 112 | 3) Apply the database migration: 113 | ```bash 114 | flask db upgrade 115 | ``` 116 | 117 | ## Local postgres database docker support 118 | You can run a local postgres docker database by using the following script: 119 | ```bash 120 | sh ./scripts/run_postgres.sh 121 | ``` 122 | 123 | This will run a postgres docker container on port 5432. Also it will create a 124 | .env file in the root directory of the project. This file contains the database 125 | connection string. The service will read this connection string from the .env file 126 | and use it to connect to the database. 127 | 128 | ## Tests and test containers integration 129 | All tests are can be found under the `tests` folder. When using the template 130 | you can place all you tests in this folder. 131 | 132 | The service uses [python unittest](https://docs.python.org/3/library/unittest.html) in combination 133 | with [flask testing] 134 | 135 | To run the tests, you can use the following command: 136 | ```bash 137 | python -m unittest discover -s tests 138 | ``` 139 | 140 | You can use the test containers library to run your tests against a postgres database. 141 | You do this by ```setup_database()``` in your test class. This will create a postgres container 142 | and run your tests against it. After the tests are done, the container will be destroyed. 143 | 144 | If you want to run your tests against a different database, you can change the 145 | setyp_database method in the test class to use a different database container. 146 | 147 | ```python 148 | class Test(AppTestBase): 149 | 150 | def setUp(self) -> None: 151 | super(Test, self).setUp() 152 | self.setup_database() 153 | ``` 154 | 155 | ## Service prefix 156 | The application can use a service prefix for the endpoints. 157 | The service prefix is defined in the config.py file. Given a service prefix 158 | of 'example', endpoints will be prefixed with '/example/v1/service-context'. 159 | 160 | ```python 161 | # config.py 162 | SERVICE_PREFIX = os.environ.get(SERVICE_PREFIX, '') 163 | 164 | # middleware 165 | class PrefixMiddleware(object): 166 | ROUTE_NOT_FOUND_MESSAGE = "This url does not belong to the app." 167 | 168 | def __init__(self, app, prefix=''): 169 | self.app = app 170 | self.wsgi_app = app.wsgi_app 171 | self.prefix = prefix 172 | 173 | def __call__(self, environ, start_response): 174 | 175 | if environ['PATH_INFO'].startswith(self.prefix): 176 | environ['PATH_INFO'] = environ['PATH_INFO'][len(self.prefix):] 177 | environ['SCRIPT_NAME'] = self.prefix 178 | return self.wsgi_app(environ, start_response) 179 | else: 180 | start_response('404', [('Content-Type', 'text/plain')]) 181 | return [self.ROUTE_NOT_FOUND_MESSAGE.encode()] 182 | ``` 183 | 184 | During testing the service prefix is not applied. This allows you 185 | to test the endpoints without having to add the service prefix to the 186 | endpoint. 187 | 188 | If the service prefix is not set, the service will not use a service prefix. 189 | 190 | 191 | ## Dependency injection 192 | This template uses the [dependency_injector](https://pypi.org/project/dependency-injector/) library 193 | for dependency injection. The template provides you with a container class that you can use to 194 | register your dependencies. The container class is located in the `src/dependency_container.py` file. 195 | 196 | You can add your dependencies to the container and use them 197 | in your routers, services and repositories. 198 | 199 | ## Service repository design pattern 200 | This template provides you with a repository-service pattern. There are two base classes 201 | that you can use to create your repositories and services. These base classes are located 202 | in the `src/services/repository_service.py` and `src/infrastructure/repositories/repository.py`. 203 | 204 | ### Repository example 205 | A repository example can be seen below, this repository is used to query the MyModel model. 206 | For custom query params support override the `_apply_query_params` method. 207 | 208 | ```python 209 | from infrastructure.repositories import Repository 210 | from infrastructure.models import MyModel 211 | 212 | class MyExampleRepository(Repository): 213 | base_class = MyModel 214 | DEFAULT_NOT_FOUND_MESSAGE = "MyModel was not found" 215 | 216 | def _apply_query_params(self, query, query_params): 217 | name_query_param = self.get_query_param("name", query_params) 218 | 219 | if name_query_param: 220 | query = query.filter_by(name=name_query_param) 221 | 222 | return query 223 | ``` 224 | 225 | ### Service example 226 | A Service example can be seen below, this service expect a repository to be injected in its contuctor. 227 | 228 | ```python 229 | from services.repository_service import RepositoryService 230 | 231 | class MyExampleService(RepositoryService): 232 | # The RepositoryService gives you access to crud repository operations by the inheritance 233 | # RepositoryService. 234 | pass 235 | 236 | # Can be instantiate by injecting the repository 237 | my_example_service = MyExampleService(my_example_repository) 238 | ``` 239 | -------------------------------------------------------------------------------- /docs/maintenance_window_database_migration_strategy.md: -------------------------------------------------------------------------------- 1 | # Maintenance window strategy for database migrations 2 | Database migrations can be a tricky task for any organization, especially when 3 | it comes to maintaining the availability of the system during the migration process. 4 | One way to mitigate this risk is to implement a maintenance window strategy. 5 | 6 | In a maintenance window, the application is taken offline for maintenance or updates. 7 | This approach enables IT teams to perform necessary updates or migrations 8 | without compromising the system's availability for end users or risking a 9 | corrupt database. 10 | 11 | As can be seen below in the diagram, the maintenance window strategy is 12 | implemented by taking the system offline during the maintenance window and 13 | performing the database migration. Once the migration is complete, the system 14 | is brought back online. During the maintenance window, the system is not 15 | available for end users and any requests made to the system receive a 503 16 | http status code. 17 | 18 | drawing 19 | 20 | The criteria for implementing a maintenance window strategy for database 21 | migrations are: 22 | 23 | * Your database does not have migration tools that can be used to perform migrations 24 | without taking the database offline. 25 | * Your database is not hosted on a cloud provider that provides a managed database 26 | service that can be used to perform migrations without taking the database offline. 27 | * You're using a single database for your application and not a set of distributed database. 28 | 29 | ## Real-world example 30 | For a real-world example, we will look at a maintenance window strategy implementation for 31 | a FLASK application with a SQL based database. Given that the application uses SQLAlchemy and alembic for 32 | database migrations we will have the following components: 33 | 34 | ### Service context database model 35 | The service context database model is a database model that is used to store the 36 | current state of the service. This model is used to determine if the service is 37 | in maintenance mode or not. The model is defined as follows: 38 | 39 | > Note: The service context database model can also be extended to store other 40 | > information about the service such as the current version of the service. 41 | 42 | ```python 43 | from src.infrastructure.databases import sqlalchemy_db as db 44 | from src.infrastructure.models.model_extension import ModelExtension 45 | 46 | 47 | class ServiceContext(db.Model, ModelExtension): 48 | __tablename__ = 'service_context' 49 | id = db.Column(db.Integer, primary_key=True) 50 | maintenance = db.Column(db.Boolean, default=False) 51 | ``` 52 | 53 | We use a database model to store the current state of the service because it allows 54 | us to store the state of the service persistently. This means that if the 55 | service is restarted, the state of the service will be restored. Also, if the service 56 | is running on multiple instances, the state of the service will be consistent across 57 | all instances. 58 | 59 | ### Service context service 60 | The service context service is a service that is used to interact with the service 61 | context database model. The service context service is defined as follows: 62 | 63 | ```python 64 | from src.infrastructure.models import ServiceContext 65 | from src.infrastructure.databases import sqlalchemy_db as db 66 | 67 | 68 | class ServiceContextService: 69 | 70 | def update(self, data): 71 | service_context = ServiceContext.query.first() 72 | 73 | if service_context is None: 74 | service_context = ServiceContext() 75 | 76 | service_context.update(db, data) 77 | return service_context 78 | 79 | def get_service_context(self): 80 | status = ServiceContext.query.first() 81 | 82 | if status is None: 83 | status = ServiceContext() 84 | status.save(db) 85 | 86 | return status 87 | ``` 88 | 89 | ### Maintenance mode activation and deactivation 90 | To activate of deactivate the maintenance mode, we can use a route as shown below: 91 | ```python 92 | @blueprint.route('/service-context', methods=['PATCH']) 93 | @post_data_required 94 | @inject 95 | def update_service_context( 96 | json_data, 97 | service_context_service=Provide[ 98 | DependencyContainer.service_context_service 99 | ] 100 | ): 101 | service_context = service_context_service.update(json_data) 102 | return create_response(service_context, ServiceContextSchema) 103 | ``` 104 | When creating this public route, make sure that you have a way to authenticate the user 105 | that makes the request in order to determine that the user has the necessary permissions. 106 | 107 | You can also create a management command with [Flask script]("https://flask-script.readthedocs.io/en/latest/") to 108 | activate the maintenance mode from within the application: 109 | 110 | ```python 111 | @manager.command 112 | def deactivate_maintenance_mode(): 113 | service_context_service = app.container.service_context_service() 114 | service_context_service.update({"maintenance": True}) 115 | logger.info("Maintenance mode activated") 116 | 117 | 118 | @manager.command 119 | def deactivate_maintenance_mode(): 120 | service_context_service = app.container.service_context_service() 121 | service_context_service.update({"maintenance": False}) 122 | logger.info("Maintenance mode deactivated") 123 | ``` 124 | 125 | This allows you to activate or deactivate the maintenance mode from the command line if 126 | you have access to the server where the application is running. 127 | 128 | ### Maintenance mode check 129 | We're using the before_request decorator to run the maintenance mode check before 130 | each request. The maintenance mode check is defined as follows: 131 | 132 | ```python 133 | @app.before_request 134 | def check_for_maintenance(): 135 | service_context_service = app.container.service_context_service() 136 | status = service_context_service.get_status() 137 | 138 | if not ("maintenance" in request.path or "status" in request.path): 139 | if status.maintenance: 140 | return jsonify( 141 | {"message": "Service is currently enduring maintenance"} 142 | ), 503 143 | ``` 144 | 145 | With this implementation, the service will return a 503 http status code for all 146 | requests that are not made to the maintenance mode activation, deactivation 147 | or status routes. 148 | 149 | ## Applying migrations during maintenance window 150 | You have several ways to apply migrations during a maintenance window. 151 | 152 | One way is to do the migration manually by running the migration commands from 153 | within the application. This is the simplest way to apply migrations and gives 154 | you the most control over the migration process. 155 | 156 | ### Manual migrations 157 | An example of applying the migration manually is shown below in a kubernetes cluster: 158 | 159 | 1. Access a pod that has access to a database and has the maintenance window strategy implemented 160 | ```bash 161 | kubectl exec -it -- /bin/bash 162 | ``` 163 | 164 | 2. Activate the maintenance mode 165 | ```bash 166 | python manage.py activate_maintenance_mode 167 | ``` 168 | 169 | 3. Apply the migration 170 | ```bash 171 | python manage.py db upgrade 172 | ``` 173 | 174 | 4. Deactivate the maintenance mode 175 | ```bash 176 | python manage.py deactivate_maintenance_mode 177 | ``` 178 | 179 | ### Pipeline based migration 180 | This is a more advanced way to apply migrations during a maintenance window. 181 | The pipeline below is an azure devops pipeline and an example of an azure 182 | app service deployment where a maintenance window is used to apply the migration. 183 | 184 | The pipeline is defined as follows: 185 | ```bash 186 | trigger: 187 | paths: 188 | include: 189 | - /migrations/* 190 | 191 | variables: 192 | production_web_app_service_url: "https://" 193 | staging_web_app_service_url: "https://" 194 | projectRoot: $CI_PROJECT_DIR 195 | vmImageName: ubuntu-latest 196 | pythonVersion: 3.8 197 | isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')] 198 | 199 | stages: 200 | - stage: "deploy_stable" 201 | condition: and(always(), eq(variables['isMain'], True)) 202 | jobs: 203 | - job: "build_web_app" 204 | displayName: "Build web app" 205 | pool: 206 | vmImage: $(vmImageName) 207 | steps: 208 | - task: UsePythonVersion@0 209 | inputs: 210 | versionSpec: '$(pythonVersion)' 211 | - script: | 212 | python -m venv venv 213 | source venv/bin/activate 214 | python -m pip install --upgrade pip 215 | pip install setup 216 | pip install --target="./.python_packages/lib/site-packages" -r ./requirements.txt 217 | workingDirectory: $(projectRoot) 218 | displayName: "Install requirements" 219 | - task: ArchiveFiles@2 220 | inputs: 221 | rootFolderOrFile: '$(Build.SourcesDirectory)' 222 | includeRootFolder: false 223 | archiveType: 'zip' 224 | archiveFile: '$(Build.ArtifactStagingDirectory)/Application$(Build.BuildId).zip' 225 | replaceExistingArchive: true 226 | - publish: $(Build.ArtifactStagingDirectory)/Application$(Build.BuildId).zip 227 | displayName: 'Upload package' 228 | artifact: drop 229 | - script: | 230 | curl -X PATCH -H "Content-Type: application/json" -d '{"maintenance": true}' $(production_web_app_service_url)/service-context 231 | name: activate_maintenance_mode_production 232 | displayName: Activate maintenance mode on production 233 | - task: AzureWebApp@1 234 | displayName: "Deploy web app to staging" 235 | inputs: 236 | azureSubscription: '' 237 | appType: webAppLinux 238 | appName: '' 239 | deployToSlotOrASE: true 240 | resourceGroupName: '' 241 | slotName: staging 242 | - script: | 243 | curl -X PATCH -H "Content-Type: application/json" -d '{"maintenance": true}' $(staging_web_app_service_url)/service-context 244 | name: activate_maintenance_mode_staging 245 | displayName: Activate maintenance mode on staging 246 | - task: AzureKeyVault@2 247 | displayName: Load key vault db connection string 248 | inputs: 249 | connectedServiceName: azure-keyvault-connection 250 | keyVaultName: $(keyVaultName) 251 | secretsFilter: '*' 252 | - script: | 253 | > .env 254 | echo SQLALCHEMY_DATABASE_URI="$(SQLALCHEMEMY_DATABASE_URI)" >> .env 255 | name: setup_env_file 256 | displayName: Setup env file 257 | - script: | 258 | python manage.py db upgrade 259 | name: apply_migrations 260 | displayName: Apply migrations 261 | - task: AzureAppServiceManage@0 262 | displayName: 'Swap staging and production slots' 263 | inputs: 264 | azureSubscription: '' 265 | appType: webAppLinux 266 | WebAppName: '' 267 | ResourceGroupName: '' 268 | SourceSlot: staging 269 | SwapWithProduction: true 270 | - script: | 271 | curl -X PATCH -H "Content-Type: application/json" -d '{"maintenance": false}' $(production_web_app_service_url)/service-context 272 | name: deactivate_maintenance_mode_production 273 | displayName: Deactivate maintenance mode on production 274 | ``` 275 | 276 | The pipeline is triggered when a migration is pushed to the main branch. 277 | The pipeline will then build the application, 278 | set the production and staging app in maintenance mode, apply the migration and 279 | swap the production and staging slots. 280 | 281 | ## Closing remarks 282 | When planning a maintenance window for a database migration, it's important to 283 | consider the following: 284 | 285 | 1) Identify the right time: Choose a time that has the least impact on end users, such as during off-peak hours or weekends. 286 | 287 | 2) Communicate with stakeholders: Notify all stakeholders, including customers and internal teams, of the planned maintenance window well in advance. This will give them ample time to plan and prepare for the interruption in service. 288 | 289 | 3) Test the migration process: Before the actual migration, test the process in a non-production environment to ensure it runs smoothly. This will also help identify any potential issues that need to be addressed. 290 | 291 | 4) Have a rollback plan: In case something goes wrong during the migration, it's important to have a rollback plan in place to quickly restore the previous version of the database. 292 | 293 | 5) Monitor the migration: Continuously monitor the migration process to ensure it's running smoothly and to quickly address any issues that may arise. 294 | 295 | 6) By implementing a maintenance window strategy, organizations can minimize the impact of database migrations on end users and ensure the availability of the system during the migration process. 296 | 297 | It's also important to note that some migrations such as those of very large databases or 298 | those that require a lot of data transformation may require more than one maintenance window. 299 | In such scenarios it is important to plan and test the migration in chunks, allowing for a more 300 | gradual and controlled migration process. 301 | 302 | Overall, a maintenance window strategy can be a useful tool for organizations looking to perform database migrations 303 | while minimizing disruption to end users. With careful planning, testing and monitoring, IT teams can ensure a 304 | smooth and successful migration process. 305 | --------------------------------------------------------------------------------