├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── TODO.md ├── babel.cfg ├── docs ├── Makefile ├── _static │ └── theme_customizations.css ├── _templates │ ├── autosummary │ │ └── class.rst │ ├── globaltoc.html │ ├── header.html │ ├── hero.html │ └── localtoc.html ├── api │ ├── admin-bundle.rst │ ├── api-bundle.rst │ ├── babel-bundle.rst │ ├── celery-bundle.rst │ ├── controller-bundle.rst │ ├── flask-unchained.rst │ ├── graphene-bundle.rst │ ├── index.rst │ ├── mail-bundle.rst │ ├── oauth-bundle.rst │ ├── security-bundle.rst │ ├── session-bundle.rst │ ├── sqlalchemy-bundle.rst │ └── webpack-bundle.rst ├── bundles │ ├── admin.rst │ ├── api.rst │ ├── babel.rst │ ├── celery.rst │ ├── controller.rst │ ├── graphene.rst │ ├── index.rst │ ├── mail.rst │ ├── oauth.rst │ ├── security.rst │ ├── session.rst │ ├── sqlalchemy.rst │ └── webpack.rst ├── changelog.rst ├── commands.rst ├── conf.py ├── docutils.conf ├── how-flask-unchained-works.rst ├── index.rst ├── new-tutorial.bak ├── table-of-contents.rst ├── testing.rst └── tutorial │ ├── 01_getting_started.rst │ ├── 02_views_templates_and_static_assets.rst │ ├── 03_db.rst │ ├── 04_session.rst │ ├── 05_security.rst │ ├── 06_project_layout.rst │ ├── 07_modeling_authors_quotes_themes.rst │ ├── 08_model_forms_and_views.rst │ ├── 09_admin_interface.rst │ └── index.rst ├── flask_mail.py ├── flask_unchained ├── __init__.py ├── _code_templates │ └── project │ │ ├── .gitignore │ │ ├── README.md │ │ ├── __init__.py │ │ ├── app │ │ ├── __init__.py │ │ ├── config.py │ │ ├── extensions │ │ │ └── __init__.py │ │ ├── graphql │ │ │ ├── __init__.py │ │ │ ├── mutations.py │ │ │ ├── schema.py │ │ │ └── types.py │ │ ├── managers │ │ │ └── __init__.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── role.py │ │ │ └── user.py │ │ ├── routes.py │ │ ├── serializers │ │ │ └── __init__.py │ │ ├── services │ │ │ └── __init__.py │ │ ├── tasks │ │ │ └── __init__.py │ │ ├── templates │ │ │ └── site │ │ │ │ └── index.html │ │ └── views │ │ │ ├── __init__.py │ │ │ └── site_controller.py │ │ ├── assets │ │ ├── scripts │ │ │ └── app │ │ │ │ └── index.js │ │ └── styles │ │ │ └── app │ │ │ └── main.scss │ │ ├── celery_app.py │ │ ├── db │ │ └── fixtures │ │ │ ├── Role.yaml │ │ │ └── User.yaml │ │ ├── package.json │ │ ├── requirements-dev.txt │ │ ├── requirements.txt │ │ ├── static │ │ └── vendor │ │ │ ├── bootstrap-v4.1.2.min.css │ │ │ ├── bootstrap-v4.1.2.min.js │ │ │ ├── jquery-v3.3.1.slim.min.js │ │ │ └── popper-v1.14.3.min.js │ │ ├── templates │ │ ├── _flashes.html │ │ ├── email │ │ │ └── layout.html │ │ └── layout.html │ │ ├── tests │ │ ├── __init__.py │ │ ├── app │ │ │ ├── __init__.py │ │ │ └── views │ │ │ │ ├── __init__.py │ │ │ │ └── test_site_controller.py │ │ └── conftest.py │ │ ├── unchained_config.py │ │ ├── webpack │ │ ├── webpack.base.config.js │ │ └── webpack.config.js │ │ └── wsgi.py ├── _compat.py ├── app_factory.py ├── app_factory_hook.py ├── bundles │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── config.py │ │ ├── extensions │ │ │ ├── __init__.py │ │ │ └── admin.py │ │ ├── forms.py │ │ ├── hooks │ │ │ ├── __init__.py │ │ │ └── register_model_admins_hook.py │ │ ├── macro.py │ │ ├── model_admin.py │ │ ├── routes.py │ │ ├── security.py │ │ ├── static │ │ │ └── admin.css │ │ ├── templates │ │ │ ├── __init__.py │ │ │ └── admin │ │ │ │ ├── _macros.html │ │ │ │ ├── column_formatters.html │ │ │ │ ├── dashboard.html │ │ │ │ ├── layout.html │ │ │ │ ├── login.html │ │ │ │ ├── master.html │ │ │ │ └── model │ │ │ │ ├── create.html │ │ │ │ ├── details.html │ │ │ │ ├── edit.html │ │ │ │ ├── list.html │ │ │ │ └── row_actions.html │ │ └── views │ │ │ ├── __init__.py │ │ │ ├── admin_security_controller.py │ │ │ └── dashboard.py │ ├── api │ │ ├── __init__.py │ │ ├── config.py │ │ ├── decorators.py │ │ ├── extensions │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── marshmallow.py │ │ ├── hooks │ │ │ ├── __init__.py │ │ │ ├── register_model_resources_hook.py │ │ │ └── register_model_serializers_hook.py │ │ ├── model_resource.py │ │ ├── model_serializer.py │ │ ├── templates │ │ │ └── open_api │ │ │ │ └── redoc.html │ │ ├── utils.py │ │ └── views.py │ ├── babel │ │ ├── __init__.py │ │ ├── commands.py │ │ ├── config.py │ │ └── extensions.py │ ├── celery │ │ ├── __init__.py │ │ ├── commands.py │ │ ├── config.py │ │ ├── extensions │ │ │ ├── __init__.py │ │ │ └── celery.py │ │ ├── hooks │ │ │ ├── __init__.py │ │ │ └── discover_tasks_hook.py │ │ └── tasks.py │ ├── controller │ │ ├── __init__.py │ │ ├── attr_constants.py │ │ ├── bundle_blueprint.py │ │ ├── config.py │ │ ├── constants.py │ │ ├── controller.py │ │ ├── decorators.py │ │ ├── extensions.py │ │ ├── hooks │ │ │ ├── __init__.py │ │ │ ├── register_blueprints_hook.py │ │ │ ├── register_bundle_blueprints_hook.py │ │ │ └── register_routes_hook.py │ │ ├── resource.py │ │ ├── route.py │ │ ├── routes.py │ │ ├── templates.py │ │ └── utils.py │ ├── graphene │ │ ├── __init__.py │ │ ├── commands.py │ │ ├── config.py │ │ ├── exceptions.py │ │ ├── hooks │ │ │ ├── __init__.py │ │ │ ├── register_graphene_mutations_hook.py │ │ │ ├── register_graphene_queries_hook.py │ │ │ ├── register_graphene_root_schema_hook.py │ │ │ └── register_graphene_types_hook.py │ │ ├── object_types.py │ │ └── pytest.py │ ├── mail │ │ ├── __init__.py │ │ ├── commands.py │ │ ├── config.py │ │ ├── extensions │ │ │ ├── __init__.py │ │ │ └── mail.py │ │ ├── pytest.py │ │ ├── templates │ │ │ └── email │ │ │ │ └── __test_email__.html │ │ └── utils.py │ ├── oauth │ │ ├── __init__.py │ │ ├── config.py │ │ ├── extensions │ │ │ ├── __init__.py │ │ │ └── oauth.py │ │ ├── routes.py │ │ ├── services.py │ │ └── views │ │ │ ├── __init__.py │ │ │ └── oauth_controller.py │ ├── security │ │ ├── __init__.py │ │ ├── admins │ │ │ ├── __init__.py │ │ │ ├── role_admin.py │ │ │ └── user_admin.py │ │ ├── babel.cfg │ │ ├── commands │ │ │ ├── __init__.py │ │ │ ├── roles.py │ │ │ ├── users.py │ │ │ └── utils.py │ │ ├── config.py │ │ ├── decorators │ │ │ ├── __init__.py │ │ │ ├── anonymous_user_required.py │ │ │ ├── auth_required.py │ │ │ ├── auth_required_same_user.py │ │ │ ├── roles_accepted.py │ │ │ └── roles_required.py │ │ ├── exceptions.py │ │ ├── extensions │ │ │ ├── __init__.py │ │ │ └── security.py │ │ ├── forms.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── anonymous_user.py │ │ │ ├── role.py │ │ │ ├── user.py │ │ │ └── user_role.py │ │ ├── pytest.py │ │ ├── routes.py │ │ ├── serializers │ │ │ ├── __init__.py │ │ │ ├── role_serializer.py │ │ │ └── user_serializer.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ ├── role_manager.py │ │ │ ├── security_service.py │ │ │ ├── security_utils_service.py │ │ │ └── user_manager.py │ │ ├── signals.py │ │ ├── templates │ │ │ └── security │ │ │ │ ├── _macros.html │ │ │ │ ├── change_password.html │ │ │ │ ├── email │ │ │ │ ├── email_confirmation_instructions.html │ │ │ │ ├── password_changed_notice.html │ │ │ │ ├── password_reset_notice.html │ │ │ │ ├── reset_password_instructions.html │ │ │ │ └── welcome.html │ │ │ │ ├── forgot_password.html │ │ │ │ ├── layout.html │ │ │ │ ├── login.html │ │ │ │ ├── register.html │ │ │ │ ├── reset_password.html │ │ │ │ └── send_confirmation_email.html │ │ ├── translations │ │ │ ├── en │ │ │ │ └── LC_MESSAGES │ │ │ │ │ ├── flask_unchained.bundles.security.mo │ │ │ │ │ └── flask_unchained.bundles.security.po │ │ │ └── flask_unchained.bundles.security.pot │ │ ├── unchained_config.py │ │ ├── utils.py │ │ ├── validators.py │ │ └── views │ │ │ ├── __init__.py │ │ │ ├── security_controller.py │ │ │ └── user_resource.py │ ├── session │ │ ├── __init__.py │ │ ├── config.py │ │ ├── extensions │ │ │ ├── __init__.py │ │ │ └── session.py │ │ ├── hooks │ │ │ ├── __init__.py │ │ │ └── register_session_model_hook.py │ │ └── session_interfaces │ │ │ ├── __init__.py │ │ │ └── sqla.py │ ├── sqlalchemy │ │ ├── __init__.py │ │ ├── alembic │ │ │ ├── __init__.py │ │ │ ├── migrations.py │ │ │ ├── reversible_op.py │ │ │ └── templates │ │ │ │ └── flask │ │ │ │ ├── README │ │ │ │ ├── alembic.ini.mako │ │ │ │ ├── env.py │ │ │ │ └── script.py.mako │ │ ├── base_model.py │ │ ├── commands.py │ │ ├── config.py │ │ ├── extensions │ │ │ ├── __init__.py │ │ │ ├── migrate.py │ │ │ └── sqlalchemy_unchained.py │ │ ├── forms.py │ │ ├── hooks │ │ │ ├── __init__.py │ │ │ └── register_models_hook.py │ │ ├── meta_options.py │ │ ├── model_factory.py │ │ ├── model_registry.py │ │ ├── pytest.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ ├── model_manager.py │ │ │ └── session_manager.py │ │ └── sqla │ │ │ ├── __init__.py │ │ │ ├── column.py │ │ │ ├── events.py │ │ │ ├── foreign_key.py │ │ │ └── types.py │ └── webpack │ │ ├── __init__.py │ │ ├── config.py │ │ └── extensions │ │ ├── __init__.py │ │ └── webpack.py ├── cli.py ├── click.py ├── clips_pattern.py ├── commands │ ├── __init__.py │ ├── clean.py │ ├── lint.py │ ├── new.py │ ├── shell.py │ ├── unchained.py │ └── urls.py ├── config.py ├── constants.py ├── di.py ├── exceptions.py ├── flask_unchained.py ├── forms │ ├── __init__.py │ ├── _compat.py │ ├── fields.py │ ├── flask_form.py │ └── validators.py ├── hooks │ ├── __init__.py │ ├── configure_app_hook.py │ ├── init_extensions_hook.py │ ├── register_commands_hook.py │ ├── register_extensions_hook.py │ ├── register_services_hook.py │ ├── run_hooks_hook.py │ └── views_hook.py ├── pytest.py ├── routes.py ├── string_utils.py ├── templates │ ├── _flashes.html │ └── layout.html ├── unchained.py ├── utils.py └── views.py ├── poetry.lock ├── pyproject.toml ├── tests ├── __init__.py ├── _bundles │ ├── __init__.py │ ├── app_bundle_in_module │ │ ├── __init__.py │ │ ├── bundle.py │ │ └── config.py │ ├── bundle_in_module │ │ ├── __init__.py │ │ └── bundle.py │ ├── empty_bundle │ │ └── __init__.py │ ├── error_bundle │ │ └── __init__.py │ ├── myapp │ │ ├── __init__.py │ │ ├── commands.py │ │ ├── config.py │ │ ├── extensions.py │ │ ├── static │ │ │ └── .gitkeep │ │ └── templates │ │ │ └── .gitkeep │ ├── override_vendor_bundle │ │ ├── __init__.py │ │ ├── bundle.py │ │ ├── commands.py │ │ ├── config.py │ │ └── extension.py │ ├── services_bundle │ │ ├── __init__.py │ │ └── services.py │ ├── services_ext_bundle │ │ ├── __init__.py │ │ └── services.py │ └── vendor_bundle │ │ ├── __init__.py │ │ ├── bundle.py │ │ ├── commands.py │ │ ├── config.py │ │ ├── extension.py │ │ ├── static │ │ └── .gitkeep │ │ ├── templates │ │ └── .gitkeep │ │ └── views.py ├── _unchained_config.py ├── bundles │ ├── __init__.py │ ├── controller │ │ ├── __init__.py │ │ ├── fixtures │ │ │ ├── __init__.py │ │ │ ├── app_bundle │ │ │ │ ├── __init__.py │ │ │ │ ├── routes.py │ │ │ │ └── views.py │ │ │ ├── auto_route_app_bundle │ │ │ │ ├── __init__.py │ │ │ │ └── views.py │ │ │ ├── bp_routes.py │ │ │ ├── bp_views.py │ │ │ ├── empty_bundle │ │ │ │ ├── __init__.py │ │ │ │ ├── routes.py │ │ │ │ └── views.py │ │ │ ├── other_bp_routes.py │ │ │ ├── other_routes.py │ │ │ ├── routes.py │ │ │ ├── vendor_bundle │ │ │ │ ├── __init__.py │ │ │ │ ├── routes.py │ │ │ │ └── views.py │ │ │ ├── views.py │ │ │ └── warning_bundle │ │ │ │ ├── __init__.py │ │ │ │ ├── routes.py │ │ │ │ └── views.py │ │ ├── test_controller.py │ │ ├── test_register_blueprints_hook.py │ │ ├── test_register_routes_hook.py │ │ ├── test_resource.py │ │ ├── test_route.py │ │ ├── test_routes.py │ │ ├── test_routes_integration.py │ │ └── test_utils.py │ ├── graphene │ │ ├── __init__.py │ │ ├── _bundles │ │ │ ├── __init__.py │ │ │ └── graphene_bundle │ │ │ │ ├── __init__.py │ │ │ │ ├── graphene │ │ │ │ ├── __init__.py │ │ │ │ ├── mutations.py │ │ │ │ ├── schema.py │ │ │ │ └── types.py │ │ │ │ ├── models.py │ │ │ │ └── services.py │ │ ├── conftest.py │ │ ├── hooks │ │ │ └── __init__.py │ │ ├── test_mutations.py │ │ └── test_schema.py │ ├── mail │ │ ├── __init__.py │ │ ├── _unchained_config.py │ │ ├── test_mail.py │ │ └── test_upstream.py │ ├── security │ │ ├── __init__.py │ │ ├── _app │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ ├── routes.py │ │ │ ├── templates │ │ │ │ ├── email │ │ │ │ │ └── layout.html │ │ │ │ ├── layout.html │ │ │ │ └── site │ │ │ │ │ └── index.html │ │ │ └── views.py │ │ ├── _bundles │ │ │ ├── __init__.py │ │ │ └── security │ │ │ │ ├── __init__.py │ │ │ │ ├── config.py │ │ │ │ ├── forms.py │ │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ ├── role.py │ │ │ │ └── user.py │ │ │ │ ├── serializers │ │ │ │ ├── __init__.py │ │ │ │ └── user_serializer.py │ │ │ │ └── services.py │ │ ├── commands │ │ │ ├── test_roles.py │ │ │ └── test_users.py │ │ ├── conftest.py │ │ ├── decorators │ │ │ ├── __init__.py │ │ │ ├── test_anonymous_user_required.py │ │ │ ├── test_auth_required.py │ │ │ └── test_auth_required_same_user.py │ │ └── views │ │ │ ├── __init__.py │ │ │ ├── security_controller │ │ │ ├── __init__.py │ │ │ ├── test_change_password.py │ │ │ ├── test_confirm_email.py │ │ │ ├── test_forgot_password.py │ │ │ ├── test_login.py │ │ │ ├── test_logout.py │ │ │ ├── test_register.py │ │ │ ├── test_reset_password.py │ │ │ └── test_send_confirmation_email.py │ │ │ └── user_resource │ │ │ ├── __init__.py │ │ │ └── test_user_resource.py │ └── sqlalchemy │ │ ├── __init__.py │ │ ├── _bundles │ │ ├── __init__.py │ │ ├── app │ │ │ ├── __init__.py │ │ │ └── models.py │ │ ├── backref │ │ │ ├── __init__.py │ │ │ └── models.py │ │ ├── custom_extension │ │ │ ├── __init__.py │ │ │ ├── base_model.py │ │ │ └── extensions │ │ │ │ ├── __init__.py │ │ │ │ └── sqlalchemy.py │ │ ├── ext_ext_vendor_one │ │ │ ├── __init__.py │ │ │ └── models.py │ │ ├── ext_vendor_one │ │ │ ├── __init__.py │ │ │ └── models.py │ │ ├── polymorphic │ │ │ ├── __init__.py │ │ │ └── models.py │ │ ├── vendor_one │ │ │ ├── __init__.py │ │ │ └── models.py │ │ ├── vendor_three │ │ │ ├── __init__.py │ │ │ └── models │ │ │ │ ├── __init__.py │ │ │ │ ├── many_to_many_model.py │ │ │ │ ├── many_to_many_table.py │ │ │ │ ├── one_to_many.py │ │ │ │ ├── one_to_one.py │ │ │ │ └── polymorphic.py │ │ └── vendor_two │ │ │ ├── __init__.py │ │ │ └── models.py │ │ ├── _model_fixtures.py │ │ ├── conftest.py │ │ ├── services │ │ ├── __init__.py │ │ ├── test_model_manager.py │ │ └── test_session_manager.py │ │ ├── sqla │ │ ├── __init__.py │ │ ├── test_foreign_key.py │ │ └── test_types.py │ │ ├── test_custom_extension.py │ │ ├── test_decorators.py │ │ ├── test_factory_relationships.py │ │ ├── test_model_meta_options.py │ │ └── test_register_models_hook.py ├── commands │ ├── __init__.py │ └── test_new.py ├── conftest.py ├── hooks │ ├── __init__.py │ ├── test_configure_app_hook.py │ ├── test_extension_hooks.py │ ├── test_register_commands_hook.py │ ├── test_register_services_hook.py │ └── test_run_hooks_hook.py ├── templates │ ├── __init__.py │ ├── default │ │ └── index.html │ ├── foobar │ │ └── index.html.j2 │ ├── send_mail.html │ └── site │ │ ├── about.html │ │ ├── index.html │ │ └── terms.html ├── test_app_factory.py ├── test_bundle.py ├── test_clips_pattern.py ├── test_di.py ├── test_string_utils.py ├── test_unchained.py └── test_utils.py ├── tox.ini └── unchained_config.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .coverage 4 | .cache/ 5 | .pytest_cache/ 6 | .tox/ 7 | __pycache__/ 8 | build/ 9 | coverage_html_report/ 10 | dist/ 11 | docs/_build 12 | docs/.doctrees 13 | venv/ 14 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | build: 2 | image: latest 3 | 4 | python: 5 | version: 3.8 6 | setup_py_install: true 7 | 8 | requirements_file: requirements-dev.txt 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2018 Brian Cappello 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | rm -fr build 3 | rm -fr dist 4 | 5 | sdist: clean 6 | python setup.py sdist 7 | 8 | wheel: clean 9 | python setup.py bdist_wheel 10 | 11 | dist: sdist wheel 12 | twine upload dist/* 13 | 14 | .PHONY: clean dist sdist wheel 15 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [ignore: build/**] 2 | [ignore: dist/**] 3 | [ignore: tests/**] 4 | 5 | [python: **.py] 6 | [jinja2: **/templates/**.html] 7 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ 8 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = FlaskUnchained 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/_static/theme_customizations.css: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * IMPORTANT: Need to use !important on rules in this file to override theme * 3 | *******************************************************************************/ 4 | 5 | /* override table width restrictions (fix line-wrap inside cells) */ 6 | @media screen and (min-width: 767px) { 7 | .wy-table-responsive table td { 8 | white-space: normal !important; 9 | } 10 | 11 | .wy-table-responsive { 12 | overflow: visible !important; 13 | } 14 | } 15 | 16 | /* do not add extra spacing around the hero text in responsive modes */ 17 | .md-hero__inner { 18 | margin-top: 0 !important; 19 | margin-bottom: 0 !important; 20 | } 21 | 22 | /* make the placement of the top-left nav-icon consistent (infinity icon) */ 23 | .md-header-nav__button { 24 | margin: 0 0 0 .4rem !important; 25 | width: 20px !important; 26 | } 27 | 28 | @media only screen and (max-width: 76.1875em) { 29 | /* do not add extra spacing around the hero text in responsive modes */ 30 | .md-hero__inner { 31 | padding: .8rem .8rem .4rem !important; 32 | } 33 | 34 | /* make the placement of the top-left nav-icon consistent (hamburger icon) */ 35 | .md-header-nav__button { 36 | margin: .2rem !important; 37 | padding: .4rem !important; 38 | } 39 | 40 | /* add some left padding to nav sub-items when the sidebar is collapsed */ 41 | .md-nav__item > .md-nav__list { 42 | margin-left: .8rem !important; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/class.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | :exclude-members: 7 | 8 | {% block methods %} 9 | {% if methods %} 10 | .. rubric:: Methods 11 | 12 | .. autosummary:: 13 | :toctree: generated/ 14 | 15 | {% for item in methods %} 16 | {%- if not item.startswith('_') or item in ['__call__'] %} ~{{ name }}.{{ item }} 17 | {% endif %} 18 | {%- endfor %} 19 | {% endif %} 20 | {% endblock %} 21 | {% block attributes %} 22 | {% if attributes %} 23 | .. rubric:: Properties 24 | 25 | .. autosummary:: 26 | :toctree: generated/ 27 | 28 | {% for item in attributes %} 29 | {%- if not item.startswith('_') or item in ['__call__'] %} ~{{ name }}.{{ item }} 30 | {% endif %} 31 | {%- endfor %} 32 | {% endif %} 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /docs/_templates/globaltoc.html: -------------------------------------------------------------------------------- 1 | {% set toctree = toctree( 2 | maxdepth=theme_globaltoc_depth | toint, 3 | collapse=theme_globaltoc_collapse | tobool, 4 | includehidden=theme_globaltoc_includehidden | tobool 5 | ) %} 6 | 7 | {% if toctree and sidebars and 'globaltoc.html' in sidebars %} 8 | {% set toctree_nodes = derender_toc(toctree, False) %} 9 | 10 | {% if toctree_nodes.caption %} 11 |

{{ toctree_nodes.caption }}

12 | {% endif %} 13 | 14 | 46 | {# TODO: Fallback to toc? #} 47 | {% endif %} 48 | -------------------------------------------------------------------------------- /docs/_templates/hero.html: -------------------------------------------------------------------------------- 1 | {% if pagename in theme_heroes or '*' in theme_heroes %} 2 | {% set hero_text = theme_heroes.get(pagename, theme_heroes['*']) %} 3 | 10 | {% endif %} 11 | -------------------------------------------------------------------------------- /docs/_templates/localtoc.html: -------------------------------------------------------------------------------- 1 | {% set toc_nodes = derender_toc(toc, True, pagename) if display_toc else [] %} 2 | 3 | 30 | -------------------------------------------------------------------------------- /docs/api/babel-bundle.rst: -------------------------------------------------------------------------------- 1 | Babel Bundle API 2 | ---------------- 3 | 4 | **flask_unchained.bundles.babel** 5 | 6 | .. autosummary:: 7 | :nosignatures: 8 | 9 | ~flask_unchained.bundles.babel.BabelBundle 10 | 11 | .. autosummary:: 12 | 13 | ~flask_unchained.gettext 14 | ~flask_unchained.ngettext 15 | ~flask_unchained.lazy_gettext 16 | ~flask_unchained.lazy_ngettext 17 | 18 | **flask_unchained.bundles.babel.config** 19 | 20 | .. autosummary:: 21 | :nosignatures: 22 | 23 | ~flask_unchained.bundles.babel.config.Config 24 | ~flask_unchained.bundles.babel.config.DevConfig 25 | ~flask_unchained.bundles.babel.config.ProdConfig 26 | ~flask_unchained.bundles.babel.config.StagingConfig 27 | 28 | BabelBundle 29 | ^^^^^^^^^^^ 30 | .. autoclass:: flask_unchained.bundles.babel.BabelBundle 31 | :members: 32 | 33 | gettext functions 34 | ^^^^^^^^^^^^^^^^^ 35 | .. autofunction:: flask_unchained.gettext 36 | .. autofunction:: flask_unchained.ngettext 37 | .. autofunction:: flask_unchained.lazy_gettext 38 | .. autofunction:: flask_unchained.lazy_ngettext 39 | 40 | Config 41 | ^^^^^^ 42 | .. automodule:: flask_unchained.bundles.babel.config 43 | :members: 44 | :undoc-members: 45 | -------------------------------------------------------------------------------- /docs/api/celery-bundle.rst: -------------------------------------------------------------------------------- 1 | Celery Bundle API 2 | ----------------- 3 | 4 | **flask_unchained.bundles.celery** 5 | 6 | .. autosummary:: 7 | :nosignatures: 8 | 9 | ~flask_unchained.bundles.celery.CeleryBundle 10 | 11 | **flask_unchained.bundles.celery.config** 12 | 13 | .. autosummary:: 14 | :nosignatures: 15 | 16 | ~flask_unchained.bundles.celery.config.Config 17 | 18 | **flask_unchained.bundles.celery.extensions** 19 | 20 | .. autosummary:: 21 | :nosignatures: 22 | 23 | ~flask_unchained.bundles.celery.Celery 24 | 25 | **flask_unchained.bundles.celery.hooks** 26 | 27 | .. autosummary:: 28 | :nosignatures: 29 | 30 | ~flask_unchained.bundles.celery.hooks.DiscoverTasksHook 31 | 32 | CeleryBundle 33 | ^^^^^^^^^^^^ 34 | .. autoclass:: flask_unchained.bundles.celery.CeleryBundle 35 | :members: 36 | 37 | Config 38 | ^^^^^^ 39 | .. automodule:: flask_unchained.bundles.celery.config 40 | :members: 41 | :undoc-members: 42 | 43 | The Celery Extension 44 | ^^^^^^^^^^^^^^^^^^^^ 45 | .. autoclass:: flask_unchained.bundles.celery.Celery 46 | :members: task 47 | 48 | DiscoverTasksHook 49 | ^^^^^^^^^^^^^^^^^ 50 | .. autoclass:: flask_unchained.bundles.celery.hooks.DiscoverTasksHook 51 | :members: 52 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 3 6 | 7 | flask-unchained 8 | admin-bundle 9 | api-bundle 10 | babel-bundle 11 | celery-bundle 12 | controller-bundle 13 | graphene-bundle 14 | mail-bundle 15 | oauth-bundle 16 | security-bundle 17 | session-bundle 18 | sqlalchemy-bundle 19 | webpack-bundle 20 | -------------------------------------------------------------------------------- /docs/api/mail-bundle.rst: -------------------------------------------------------------------------------- 1 | Mail Bundle API 2 | --------------- 3 | 4 | **flask_unchained.bundles.mail** 5 | 6 | .. autosummary:: 7 | :nosignatures: 8 | 9 | ~flask_unchained.bundles.mail.MailBundle 10 | 11 | **flask_unchained.bundles.mail.config** 12 | 13 | .. autosummary:: 14 | :nosignatures: 15 | 16 | ~flask_unchained.bundles.mail.config.Config 17 | ~flask_unchained.bundles.mail.config.DevConfig 18 | ~flask_unchained.bundles.mail.config.ProdConfig 19 | ~flask_unchained.bundles.mail.config.StagingConfig 20 | ~flask_unchained.bundles.mail.config.TestConfig 21 | 22 | **flask_unchained.bundles.mail.extensions** 23 | 24 | .. autosummary:: 25 | :nosignatures: 26 | 27 | ~flask_unchained.bundles.mail.Mail 28 | 29 | MailBundle 30 | ^^^^^^^^^^ 31 | .. autoclass:: flask_unchained.bundles.mail.MailBundle 32 | :members: 33 | 34 | Config 35 | ^^^^^^ 36 | .. automodule:: flask_unchained.bundles.mail.config 37 | :members: Config, DevConfig, ProdConfig, StagingConfig, TestConfig 38 | 39 | The Mail Extension 40 | ^^^^^^^^^^^^^^^^^^ 41 | .. autoclass:: flask_unchained.bundles.mail.Mail 42 | :members: send_message 43 | -------------------------------------------------------------------------------- /docs/api/oauth-bundle.rst: -------------------------------------------------------------------------------- 1 | OAuth Bundle API 2 | ---------------- 3 | 4 | **flask_unchained.bundles.oauth** 5 | 6 | .. autosummary:: 7 | :nosignatures: 8 | 9 | ~flask_unchained.bundles.oauth.OAuthBundle 10 | 11 | **flask_unchained.bundles.oauth.config** 12 | 13 | .. autosummary:: 14 | :nosignatures: 15 | 16 | ~flask_unchained.bundles.oauth.config.Config 17 | 18 | **flask_unchained.bundles.oauth.services** 19 | 20 | .. autosummary:: 21 | :nosignatures: 22 | 23 | ~flask_unchained.bundles.oauth.OAuthService 24 | 25 | **flask_unchained.bundles.oauth.views** 26 | 27 | .. autosummary:: 28 | :nosignatures: 29 | 30 | ~flask_unchained.bundles.oauth.OAuthController 31 | 32 | 33 | OAuthBundle 34 | ^^^^^^^^^^^ 35 | .. autoclass:: flask_unchained.bundles.oauth.OAuthBundle 36 | :members: 37 | 38 | Config 39 | ^^^^^^ 40 | .. automodule:: flask_unchained.bundles.oauth.config 41 | :members: 42 | :undoc-members: 43 | 44 | OAuthService 45 | ^^^^^^^^^^^^ 46 | .. autoclass:: flask_unchained.bundles.oauth.OAuthService 47 | :members: 48 | 49 | OAuthController 50 | ^^^^^^^^^^^^^^^ 51 | .. autoclass:: flask_unchained.bundles.oauth.OAuthController 52 | :members: 53 | -------------------------------------------------------------------------------- /docs/api/session-bundle.rst: -------------------------------------------------------------------------------- 1 | Session Bundle API 2 | ------------------ 3 | 4 | **flask_unchained.bundles.session** 5 | 6 | .. autosummary:: 7 | :nosignatures: 8 | 9 | ~flask_unchained.bundles.session.SessionBundle 10 | 11 | **flask_unchained.bundles.session.config** 12 | 13 | .. autosummary:: 14 | :nosignatures: 15 | 16 | ~flask_unchained.bundles.session.config.DefaultFlaskConfigForSessions 17 | ~flask_unchained.bundles.session.config.Config 18 | 19 | **flask_unchained.bundles.session.hooks** 20 | 21 | .. autosummary:: 22 | :nosignatures: 23 | 24 | ~flask_unchained.bundles.session.hooks.RegisterSessionModelHook 25 | 26 | SessionBundle 27 | ^^^^^^^^^^^^^ 28 | .. autoclass:: flask_unchained.bundles.session.SessionBundle 29 | :members: 30 | 31 | Config 32 | ^^^^^^ 33 | .. automodule:: flask_unchained.bundles.session.config 34 | :members: 35 | :undoc-members: 36 | 37 | RegisterSessionModelHook 38 | ^^^^^^^^^^^^^^^^^^^^^^^^ 39 | .. autoclass:: flask_unchained.bundles.session.hooks.RegisterSessionModelHook 40 | :members: 41 | -------------------------------------------------------------------------------- /docs/api/webpack-bundle.rst: -------------------------------------------------------------------------------- 1 | Webpack Bundle API 2 | ------------------ 3 | 4 | **flask_unchained.bundles.webpack** 5 | 6 | .. autosummary:: 7 | :nosignatures: 8 | 9 | ~flask_unchained.bundles.webpack.WebpackBundle 10 | 11 | **flask_unchained.bundles.webpack.config** 12 | 13 | .. autosummary:: 14 | :nosignatures: 15 | 16 | ~flask_unchained.bundles.webpack.config.Config 17 | ~flask_unchained.bundles.webpack.config.ProdConfig 18 | ~flask_unchained.bundles.webpack.config.StagingConfig 19 | 20 | **flask_unchained.bundles.webpack.extensions** 21 | 22 | .. autosummary:: 23 | :nosignatures: 24 | 25 | ~flask_unchained.bundles.webpack.Webpack 26 | 27 | WebpackBundle 28 | ^^^^^^^^^^^^^ 29 | .. autoclass:: flask_unchained.bundles.webpack.WebpackBundle 30 | :members: 31 | 32 | Config 33 | ^^^^^^ 34 | .. automodule:: flask_unchained.bundles.webpack.config 35 | :members: 36 | 37 | Extensions 38 | ^^^^^^^^^^ 39 | .. autoclass:: flask_unchained.bundles.webpack.Webpack 40 | :members: 41 | -------------------------------------------------------------------------------- /docs/bundles/admin.rst: -------------------------------------------------------------------------------- 1 | Admin Bundle 2 | ------------ 3 | 4 | Integrates `Flask Admin `_ with Flask Unchained. 5 | 6 | Installation 7 | ^^^^^^^^^^^^ 8 | 9 | Install dependencies: 10 | 11 | .. code:: bash 12 | 13 | pip install "flask-unchained[sqlalchemy,session,security,admin]" 14 | 15 | Enable the bundle in your ``BUNDLES``: 16 | 17 | .. code:: python 18 | 19 | # project-root/unchained_config.py 20 | 21 | BUNDLES = [ 22 | # ... 23 | 'flask_unchained.bundles.babel', # required by Security Bundle 24 | 'flask_unchained.bundles.session', # required by Security Bundle 25 | 'flask_unchained.bundles.sqlalchemy', # required by Admin Bundle 26 | 'flask_unchained.bundles.security', # required by Admin Bundle 27 | 'flask_unchained.bundles.admin', 28 | 'app', 29 | ] 30 | 31 | And include the Admin Bundle's routes: 32 | 33 | .. code:: python 34 | 35 | # project-root/your_app_bundle/routes.py 36 | 37 | routes = lambda: [ 38 | # ... 39 | include('flask_unchained.bundles.admin.routes'), 40 | ] 41 | 42 | Config 43 | ^^^^^^ 44 | 45 | .. autoclass:: flask_unchained.bundles.admin.config.Config 46 | :members: 47 | 48 | API Docs 49 | ^^^^^^^^ 50 | 51 | See :doc:`../api/admin-bundle` 52 | -------------------------------------------------------------------------------- /docs/bundles/babel.rst: -------------------------------------------------------------------------------- 1 | Babel Bundle 2 | ------------ 3 | 4 | The babel bundle provides support for internationalization and localization by integrating `Flask BabelEx `_ with Flask Unchained. 5 | 6 | Installation 7 | ^^^^^^^^^^^^ 8 | 9 | The babel bundle comes enabled by default. 10 | 11 | Config 12 | ^^^^^^ 13 | 14 | .. automodule:: flask_unchained.bundles.babel.config 15 | :members: 16 | :noindex: 17 | 18 | Commands 19 | ^^^^^^^^ 20 | 21 | .. click:: flask_unchained.bundles.babel.commands:babel 22 | :prog: flask babel 23 | :show-nested: 24 | 25 | API Docs 26 | ^^^^^^^^ 27 | 28 | See :doc:`../api/babel-bundle` 29 | -------------------------------------------------------------------------------- /docs/bundles/celery.rst: -------------------------------------------------------------------------------- 1 | Celery Bundle 2 | ------------- 3 | 4 | Integrates `Celery `_ with Flask Unchained. 5 | 6 | Dependencies 7 | ^^^^^^^^^^^^ 8 | 9 | * A `broker `_ of some sort; Redis or RabbitMQ are popular choices. 10 | 11 | Installation 12 | ^^^^^^^^^^^^ 13 | 14 | Install dependencies: 15 | 16 | .. code:: bash 17 | 18 | pip install "flask-unchained[celery]" 19 | 20 | And enable the celery bundle in your ``unchained_config.py``: 21 | 22 | .. code:: python 23 | 24 | # your_project_root/unchained_config.py 25 | 26 | BUNDLES = [ 27 | # ... 28 | 'flask_unchained.bundles.celery', 29 | 'app', 30 | ] 31 | 32 | NOTE: If you have enabled the :doc:`mail`, and want to send emails asynchronously using celery, then you must list the celery bundle *after* the mail bundle in ``BUNDLES``. 33 | 34 | Config 35 | ^^^^^^ 36 | 37 | .. autoclass:: flask_unchained.bundles.celery.config.Config 38 | :members: 39 | :noindex: 40 | 41 | Commands 42 | ^^^^^^^^ 43 | 44 | .. click:: flask_unchained.bundles.celery.commands:celery 45 | :prog: flask celery 46 | :show-nested: 47 | 48 | API Docs 49 | ^^^^^^^^ 50 | 51 | See :doc:`../api/celery-bundle` 52 | -------------------------------------------------------------------------------- /docs/bundles/index.rst: -------------------------------------------------------------------------------- 1 | .. BEGIN setup/comments ------------------------------------------------------- 2 | 3 | The heading hierarchy is defined as: 4 | h1: = 5 | h2: - 6 | h3: ^ 7 | h4: ~ 8 | h5: " 9 | h6: # 10 | 11 | .. BEGIN document ------------------------------------------------------------- 12 | 13 | Bundles 14 | ======= 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | 19 | admin 20 | api 21 | babel 22 | celery 23 | controller 24 | graphene 25 | mail 26 | oauth 27 | security 28 | session 29 | sqlalchemy 30 | webpack 31 | -------------------------------------------------------------------------------- /docs/bundles/security.rst: -------------------------------------------------------------------------------- 1 | Security Bundle 2 | --------------- 3 | 4 | Integrates `Flask Login `_ and `Flask Principal `_ with Flask Unchained. Technically speaking, this bundle is actually a heavily refactored fork of the `Flask Security `_ project. As of this writing, it is at approximate feature parity with Flask Security, and supports session and token authentication. (We've removed support for HTTP Basic Auth, tracking users' IP addresses and similar, as well as the experimental password-less login support.) 5 | 6 | Installation 7 | ^^^^^^^^^^^^ 8 | 9 | The Security Bundle depends on the SQLAlchemy Bundle, as well as a few third-party libraries: 10 | 11 | .. code:: bash 12 | 13 | pip install "flask-unchained[security,sqlalchemy]" 14 | 15 | And enable the bundles in your ``unchained_config.py``: 16 | 17 | .. code:: python 18 | 19 | # your_project_root/unchained_config.py 20 | 21 | BUNDLES = [ 22 | # ... 23 | 'flask_unchained.bundles.sqlalchemy', 24 | 'flask_unchained.bundles.security', 25 | 'app', 26 | ] 27 | 28 | Config 29 | ^^^^^^ 30 | 31 | .. automodule:: flask_unchained.bundles.security.config 32 | :members: 33 | :noindex: 34 | 35 | Commands 36 | ^^^^^^^^ 37 | 38 | .. click:: flask_unchained.bundles.security.commands.users:users 39 | :prog: flask users 40 | :show-nested: 41 | 42 | .. click:: flask_unchained.bundles.security.commands.roles:roles 43 | :prog: flask roles 44 | :show-nested: 45 | 46 | API Docs 47 | ^^^^^^^^ 48 | 49 | See :doc:`../api/security-bundle` 50 | -------------------------------------------------------------------------------- /docs/bundles/webpack.rst: -------------------------------------------------------------------------------- 1 | Webpack Bundle 2 | -------------- 3 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. mdinclude:: ../CHANGELOG.md 2 | -------------------------------------------------------------------------------- /docs/commands.rst: -------------------------------------------------------------------------------- 1 | Commands 2 | ======== 3 | 4 | .. click:: flask_unchained.commands.new:new 5 | :prog: flask new 6 | :show-nested: 7 | 8 | .. click:: flask.cli:run_command 9 | :prog: flask run 10 | :show-nested: 11 | 12 | .. click:: flask_unchained.commands.shell:shell 13 | :prog: flask shell 14 | :show-nested: 15 | 16 | .. click:: flask_unchained.commands.unchained:unchained_group 17 | :prog: flask unchained 18 | :show-nested: 19 | 20 | .. click:: flask_unchained.commands.urls:urls 21 | :prog: flask urls 22 | :show-nested: 23 | 24 | .. click:: flask_unchained.commands.urls:url 25 | :prog: flask url 26 | :show-nested: 27 | 28 | .. click:: flask_unchained.commands.clean:clean 29 | :prog: flask clean 30 | :show-nested: 31 | 32 | .. click:: flask_unchained.commands.lint:lint 33 | :prog: flask lint 34 | :show-nested: 35 | -------------------------------------------------------------------------------- /docs/docutils.conf: -------------------------------------------------------------------------------- 1 | [restructuredtext parser] 2 | syntax_highlight = short 3 | -------------------------------------------------------------------------------- /docs/table-of-contents.rst: -------------------------------------------------------------------------------- 1 | Flask Unchained 2 | =============== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | index 8 | tutorial/index 9 | how-flask-unchained-works 10 | bundles/index 11 | commands 12 | testing 13 | api/index 14 | changelog 15 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | Included pytest fixtures 5 | ------------------------ 6 | 7 | app 8 | ^^^ 9 | 10 | .. autofunction:: flask_unchained.pytest.app 11 | 12 | maybe_inject_extensions_and_services 13 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 14 | 15 | .. autofunction:: flask_unchained.pytest.maybe_inject_extensions_and_services 16 | 17 | cli_runner 18 | ^^^^^^^^^^ 19 | 20 | .. autofunction:: flask_unchained.pytest.cli_runner 21 | 22 | client 23 | ^^^^^^ 24 | 25 | .. autofunction:: flask_unchained.pytest.client 26 | 27 | api_client 28 | ^^^^^^^^^^ 29 | 30 | .. autofunction:: flask_unchained.pytest.api_client 31 | 32 | templates 33 | ^^^^^^^^^ 34 | 35 | .. autofunction:: flask_unchained.pytest.templates 36 | 37 | Testing related classes 38 | ----------------------- 39 | 40 | FlaskCliRunner 41 | ^^^^^^^^^^^^^^ 42 | 43 | .. autoclass:: flask_unchained.pytest.FlaskCliRunner 44 | :members: 45 | 46 | HtmlTestClient 47 | ^^^^^^^^^^^^^^ 48 | 49 | .. autoclass:: flask_unchained.pytest.HtmlTestClient 50 | :members: 51 | :exclude-members: open 52 | 53 | HtmlTestResponse 54 | ^^^^^^^^^^^^^^^^ 55 | 56 | .. autoclass:: flask_unchained.pytest.HtmlTestResponse 57 | :members: 58 | 59 | ApiTestClient 60 | ^^^^^^^^^^^^^ 61 | 62 | .. autoclass:: flask_unchained.pytest.ApiTestClient 63 | :members: 64 | 65 | ApiTestResponse 66 | ^^^^^^^^^^^^^^^ 67 | 68 | .. autoclass:: flask_unchained.pytest.ApiTestResponse 69 | :members: 70 | 71 | RenderedTemplate 72 | ^^^^^^^^^^^^^^^^ 73 | 74 | .. autoclass:: flask_unchained.pytest.RenderedTemplate 75 | :members: 76 | -------------------------------------------------------------------------------- /docs/tutorial/index.rst: -------------------------------------------------------------------------------- 1 | .. _tutorial: 2 | 3 | Tutorial 4 | ======== 5 | 6 | This tutorial will walk you from Hello World through building a fully-featured quotes application. Users will be able to register, log in, create quotes and manage their favorites. 7 | 8 | .. FIXME: The tutorial project code is available on GitHub. 9 | 10 | It is assumed you're already familiar with: 11 | 12 | - Python 3.8+. The `official tutorial `_ is a great way to learn or review. 13 | - The Jinja2 templating engine. See the `official docs `_ for more info. 14 | 15 | **Table of Contents** 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | 20 | 01_getting_started 21 | 02_views_templates_and_static_assets 22 | 03_db 23 | 04_session 24 | 05_security 25 | 06_project_layout 26 | 07_modeling_authors_quotes_themes 27 | 08_model_forms_and_views 28 | 09_admin_interface 29 | -------------------------------------------------------------------------------- /flask_unchained/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | flask_unchained 3 | --------------- 4 | 5 | The quickest and easiest way to build large web apps and APIs with Flask and SQLAlchemy 6 | 7 | :copyright: Copyright © 2018 Brian Cappello 8 | :license: MIT, see LICENSE for more details 9 | """ 10 | 11 | __version__ = "0.9.0" 12 | 13 | # must be first 14 | from . import _compat # isort: skip 15 | 16 | # aliases 17 | from flask import ( 18 | Request, 19 | Response, 20 | current_app, 21 | g, 22 | render_template, 23 | render_template_string, 24 | request, 25 | session, 26 | ) 27 | from werkzeug.exceptions import abort 28 | 29 | from .app_factory import AppFactory 30 | from .app_factory_hook import AppFactoryHook 31 | from .bundles import AppBundle, Bundle 32 | from .config import BundleConfig 33 | from .constants import DEV, PROD, STAGING, TEST 34 | from .di import Service, injectable 35 | from .flask_unchained import FlaskUnchained 36 | from .forms import FlaskForm 37 | from .routes import ( 38 | controller, 39 | delete, 40 | func, 41 | get, 42 | include, 43 | patch, 44 | post, 45 | prefix, 46 | put, 47 | resource, 48 | rule, 49 | ) 50 | from .unchained import Unchained, unchained 51 | from .utils import get_boolean_env 52 | 53 | 54 | # must be last 55 | from .bundles.babel import gettext, lazy_gettext, lazy_ngettext, ngettext # isort: skip 56 | from .views import ( # isort: skip 57 | Controller, 58 | Resource, 59 | no_route, 60 | param_converter, 61 | redirect, 62 | route, 63 | url_for, 64 | ) 65 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .coverage 4 | .cache/ 5 | .pytest_cache/ 6 | .tox/ 7 | __pycache__/ 8 | build/ 9 | coverage_html_report/ 10 | dist/ 11 | docs/_build 12 | docs/.doctrees 13 | venv/ 14 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/README.md: -------------------------------------------------------------------------------- 1 | #!( # {{ app_bundle_module_name }} ) 2 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/_code_templates/project/__init__.py -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import AppBundle, FlaskUnchained 2 | 3 | 4 | class App(AppBundle): 5 | def before_init_app(self, app: FlaskUnchained) -> None: 6 | app.url_map.strict_slashes = False 7 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask_unchained import BundleConfig 4 | 5 | 6 | class Config(BundleConfig): 7 | SECRET_KEY = os.getenv('FLASK_SECRET_KEY', 'change-me-to-a-secret-key!') 8 | WTF_CSRF_ENABLED = True 9 | #! if security or session: 10 | #! SESSION_TYPE = "{{ 'sqlalchemy' if sqlalchemy else 'filesystem' }}" 11 | #! endif 12 | #! if mail: 13 | 14 | MAIL_DEFAULT_SENDER = f"noreply@localhost" # FIXME 15 | #! endif 16 | #! if webpack: 17 | 18 | WEBPACK_MANIFEST_PATH = os.path.join('static', 'assets', 'manifest.json') 19 | #! endif 20 | 21 | 22 | class DevConfig(Config): 23 | EXPLAIN_TEMPLATE_LOADING = False 24 | #! if security or sqlalchemy: 25 | SQLALCHEMY_ECHO = False 26 | #! endif 27 | #! if webpack: 28 | 29 | WEBPACK_ASSETS_HOST = 'http://localhost:3333' 30 | #! endif 31 | 32 | 33 | class ProdConfig(Config): 34 | pass 35 | 36 | 37 | class StagingConfig(ProdConfig): 38 | pass 39 | 40 | 41 | class TestConfig(Config): 42 | TESTING = True 43 | WTF_CSRF_ENABLED = False 44 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | # from vendor import ExtensionName 2 | # extension_instance = ExtensionName() 3 | 4 | EXTENSIONS = { 5 | # 'extension_name': extension_instance, 6 | # or, if an extension depends on other extension(s): 7 | # 'extension_name': (extension_instance, ['ext', 'dependency', 'names']), 8 | } 9 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/graphql/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/_code_templates/project/app/graphql/__init__.py -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/graphql/mutations.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from graphql import GraphQLError 4 | 5 | from . import types 6 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/graphql/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from flask_unchained.bundles.graphene import MutationsObjectType, QueriesObjectType 4 | 5 | from . import types 6 | from . import mutations 7 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/graphql/types.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from flask_unchained.bundles.graphene import SQLAlchemyObjectType 4 | 5 | from .. import models 6 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/managers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/_code_templates/project/app/managers/__init__.py -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | #! if security: 2 | from flask_unchained.bundles.security.models import UserRole 3 | 4 | from .role import Role 5 | from .user import User 6 | #! endif 7 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/models/role.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.security.models import Role as BaseRole 2 | 3 | 4 | class Role(BaseRole): 5 | pass 6 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/models/user.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.security.models import User as BaseUser 2 | 3 | 4 | class User(BaseUser): 5 | pass 6 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/routes.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import (controller, resource, func, include, prefix, 2 | get, delete, post, patch, put, rule) 3 | 4 | from .views import SiteController 5 | 6 | 7 | routes = lambda: [ 8 | controller(SiteController), 9 | ] 10 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/serializers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/_code_templates/project/app/serializers/__init__.py -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/_code_templates/project/app/services/__init__.py -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/_code_templates/project/app/tasks/__init__.py -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/templates/site/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block content %} 4 |

Hello from SiteController:index

5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .site_controller import SiteController 2 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/app/views/site_controller.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Controller, route 2 | 3 | 4 | class SiteController(Controller): 5 | @route('/') 6 | def index(self): 7 | return self.render('index') 8 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/assets/scripts/app/index.js: -------------------------------------------------------------------------------- 1 | // this file is included in the layout.html, which sets up HMR in development. 2 | // otherwise, there's nothing here by default, but if you have some js that 3 | // needs to be included everywhere, this would be the place to put it 4 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/assets/styles/app/main.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/_code_templates/project/assets/styles/app/main.scss -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/celery_app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask_unchained import AppFactory, PROD 4 | 5 | # import the celery extension so that celery can access it for its startup 6 | from flask_unchained.bundles.celery import celery 7 | 8 | 9 | app = AppFactory().create_app(os.getenv('FLASK_ENV', PROD)) 10 | app.app_context().push() 11 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/db/fixtures/Role.yaml: -------------------------------------------------------------------------------- 1 | ROLE_USER: 2 | name: ROLE_USER 3 | 4 | ROLE_ADMIN: 5 | name: ROLE_ADMIN 6 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/db/fixtures/User.yaml: -------------------------------------------------------------------------------- 1 | user: 2 | email: user@example.com 3 | password: password 4 | is_active: True 5 | confirmed_at: utcnow 6 | roles: ['Role(ROLE_USER)'] 7 | 8 | admin: 9 | email: admin@example.com 10 | password: password 11 | is_active: True 12 | confirmed_at: utcnow 13 | roles: ['Role(ROLE_ADMIN)'] 14 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "node_modules/.bin/webpack-dev-server --config webpack/webpack.config.js --progress --colors --port 3333 --content-base static", 4 | "build": "NODE_ENV=production node_modules/.bin/webpack --config webpack/webpack.config.js --progress --profile --colors" 5 | }, 6 | "dependencies": {}, 7 | "devDependencies": { 8 | "babel-core": "^6.26.0", 9 | "babel-loader": "^7.1.2", 10 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 11 | "babel-preset-env": "^1.6.1", 12 | "css-loader": "^0.28.7", 13 | "extract-text-webpack-plugin": "^3.0.2", 14 | "file-loader": "^1.1.6", 15 | "image-webpack-loader": "^3.4.2", 16 | "md5": "^2.2.1", 17 | "name-all-modules-plugin": "^1.0.1", 18 | "node-fs-extra": "^0.8.2", 19 | "node-sass": "^4.7.2", 20 | "resolve-url-loader": "^2.2.1", 21 | "sass-loader": "^6.0.6", 22 | "style-loader": "^0.19.1", 23 | "url-loader": "^0.6.2", 24 | "webpack": "^3.6.0", 25 | "webpack-dev-server": "^3.1.11", 26 | "webpack-manifest-plugin": "^2.0.0-rc.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | IPython==7.24.1 4 | pytest==6.2.4 5 | pytest-flask==1.2.0 6 | #! if sqlalchemy: 7 | factory_boy==2.12.0 8 | #! endif 9 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/requirements.txt: -------------------------------------------------------------------------------- 1 | #! flask-unchained[{{ ','.join(requirements) }}]>=0.9.0 2 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/templates/_flashes.html: -------------------------------------------------------------------------------- 1 | {% with messages = get_flashed_messages(with_categories=True) %} 2 | {% if messages %} 3 |
4 |
5 | {% for category, message in messages %} 6 | 12 | {% endfor %} 13 |
14 |
15 | {% endif %} 16 | {% endwith %} 17 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/templates/email/layout.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 20 | 21 | 22 | 25 | 26 |
12 | {#! {{ app_bundle_module_name }} #} 13 |
17 | {% block body %} 18 | {% endblock %} 19 |
23 | © Copyright {% now 'utc', '%Y' %} {#! {{ app_bundle_module_name }} #} 24 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}{#! {{ app_bundle_module_name }} #}{% endblock %} 8 | 9 | {% block stylesheets %} 10 | 11 | {#! if webpack: {% raw %}{{ style_tag('app') }}{% endraw %} #} 12 | {% endblock stylesheets %} 13 | 14 | {% block extra_head %} 15 | {% endblock extra_head %} 16 | 17 | 18 | 19 | {% block body %} 20 |
21 | {% include '_flashes.html' %} 22 | {% block content %} 23 | {% endblock content %} 24 |
25 | {% endblock body %} 26 | 27 | {% block javascripts %} 28 | 29 | 30 | 31 | {#! if webpack: {% raw %}{{ script_tag('app') }}{% endraw %} #} 32 | {% endblock javascripts %} 33 | 34 | 35 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/_code_templates/project/tests/__init__.py -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/tests/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/_code_templates/project/tests/app/__init__.py -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/tests/app/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/_code_templates/project/tests/app/views/__init__.py -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/tests/app/views/test_site_controller.py: -------------------------------------------------------------------------------- 1 | def test_get_index(client): 2 | r = client.get('site_controller.index') 3 | assert r.status_code == 200 4 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | #! if mail: 4 | from flask_unchained.bundles.mail.pytest import * 5 | #! endif 6 | #! if security or sqlalchemy: 7 | from flask_unchained.bundles.sqlalchemy.pytest import * 8 | #! endif 9 | #! if security: 10 | from flask_unchained.bundles.security.pytest import * 11 | #! endif 12 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/unchained_config.py: -------------------------------------------------------------------------------- 1 | BUNDLES = [ 2 | #! if api: 3 | 'flask_unchained.bundles.api', 4 | #! endif 5 | #! if mail: 6 | 'flask_unchained.bundles.mail', 7 | #! endif 8 | #! if celery: 9 | 'flask_unchained.bundles.celery', # move before mail to send emails synchronously 10 | #! endif 11 | #! if oauth: 12 | 'flask_unchained.bundles.oauth', 13 | #! endif 14 | #! if security or oauth: 15 | 'flask_unchained.bundles.security', 16 | #! endif 17 | #! if security or session: 18 | 'flask_unchained.bundles.session', 19 | #! endif 20 | #! if security or sqlalchemy: 21 | 'flask_unchained.bundles.sqlalchemy', 22 | #! endif 23 | #! if webpack: 24 | 'flask_unchained.bundles.webpack', 25 | #! endif 26 | #! if any(set(requirements) - {'dev', 'docs'}): 27 | 28 | #! endif 29 | 'app', # your app bundle *must* be last 30 | ] 31 | -------------------------------------------------------------------------------- /flask_unchained/_code_templates/project/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask_unchained import AppFactory, PROD 4 | 5 | 6 | app = AppFactory().create_app(os.getenv('FLASK_ENV', PROD)) 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_admin import helpers 2 | from flask_admin.base import expose 3 | from flask_admin.model.form import InlineFormAdmin 4 | 5 | from flask_unchained import Bundle, FlaskUnchained, url_for 6 | 7 | from .extensions import Admin, admin 8 | from .macro import macro 9 | from .model_admin import ModelAdmin 10 | from .security import AdminSecurityMixin 11 | from .views import AdminDashboardView, AdminSecurityController 12 | 13 | 14 | class AdminBundle(Bundle): 15 | """ 16 | The Admin Bundle. 17 | """ 18 | 19 | name = "admin_bundle" 20 | """ 21 | The name of the Admin Bundle. 22 | """ 23 | 24 | dependencies = ( 25 | "flask_unchained.bundles.sqlalchemy", 26 | "flask_unchained.bundles.security", 27 | ) 28 | 29 | def after_init_app(self, app: FlaskUnchained) -> None: 30 | admin._set_admin_index_view( 31 | app.config.ADMIN_INDEX_VIEW, url=app.config.ADMIN_BASE_URL 32 | ) 33 | admin._init_extension() 34 | 35 | # Register views 36 | for view in admin._views: 37 | app.register_blueprint( 38 | view.create_blueprint(admin), register_with_babel=False 39 | ) 40 | 41 | app.context_processor( 42 | lambda: dict( 43 | admin_base_template=admin.base_template, 44 | admin_view=admin.index_view, 45 | h=helpers, 46 | get_url=url_for, 47 | ) 48 | ) 49 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .admin import Admin 2 | 3 | 4 | admin = Admin() 5 | 6 | 7 | EXTENSIONS = { 8 | "admin": admin, 9 | } 10 | 11 | 12 | __all__ = [ 13 | "admin", 14 | "Admin", 15 | ] 16 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/extensions/admin.py: -------------------------------------------------------------------------------- 1 | from flask_admin import Admin as BaseAdmin 2 | 3 | from flask_unchained import FlaskUnchained 4 | 5 | 6 | class Admin(BaseAdmin): 7 | """ 8 | The `Admin` extension:: 9 | 10 | from flask_unchained.bundles.admin import admin 11 | """ 12 | 13 | def init_app(self, app: FlaskUnchained): 14 | self.app = app 15 | self.name = app.config.ADMIN_NAME 16 | self.subdomain = app.config.ADMIN_SUBDOMAIN 17 | self.base_template = app.config.ADMIN_BASE_TEMPLATE 18 | self.template_mode = app.config.ADMIN_TEMPLATE_MODE 19 | 20 | # NOTE: AdminBundle.after_init_app finishes initializing this extension 21 | # (unfortunately the admin extension is deeply integrated with its own blueprints, 22 | # so this delayed initialization is necessary for template overriding to work) 23 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .register_model_admins_hook import RegisterModelAdminsHook 2 | 3 | 4 | __all__ = [ 5 | "RegisterModelAdminsHook", 6 | ] 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/macro.py: -------------------------------------------------------------------------------- 1 | def macro(name): 2 | """Replaces :func:`~flask_admin.model.template.macro`, adding support for using 3 | macros imported from another file. For example: 4 | 5 | .. code:: html+jinja 6 | 7 | {# templates/admin/column_formatters.html #} 8 | 9 | {% macro email(model, column) %} 10 | {% set address = model[column] %} 11 | {{ address }} 12 | {% endmacro %} 13 | 14 | .. code:: python 15 | 16 | class FooAdmin(ModelAdmin): 17 | column_formatters = { 18 | 'col_name': macro('column_formatters.email') 19 | } 20 | 21 | Also required for this to work, is to add the following to the top of your 22 | master admin template: 23 | 24 | .. code:: html+jinja 25 | 26 | {# templates/admin/master.html #} 27 | 28 | {% import 'admin/column_formatters.html' as column_formatters with context %} 29 | """ 30 | 31 | def wrapper(view, context, model, column): 32 | if "." in name: 33 | macro_import_name, macro_name = name.split(".") 34 | m = getattr(context.get(macro_import_name), macro_name, None) 35 | else: 36 | m = context.resolve(name) 37 | 38 | if not m: 39 | return m 40 | 41 | return m(model=model, column=column) 42 | 43 | return wrapper 44 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/routes.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import controller, rule 2 | 3 | from .views import AdminSecurityController 4 | 5 | 6 | routes = lambda: [ 7 | controller( 8 | "/admin", 9 | AdminSecurityController, 10 | rules=[ 11 | rule("/login", "login", endpoint="admin.login"), 12 | rule("/logout", "logout", endpoint="admin.logout"), 13 | ], 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/security.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from flask import abort, current_app, request 4 | 5 | 6 | try: 7 | from flask_unchained.bundles.security import current_user as user 8 | except ImportError: 9 | user = None 10 | 11 | from flask_unchained import redirect, url_for 12 | 13 | 14 | class AdminSecurityMixin: 15 | """ 16 | Mixin class for Admin views providing integration with the Security Bundle. 17 | """ 18 | 19 | def is_accessible(self): 20 | if ( 21 | user.is_active 22 | and user.is_authenticated 23 | and user.has_role(current_app.config.ADMIN_ADMIN_ROLE_NAME) 24 | ): 25 | return True 26 | return False 27 | 28 | def inaccessible_callback(self, name, **kwargs): 29 | if not user.is_authenticated: 30 | return redirect(url_for("ADMIN_LOGIN_ENDPOINT", next=request.url)) 31 | return abort(HTTPStatus.FORBIDDEN) 32 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/static/admin.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 0; /* override a silly style added by flask-admin */ 3 | } 4 | 5 | h1 { 6 | margin-bottom: 30px; 7 | } 8 | 9 | /* improve navbar-top appearance */ 10 | .navbar-default { 11 | border-radius: 0; 12 | } 13 | .navbar-collapse.collapsing .nav.navbar-nav, 14 | .navbar-collapse.collapse.in .nav.navbar-nav { 15 | margin: 0 -15px; 16 | } 17 | 18 | .admin-form .form-group label.control-label { 19 | white-space: nowrap; 20 | } 21 | 22 | /* improve formatting of checkbox in model forms */ 23 | .admin-form .form-group input[type="checkbox"] { 24 | height: 20px; 25 | width: 20px; 26 | margin-top: 6px; 27 | } 28 | 29 | /* fix layout of Remember me? checkbox label on the login page */ 30 | form[name="login_user_form"] .checkbox label[for="remember"] { 31 | padding-left: 0; 32 | } 33 | 34 | table.dashboard td { 35 | width: 33%; 36 | } 37 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/templates/admin/_macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_field_with_errors(field) %} 2 |
3 | {{ field.label }} {{ field(class_='form-control {% if field.errors %}is-invalid{% endif %}', **kwargs)|safe }} 4 | {% if field.errors %} 5 |
6 | {% for error in field.errors %} 7 |
{{ error }}
8 | {% endfor %} 9 |
10 | {% endif %} 11 |
12 | {% endmacro %} 13 | 14 | {% macro render_field(field) %} 15 |

{{ field(class_='form-control', **kwargs)|safe }}

16 | {% endmacro %} 17 | 18 | {% macro render_checkbox_field(field) -%} 19 |
20 | {{ field(type='checkbox', class_='form-check-input', **kwargs) }} 21 | {{ field.label(class_='form-check-label') }} 22 |
23 | {%- endmacro %} 24 | 25 | {% macro form_errors(form) %} 26 | {% set errors = form.errors.get('_error', []) %} 27 | {% if errors %} 28 |
29 | {% for error in errors %} 30 |
{{ error }}
31 | {% endfor %} 32 |
33 | {% endif %} 34 | {% endmacro %} 35 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/templates/admin/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | 3 | {% from "admin/_macros.html" import form_errors, render_field, render_field_with_errors, render_checkbox_field %} 4 | 5 | {% block access_control %} 6 | {% endblock %} 7 | 8 | {% block body %} 9 | {{ super() }} 10 |
11 |
12 |

Login

13 |
14 |
15 | {{ form_errors(login_user_form) }} 16 | {{ render_field(login_user_form.next, value="ADMIN_POST_LOGIN_REDIRECT_ENDPOINT") }} 17 | {{ render_field(login_user_form.csrf_token) }} 18 | {{ render_field_with_errors(login_user_form.email, autofocus=True) }} 19 | {{ render_field_with_errors(login_user_form.password) }} 20 | {{ render_checkbox_field(login_user_form.remember) }} 21 | {{ render_field(login_user_form.submit, class="btn btn-primary") }} 22 |
23 |
24 |
25 |
26 | {% endblock body %} 27 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/templates/admin/master.html: -------------------------------------------------------------------------------- 1 | {% extends admin_base_template %} 2 | 3 | {% import 'admin/column_formatters.html' as column_formatters with context %} 4 | 5 | {% block head_tail %} 6 | {{ super() }} 7 | 8 | {% endblock %} 9 | 10 | {% block access_control %} 11 | 14 | {% endblock %} 15 | 16 | {# configure jQuery to automatically send the CSRF token on AJAX requests #} 17 | {# (necessary for e.g. list-editable forms and related-model popup forms) #} 18 | {% block tail %} 19 | {{ super() }} 20 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/templates/admin/model/create.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/create.html' %} 2 | 3 | {% block body %} 4 |

Create {{ admin_view.model.__label__ }}

5 | {{ super() }} 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/templates/admin/model/details.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/details.html' %} 2 | 3 | {% block body %} 4 |

{{ admin_view.model.__label__ }} Details

5 | {{ super() }} 6 | {% endblock %} 7 | 8 | {% block details_table %} 9 | 10 | {% for c, name in details_columns %} 11 | 12 | 15 | 18 | 19 | {% endfor %} 20 |
13 | {{ name }} 14 | 16 | {{ get_value(model, c) | safe }} 17 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/templates/admin/model/edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/edit.html' %} 2 | 3 | {% block body %} 4 |

Edit {{ admin_view.model.__label__ }}

5 | {{ super() }} 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .admin_security_controller import AdminSecurityController 2 | from .dashboard import AdminDashboardView 3 | 4 | 5 | __all__ = [ 6 | "AdminDashboardView", 7 | "AdminSecurityController", 8 | ] 9 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/views/admin_security_controller.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from flask_unchained import lazy_gettext as _ 4 | from flask_unchained import request, route 5 | from flask_unchained.bundles.security import SecurityController, current_user 6 | 7 | 8 | class AdminSecurityController(SecurityController): 9 | """ 10 | Extends :class:`~flask_unchained.bundles.security.SecurityController`, to 11 | customize the template folder to use admin-specific templates. 12 | """ 13 | 14 | class Meta: 15 | template_folder = "admin" 16 | 17 | @route(endpoint="admin.logout") 18 | def logout(self): 19 | """ 20 | View function to log a user out. Supports html and json requests. 21 | """ 22 | if current_user.is_authenticated: 23 | self.security_service.logout_user() 24 | 25 | if request.is_json: 26 | return "", HTTPStatus.NO_CONTENT 27 | 28 | self.flash(_("flask_unchained.bundles.security:flash.logout"), category="success") 29 | return self.redirect( 30 | "ADMIN_POST_LOGOUT_REDIRECT_ENDPOINT", 31 | "SECURITY_POST_LOGOUT_REDIRECT_ENDPOINT", 32 | ) 33 | -------------------------------------------------------------------------------- /flask_unchained/bundles/admin/views/dashboard.py: -------------------------------------------------------------------------------- 1 | from flask_admin import AdminIndexView as BaseAdminIndexView 2 | from flask_admin import expose 3 | 4 | from ..security import AdminSecurityMixin 5 | 6 | 7 | class AdminDashboardView(AdminSecurityMixin, BaseAdminIndexView): 8 | """ 9 | Default admin dashboard view. Renders the ``admin/dashboard.html`` template. 10 | """ 11 | 12 | # overridden to not take any arguments 13 | def __init__(self): 14 | super().__init__() 15 | 16 | @expose("/") 17 | def index(self): 18 | return self.render("admin/dashboard.html") 19 | 20 | def is_visible(self): 21 | """ 22 | Overridden to hide this view from the menu by default. 23 | """ 24 | return False 25 | -------------------------------------------------------------------------------- /flask_unchained/bundles/api/config.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | 3 | from flask_unchained import BundleConfig 4 | from flask_unchained.string_utils import camel_case, snake_case 5 | 6 | 7 | class Config(BundleConfig): 8 | """ 9 | Default config settings for the API Bundle. 10 | """ 11 | 12 | API_OPENAPI_VERSION = "3.0.2" 13 | API_REDOC_SOURCE_URL = ( 14 | "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js" 15 | ) 16 | 17 | API_TITLE = None 18 | API_VERSION = 1 19 | API_DESCRIPTION = None 20 | 21 | API_APISPEC_PLUGINS = None 22 | 23 | DUMP_KEY_FN = camel_case 24 | """ 25 | An optional function to use for converting keys when dumping data to send over 26 | the wire. By default, we convert snake_case to camelCase. 27 | """ 28 | 29 | LOAD_KEY_FN = snake_case 30 | """ 31 | An optional function to use for converting keys received over the wire to 32 | the backend's representation. By default, we convert camelCase to snake_case. 33 | """ 34 | 35 | ACCEPT_HANDLERS = {"application/json": jsonify} 36 | """ 37 | Functions to use for converting response data for Accept headers. 38 | """ 39 | -------------------------------------------------------------------------------- /flask_unchained/bundles/api/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import Api 2 | from .marshmallow import Marshmallow 3 | 4 | 5 | api = Api() 6 | ma = Marshmallow() 7 | 8 | 9 | EXTENSIONS = { 10 | "api": api, 11 | "ma": (ma, ["db"]), 12 | } 13 | 14 | 15 | __all__ = [ 16 | "api", 17 | "Api", 18 | "ma", 19 | "Marshmallow", 20 | ] 21 | -------------------------------------------------------------------------------- /flask_unchained/bundles/api/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .register_model_resources_hook import RegisterModelResourcesHook 2 | from .register_model_serializers_hook import RegisterModelSerializersHook 3 | 4 | 5 | __all__ = [ 6 | "RegisterModelResourcesHook", 7 | "RegisterModelSerializersHook", 8 | ] 9 | -------------------------------------------------------------------------------- /flask_unchained/bundles/api/templates/open_api/redoc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | 8 | 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /flask_unchained/bundles/api/utils.py: -------------------------------------------------------------------------------- 1 | # from Flask-RESTful 2 | def unpack(value): 3 | """ 4 | Return a three tuple of data, code, and headers 5 | """ 6 | if not isinstance(value, tuple): 7 | return value, 200, {} 8 | 9 | try: 10 | data, code, headers = value 11 | return data, code, headers 12 | except ValueError: 13 | pass 14 | 15 | try: 16 | data, code = value 17 | return data, code, {} 18 | except ValueError: 19 | pass 20 | 21 | return value, 200, {} 22 | -------------------------------------------------------------------------------- /flask_unchained/bundles/api/views.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Controller, current_app, injectable, route 2 | 3 | from .extensions import Api 4 | 5 | 6 | class OpenAPIController(Controller): 7 | class Meta: 8 | url_prefix = "/docs" 9 | 10 | api: Api = injectable 11 | 12 | @route("/") 13 | def redoc(self): 14 | return self.render("redoc", redoc_url=current_app.config.API_REDOC_SOURCE_URL) 15 | 16 | @route("/openapi.json") 17 | def json(self): 18 | return self.jsonify(self.api.spec.to_dict()) 19 | -------------------------------------------------------------------------------- /flask_unchained/bundles/babel/extensions.py: -------------------------------------------------------------------------------- 1 | from flask_babel import Babel 2 | 3 | 4 | babel = Babel() 5 | 6 | 7 | EXTENSIONS = { 8 | "babel": babel, 9 | } 10 | 11 | 12 | __all__ = [ 13 | "babel", 14 | "Babel", 15 | ] 16 | -------------------------------------------------------------------------------- /flask_unchained/bundles/celery/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | from .extensions import Celery, celery 4 | 5 | 6 | class CeleryBundle(Bundle): 7 | """ 8 | The Celery Bundle. 9 | """ 10 | 11 | name = "celery_bundle" 12 | """ 13 | The name of the Celery Bundle. 14 | """ 15 | 16 | command_group_names = ["celery"] 17 | """ 18 | Click groups for the Celery Bundle. 19 | """ 20 | -------------------------------------------------------------------------------- /flask_unchained/bundles/celery/commands.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | import time 4 | 5 | from flask_unchained.cli import cli 6 | 7 | 8 | @cli.group() 9 | def celery(): 10 | """ 11 | Celery commands. 12 | """ 13 | 14 | 15 | @celery.command() 16 | def worker(): 17 | """ 18 | Start the celery worker. 19 | """ 20 | _run_until_killed("celery worker -A celery_app.celery -l debug", "celery worker") 21 | 22 | 23 | @celery.command() 24 | def beat(): 25 | """ 26 | Start the celery beat. 27 | """ 28 | _run_until_killed("celery beat -A celery_app.celery -l debug", "celery beat") 29 | 30 | 31 | def _run_until_killed(cmd, kill_proc): 32 | p = None 33 | try: 34 | p = subprocess.Popen(cmd, shell=True) # nosec 35 | while p.poll() is None: 36 | time.sleep(0.25) 37 | except KeyboardInterrupt: 38 | if p is None: 39 | return sys.exit(1) 40 | 41 | # attempt graceful termination, timing out after 60 seconds 42 | p.terminate() 43 | try: 44 | r = p.wait(timeout=60) 45 | except subprocess.TimeoutExpired: 46 | subprocess.run(f"pkill -9 -f {kill_proc!r}", shell=True) # nosec 47 | sys.exit(1) 48 | else: 49 | sys.exit(r) 50 | -------------------------------------------------------------------------------- /flask_unchained/bundles/celery/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask_unchained import BundleConfig 4 | 5 | from .tasks import _send_mail_async 6 | 7 | 8 | class Config(BundleConfig): 9 | """ 10 | Default configuration options for the Celery Bundle. 11 | """ 12 | 13 | CELERY_BROKER_URL = "redis://{host}:{port}/0".format( 14 | host=os.getenv("FLASK_REDIS_HOST", "127.0.0.1"), 15 | port=int(os.getenv("FLASK_REDIS_PORT", "6379")), 16 | ) 17 | """ 18 | The broker URL to connect to. 19 | """ 20 | 21 | CELERY_RESULT_BACKEND = CELERY_BROKER_URL 22 | """ 23 | The result backend URL to connect to. 24 | """ 25 | 26 | CELERY_ACCEPT_CONTENT = ("json", "pickle", "dill") 27 | """ 28 | Tuple of supported serialization strategies. 29 | """ 30 | 31 | MAIL_SEND_FN = _send_mail_async 32 | """ 33 | If the celery bundle is listed *after* the mail bundle in 34 | ``unchained_config.BUNDLES``, then this configures the mail bundle to 35 | send emails asynchronously. 36 | """ 37 | -------------------------------------------------------------------------------- /flask_unchained/bundles/celery/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import Celery 2 | 3 | 4 | celery = Celery() 5 | 6 | 7 | EXTENSIONS = { 8 | "celery": celery, 9 | } 10 | 11 | 12 | __all__ = [ 13 | "celery", 14 | "Celery", 15 | ] 16 | -------------------------------------------------------------------------------- /flask_unchained/bundles/celery/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .discover_tasks_hook import DiscoverTasksHook 2 | 3 | 4 | __all__ = [ 5 | "DiscoverTasksHook", 6 | ] 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/celery/hooks/discover_tasks_hook.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | from flask_unchained import AppFactoryHook, FlaskUnchained 4 | 5 | 6 | class DiscoverTasksHook(AppFactoryHook): 7 | """ 8 | Discovers celery tasks. 9 | """ 10 | 11 | name = "celery_tasks" 12 | """ 13 | The name of this hook. 14 | """ 15 | 16 | bundle_module_names = ["tasks"] 17 | """ 18 | The default module this hook loads from. 19 | 20 | Override by setting the ``celery_tasks_module_names`` attribute on your 21 | bundle class. 22 | """ 23 | 24 | bundle_override_module_names_attr = "celery_tasks_module_names" 25 | run_after = ["init_extensions"] 26 | 27 | def process_objects(self, app: FlaskUnchained, objects: Dict[str, Any]): 28 | # don't need to do anything, just make sure the tasks modules get imported 29 | # (which happens just by this hook running) 30 | pass 31 | 32 | def type_check(self, obj: Any): 33 | return False 34 | -------------------------------------------------------------------------------- /flask_unchained/bundles/celery/tasks.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | 3 | from ..mail.extensions import mail 4 | from ..mail.utils import make_message 5 | from .extensions import celery 6 | 7 | 8 | def _send_mail_async(subject_or_message=None, to=None, template=None, **kwargs): 9 | subject_or_message = subject_or_message or kwargs.pop("subject") 10 | if current_app and current_app.testing: 11 | return async_mail_task.apply([subject_or_message, to, template], kwargs) 12 | return async_mail_task.delay(subject_or_message, to, template, **kwargs) 13 | 14 | 15 | @celery.task(serializer="dill") 16 | def async_mail_task(subject_or_message, to=None, template=None, **kwargs): 17 | """ 18 | Celery task to send emails asynchronously using the mail bundle. 19 | """ 20 | to = to or kwargs.pop("recipients", []) 21 | msg = make_message(subject_or_message, to, template, **kwargs) 22 | with mail.connect() as connection: 23 | connection.send(msg) 24 | -------------------------------------------------------------------------------- /flask_unchained/bundles/controller/attr_constants.py: -------------------------------------------------------------------------------- 1 | CONTROLLER_ROUTES_ATTR = "__fcb_controller_routes__" 2 | FN_ROUTES_ATTR = "__fcb_fn_routes__" 3 | NO_ROUTES_ATTR = "__fcb_no_routes__" 4 | NOT_VIEWS_ATTR = "__fcb_not_views_method_names__" 5 | REMOVE_SUFFIXES_ATTR = "__fcb_remove_suffixes__" 6 | -------------------------------------------------------------------------------- /flask_unchained/bundles/controller/config.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import BundleConfig 2 | 3 | 4 | class Config(BundleConfig): 5 | """ 6 | Default configuration options for the controller bundle. 7 | """ 8 | 9 | FLASH_MESSAGES = True 10 | """ 11 | Whether or not to enable flash messages. 12 | 13 | NOTE: This only works for messages flashed using the 14 | :meth:`flask_unchained.Controller.flash` method; 15 | using the :func:`flask.flash` function directly will not respect this setting. 16 | """ 17 | 18 | TEMPLATE_FILE_EXTENSION = ".html" 19 | """ 20 | The default file extension to use for templates. 21 | """ 22 | 23 | WTF_CSRF_ENABLED = False 24 | """ 25 | Whether or not to enable CSRF protection. 26 | """ 27 | 28 | CSRF_TOKEN_COOKIE_NAME = "csrf_token" 29 | """ 30 | The cookie name to set on responses for the CSRF token. Defaults to "csrf_token". 31 | """ 32 | -------------------------------------------------------------------------------- /flask_unchained/bundles/controller/constants.py: -------------------------------------------------------------------------------- 1 | CREATE = "create" 2 | DELETE = "delete" 3 | GET = "get" 4 | LIST = "list" 5 | PATCH = "patch" 6 | PUT = "put" 7 | 8 | RESOURCE_INDEX_METHODS = (LIST, CREATE) 9 | RESOURCE_MEMBER_METHODS = (GET, DELETE, PATCH, PUT) 10 | ALL_RESOURCE_METHODS = RESOURCE_INDEX_METHODS + RESOURCE_MEMBER_METHODS 11 | -------------------------------------------------------------------------------- /flask_unchained/bundles/controller/extensions.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import CSRFProtect 2 | 3 | 4 | csrf = CSRFProtect() 5 | 6 | 7 | EXTENSIONS = { 8 | "csrf": csrf, 9 | } 10 | 11 | 12 | __all__ = [ 13 | "csrf", 14 | "CSRFProtect", 15 | ] 16 | -------------------------------------------------------------------------------- /flask_unchained/bundles/controller/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .register_blueprints_hook import RegisterBlueprintsHook 2 | from .register_bundle_blueprints_hook import RegisterBundleBlueprintsHook 3 | from .register_routes_hook import RegisterRoutesHook 4 | 5 | 6 | __all__ = [ 7 | "RegisterBlueprintsHook", 8 | "RegisterBundleBlueprintsHook", 9 | "RegisterRoutesHook", 10 | ] 11 | -------------------------------------------------------------------------------- /flask_unchained/bundles/graphene/commands.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from flask_unchained import unchained 4 | from flask_unchained.cli import cli, click 5 | 6 | 7 | @cli.group() 8 | def gql(): 9 | """ 10 | GraphQL commands. 11 | """ 12 | 13 | 14 | @gql.command("dump-schema") 15 | @click.option("--out", "-o", default="schema.json", help="The filename to dump to.") 16 | @click.option("--indent", default=4, help="How many spaces to indent the output by.") 17 | def dump_schema(out, indent): 18 | root_schema = unchained.graphene_bundle.root_schema 19 | schema = dict(data=root_schema.introspect()) 20 | with open(out, "w") as outfile: 21 | json.dump(schema, outfile, indent=indent, sort_keys=True) 22 | 23 | print(f"Successfully dumped GraphQL schema to {out}") 24 | -------------------------------------------------------------------------------- /flask_unchained/bundles/graphene/config.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import BundleConfig 2 | 3 | 4 | class Config(BundleConfig): 5 | GRAPHENE_URL = "/graphql" 6 | """ 7 | The URL where graphene should be served from. Set to ``None`` to disable. 8 | """ 9 | 10 | GRAPHENE_BATCH_URL = None 11 | """ 12 | The URL where graphene should be served from in batch mode. Set to ``None`` 13 | to disable. 14 | """ 15 | 16 | GRAPHENE_ENABLE_GRAPHIQL = False 17 | """ 18 | Whether or not to enable GraphIQL. 19 | """ 20 | 21 | GRAPHENE_PRETTY_JSON = False 22 | """ 23 | Whether or not to pretty print the returned JSON. 24 | """ 25 | 26 | 27 | class DevConfig(Config): 28 | GRAPHENE_ENABLE_GRAPHIQL = True 29 | """ 30 | Whether or not to enable GraphIQL. 31 | """ 32 | 33 | GRAPHENE_PRETTY_JSON = True 34 | """ 35 | Whether or not to pretty print the returned JSON. 36 | """ 37 | -------------------------------------------------------------------------------- /flask_unchained/bundles/graphene/exceptions.py: -------------------------------------------------------------------------------- 1 | class MutationValidationError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /flask_unchained/bundles/graphene/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .register_graphene_mutations_hook import RegisterGrapheneMutationsHook 2 | from .register_graphene_queries_hook import RegisterGrapheneQueriesHook 3 | from .register_graphene_root_schema_hook import RegisterGrapheneRootSchemaHook 4 | from .register_graphene_types_hook import RegisterGrapheneTypesHook 5 | -------------------------------------------------------------------------------- /flask_unchained/bundles/graphene/hooks/register_graphene_mutations_hook.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | from flask_unchained import AppFactoryHook, FlaskUnchained 4 | 5 | from ..object_types import MutationsObjectType 6 | 7 | 8 | class RegisterGrapheneMutationsHook(AppFactoryHook): 9 | """ 10 | Registers Graphene Mutations with the Graphene Bundle. 11 | """ 12 | 13 | name = "graphene_mutations" 14 | """ 15 | The name of this hook. 16 | """ 17 | 18 | bundle_module_names = ["graphene.mutations", "graphene.schema"] 19 | """ 20 | The default module this hook loads from. 21 | 22 | Override by setting the ``graphene_mutations_module_names`` attribute on your 23 | bundle class. 24 | """ 25 | 26 | bundle_override_module_names_attr = "graphene_mutations_module_names" 27 | run_after = ["graphene_types"] 28 | 29 | def process_objects( 30 | self, app: FlaskUnchained, mutations: Dict[str, MutationsObjectType] 31 | ): 32 | """ 33 | Register discovered mutations with the Graphene Bundle. 34 | """ 35 | self.bundle.mutations = mutations 36 | 37 | def type_check(self, obj: Any): 38 | """ 39 | Returns True if ``obj`` is a subclass of 40 | :class:`~flask_unchained.bundles.graphene.MutationsObjectType`. 41 | """ 42 | is_subclass = isinstance(obj, type) and issubclass(obj, MutationsObjectType) 43 | return ( 44 | is_subclass 45 | and obj != MutationsObjectType 46 | and (not hasattr(obj, "Meta") or not getattr(obj.Meta, "abstract", False)) 47 | ) 48 | -------------------------------------------------------------------------------- /flask_unchained/bundles/graphene/hooks/register_graphene_queries_hook.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | from flask_unchained import AppFactoryHook, FlaskUnchained 4 | 5 | from ..object_types import QueriesObjectType 6 | 7 | 8 | class RegisterGrapheneQueriesHook(AppFactoryHook): 9 | """ 10 | Registers Graphene Queries with the Graphene Bundle. 11 | """ 12 | 13 | name = "graphene_queries" 14 | """ 15 | The name of this hook. 16 | """ 17 | 18 | bundle_module_names = ["graphene.queries", "graphene.schema"] 19 | """ 20 | The default module this hook loads from. 21 | 22 | Override by setting the ``graphene_queries_module_names`` attribute on your 23 | bundle class. 24 | """ 25 | 26 | bundle_override_module_names_attr = "graphene_queries_module_names" 27 | run_after = ["graphene_types"] 28 | 29 | def process_objects(self, app: FlaskUnchained, queries: Dict[str, QueriesObjectType]): 30 | """ 31 | Register discovered queries with the Graphene Bundle. 32 | """ 33 | self.bundle.queries = queries 34 | 35 | def type_check(self, obj: Any): 36 | """ 37 | Returns True if ``obj`` is a subclass of 38 | :class:`~flask_unchained.bundles.graphene.QueriesObjectType`. 39 | """ 40 | is_subclass = isinstance(obj, type) and issubclass(obj, QueriesObjectType) 41 | return ( 42 | is_subclass 43 | and obj != QueriesObjectType 44 | and (not hasattr(obj, "Meta") or not getattr(obj.Meta, "abstract", False)) 45 | ) 46 | -------------------------------------------------------------------------------- /flask_unchained/bundles/graphene/hooks/register_graphene_root_schema_hook.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | import graphene 4 | 5 | from flask_unchained import AppFactoryHook, Bundle, FlaskUnchained 6 | 7 | 8 | class RegisterGrapheneRootSchemaHook(AppFactoryHook): 9 | """ 10 | Creates the root :class:`graphene.Schema` to register with Flask-GraphQL. 11 | """ 12 | 13 | name = "graphene_root_schema" 14 | """ 15 | The name of this hook. 16 | """ 17 | 18 | bundle_module_names = None 19 | run_after = ["graphene_queries", "graphene_mutations"] 20 | 21 | def run_hook( 22 | self, 23 | app: FlaskUnchained, 24 | bundles: List[Bundle], 25 | unchained_config: Optional[Dict[str, Any]] = None, 26 | ) -> None: 27 | """ 28 | Create the root :class:`graphene.Schema` from queries, mutations, and types 29 | discovered by the other hooks and register it with the Graphene Bundle. 30 | """ 31 | mutations = tuple(self.bundle.mutations.values()) 32 | queries = tuple(self.bundle.queries.values()) 33 | types = list(self.bundle.types.values()) 34 | 35 | self.bundle.root_schema = graphene.Schema( 36 | query=queries and type("Queries", queries, {}) or None, 37 | mutation=mutations and type("Mutations", mutations, {}) or None, 38 | types=types or None, 39 | ) 40 | -------------------------------------------------------------------------------- /flask_unchained/bundles/graphene/hooks/register_graphene_types_hook.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | from flask_unchained import AppFactoryHook, FlaskUnchained 4 | 5 | from ..object_types import SQLAlchemyObjectType 6 | 7 | 8 | class RegisterGrapheneTypesHook(AppFactoryHook): 9 | """ 10 | Registers SQLAlchemyObjectTypes with the Graphene Bundle. 11 | """ 12 | 13 | name = "graphene_types" 14 | """ 15 | The name of this hook. 16 | """ 17 | 18 | bundle_module_names = ["graphene.types", "graphene.schema"] 19 | """ 20 | The default module this hook loads from. 21 | 22 | Override by setting the ``graphene_types_module_names`` attribute on your 23 | bundle class. 24 | """ 25 | 26 | bundle_override_module_names_attr = "graphene_types_module_names" 27 | run_after = ["models", "services"] 28 | 29 | def process_objects( 30 | self, app: FlaskUnchained, types: Dict[str, SQLAlchemyObjectType] 31 | ): 32 | self.bundle.types = types 33 | 34 | def type_check(self, obj: Any): 35 | is_subclass = isinstance(obj, type) and issubclass(obj, SQLAlchemyObjectType) 36 | return ( 37 | is_subclass 38 | and obj != SQLAlchemyObjectType 39 | and (not hasattr(obj, "Meta") or not getattr(obj.Meta, "abstract", False)) 40 | ) 41 | -------------------------------------------------------------------------------- /flask_unchained/bundles/graphene/pytest.py: -------------------------------------------------------------------------------- 1 | import graphql 2 | import pytest 3 | 4 | from graphene.test import Client as GrapheneClient 5 | 6 | from flask_unchained import request, unchained 7 | 8 | 9 | def _raise_non_graphql_exceptions(error): 10 | if isinstance(error, graphql.GraphQLError): 11 | return graphql.format_error(error) 12 | 13 | raise error 14 | 15 | 16 | class GraphQLClient(GrapheneClient): 17 | def __init__(self, schema=None, format_error=None, **execute_options): 18 | super().__init__( 19 | schema or unchained.graphene_bundle.root_schema, 20 | format_error=format_error or _raise_non_graphql_exceptions, 21 | **execute_options, 22 | ) 23 | 24 | def execute(self, query, values=None): 25 | return super().execute(query, context_value=request, variable_values=values) 26 | 27 | 28 | @pytest.fixture() 29 | def graphql_client(): 30 | yield GraphQLClient() 31 | -------------------------------------------------------------------------------- /flask_unchained/bundles/mail/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | from .extensions import Mail, mail 4 | 5 | 6 | class MailBundle(Bundle): 7 | """ 8 | The Mail Bundle. 9 | """ 10 | 11 | name = "mail_bundle" 12 | """ 13 | The name of the Mail Bundle. 14 | """ 15 | 16 | command_group_names = ["mail"] 17 | """ 18 | Click groups for the Mail Bundle. 19 | """ 20 | -------------------------------------------------------------------------------- /flask_unchained/bundles/mail/commands.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.cli import cli, click 2 | 3 | from .extensions import mail as mail_ext 4 | 5 | 6 | @cli.group() 7 | def mail(): 8 | """ 9 | Mail commands. 10 | """ 11 | 12 | 13 | @mail.command(name="send-test-email") 14 | @click.option("--to", prompt="To", help="Email address of the recipient.") 15 | @click.option( 16 | "--subject", 17 | prompt="Subject", 18 | default="Hello world from Flask Mail", 19 | help="Email subject.", 20 | ) 21 | def send_test_email(to, subject): 22 | """ 23 | Attempt to send a test email to the given email address. 24 | """ 25 | mail_ext.send(subject, to, template="email/__test_email__.html") 26 | -------------------------------------------------------------------------------- /flask_unchained/bundles/mail/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .mail import Mail 2 | 3 | 4 | mail = Mail() 5 | 6 | 7 | EXTENSIONS = { 8 | "mail": mail, 9 | } 10 | 11 | 12 | __all__ = [ 13 | "mail", 14 | "Mail", 15 | ] 16 | -------------------------------------------------------------------------------- /flask_unchained/bundles/mail/pytest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .extensions import mail 4 | 5 | 6 | @pytest.fixture() 7 | def outbox(): 8 | """ 9 | Fixture to record which messages got sent by the mail extension (if any). 10 | Example Usage:: 11 | 12 | def test_some_view(client, outbox): 13 | r = client.get('some.endpoint.that.sends.mail') 14 | assert len(outbox) == 1 15 | assert outbox[0].subject == "You've got mail!" 16 | """ 17 | with mail.record_messages() as messages: 18 | yield messages 19 | -------------------------------------------------------------------------------- /flask_unchained/bundles/mail/templates/email/__test_email__.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Hello world from Flask Mail!

5 | 6 | 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/oauth/__init__.py: -------------------------------------------------------------------------------- 1 | # monkeypatch for using flask-oauthlib with werkzeug 1.x 2 | import werkzeug 3 | 4 | from werkzeug.http import parse_options_header 5 | from werkzeug.urls import url_decode, url_encode, url_quote 6 | from werkzeug.utils import cached_property 7 | 8 | 9 | setattr(werkzeug, "parse_options_header", parse_options_header) 10 | setattr(werkzeug, "url_decode", url_decode) 11 | setattr(werkzeug, "url_encode", url_encode) 12 | setattr(werkzeug, "url_quote", url_quote) 13 | setattr(werkzeug, "cached_property", cached_property) 14 | # end monkeypatch 15 | 16 | from flask_unchained import Bundle 17 | 18 | from .services import OAuthService 19 | from .views import OAuthController 20 | 21 | 22 | class OAuthBundle(Bundle): 23 | """ 24 | The OAuth Bundle. 25 | """ 26 | 27 | name = "oauth_bundle" 28 | """ 29 | The name of the OAuth Bundle. 30 | """ 31 | -------------------------------------------------------------------------------- /flask_unchained/bundles/oauth/config.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import BundleConfig 2 | 3 | 4 | class Config(BundleConfig): 5 | OAUTH_REMOTE_APP_GITHUB = dict( 6 | base_url="https://api.github.com", 7 | access_token_url="https://github.com/login/oauth/access_token", 8 | access_token_method="POST", 9 | authorize_url="https://github.com/login/oauth/authorize", 10 | request_token_url=None, 11 | request_token_params={"scope": "user:email"}, 12 | ) 13 | 14 | OAUTH_REMOTE_APP_AMAZON = dict( 15 | base_url="https://api.amazon.com", 16 | access_token_url="https://api.amazon.com/auth/o2/token", 17 | access_token_method="POST", 18 | authorize_url="https://www.amazon.com/ap/oa", 19 | request_token_url=None, 20 | request_token_params={"scope": "profile:email"}, 21 | ) 22 | 23 | OAUTH_REMOTE_APP_GITLAB = dict( 24 | base_url="https://gitlab.com/api/v4/user", 25 | access_token_url="https://gitlab.com/oauth/token", 26 | access_token_method="POST", 27 | authorize_url="https://gitlab.com/oauth/authorize", 28 | request_token_url=None, 29 | request_token_params={"scope": "openid read_user"}, 30 | ) 31 | -------------------------------------------------------------------------------- /flask_unchained/bundles/oauth/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .oauth import OAuth 2 | 3 | 4 | oauth = OAuth() 5 | 6 | 7 | EXTENSIONS = { 8 | "oauth": (oauth, ["security"]), 9 | } 10 | 11 | 12 | __all__ = [ 13 | "oauth", 14 | "OAuth", 15 | ] 16 | -------------------------------------------------------------------------------- /flask_unchained/bundles/oauth/extensions/oauth.py: -------------------------------------------------------------------------------- 1 | from flask_oauthlib.client import OAuth as BaseOAuth 2 | 3 | from flask_unchained import session 4 | 5 | 6 | REMOTE_APP_NAME_CONFIG_PREFIX = "OAUTH_REMOTE_APP_" 7 | 8 | 9 | class OAuth(BaseOAuth): 10 | 11 | def init_app(self, app): 12 | super().init_app(app) 13 | 14 | for config_key, remote_app_config in app.config.items(): 15 | if not config_key.startswith(REMOTE_APP_NAME_CONFIG_PREFIX): 16 | continue 17 | 18 | remote_app_name = config_key[len(REMOTE_APP_NAME_CONFIG_PREFIX) :].lower() 19 | remote_app = self.remote_app( 20 | remote_app_name, 21 | **remote_app_config, 22 | app_key=f"OAUTH_{remote_app_name}".upper(), 23 | ) 24 | 25 | remote_app.tokengetter(lambda: session.get("oauth_token")) 26 | -------------------------------------------------------------------------------- /flask_unchained/bundles/oauth/routes.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import ( 2 | controller, 3 | delete, 4 | func, 5 | get, 6 | include, 7 | patch, 8 | post, 9 | prefix, 10 | put, 11 | resource, 12 | rule, 13 | ) 14 | 15 | from .views import OAuthController 16 | 17 | 18 | routes = lambda: [ 19 | controller(OAuthController), 20 | ] 21 | -------------------------------------------------------------------------------- /flask_unchained/bundles/oauth/services.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | from flask_oauthlib.client import OAuthRemoteApp 4 | 5 | from flask_unchained import Service, unchained 6 | 7 | 8 | @unchained.service("oauth_service") 9 | class OAuthService(Service): 10 | def get_user_details(self, provider: OAuthRemoteApp) -> Tuple[str, dict]: 11 | """ 12 | For the given ``provider``, return the user's email address and any 13 | extra data to create the user model with. 14 | """ 15 | if provider.name == "amazon": 16 | data = provider.get("/user/profile").data 17 | return data["email"], {} 18 | 19 | elif provider.name == "github": 20 | data = provider.get("/user").data 21 | return data["email"], {} 22 | elif provider.name == "gitlab": 23 | data = provider.get("user").data 24 | return data["email"], {} 25 | 26 | raise NotImplementedError(f"Unknown OAuth remote app: {provider.name}") 27 | 28 | def on_authorized(self, provider: OAuthRemoteApp) -> None: 29 | """ 30 | Optional callback to add custom behavior upon OAuth authorized. 31 | """ 32 | pass 33 | -------------------------------------------------------------------------------- /flask_unchained/bundles/oauth/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .oauth_controller import OAuthController 2 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | from .decorators import anonymous_user_required, auth_required, auth_required_same_user 4 | from .exceptions import AuthenticationError, SecurityException 5 | from .models import AnonymousUser, Role, User, UserRole 6 | from .services import RoleManager, SecurityService, SecurityUtilsService, UserManager 7 | from .utils import current_user 8 | from .views import SecurityController, UserResource 9 | 10 | 11 | from .extensions import Security, security # isort: skip (must be last) 12 | 13 | 14 | class SecurityBundle(Bundle): 15 | """ 16 | The Security Bundle. Integrates 17 | `Flask Login `_ and 18 | `Flask Principal `_ 19 | with Flask Unchained. 20 | """ 21 | 22 | name = "security_bundle" 23 | """ 24 | The name of the Security Bundle. 25 | """ 26 | 27 | dependencies = ( 28 | "flask_unchained.bundles.controller", 29 | "flask_unchained.bundles.session", 30 | "flask_unchained.bundles.sqlalchemy", 31 | "flask_unchained.bundles.babel", 32 | ) 33 | 34 | command_group_names = ["users", "roles"] 35 | """ 36 | Click groups for the Security Bundle. 37 | """ 38 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/admins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/bundles/security/admins/__init__.py -------------------------------------------------------------------------------- /flask_unchained/bundles/security/admins/role_admin.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.admin import ModelAdmin 2 | from flask_unchained.bundles.admin.templates import details_link, edit_link 3 | 4 | from ..models import Role 5 | 6 | 7 | class RoleAdmin(ModelAdmin): 8 | model = Role 9 | 10 | name = "Roles" 11 | category_name = "Security" 12 | menu_icon_value = "fa fa-key" 13 | 14 | column_searchable_list = ("name",) 15 | column_sortable_list = ("name",) 16 | 17 | column_formatters = dict(name=details_link("role")) 18 | column_formatters_detail = dict(name=edit_link("role")) 19 | 20 | form_columns = ("name",) 21 | 22 | column_details_list = ("id", "name", "created_at", "updated_at") 23 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/babel.cfg: -------------------------------------------------------------------------------- 1 | [python: ^**.py] 2 | 3 | [jinja2: ^security/templates/**.html] 4 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ 5 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .roles import roles 2 | from .users import users 3 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/commands/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from flask_unchained import unchained 4 | from flask_unchained.cli import click 5 | 6 | from ..services import RoleManager, UserManager 7 | 8 | 9 | user_manager: UserManager = unchained.get_local_proxy("user_manager") 10 | role_manager: RoleManager = unchained.get_local_proxy("role_manager") 11 | 12 | 13 | def _query_to_user(query): 14 | kwargs = _query_to_kwargs(query) 15 | user = user_manager.filter_by(**kwargs).one_or_none() 16 | if not user: 17 | click.secho( 18 | f"ERROR: Could not locate a user by {_format_query(query)}", 19 | fg="white", 20 | bg="red", 21 | ) 22 | sys.exit(1) 23 | return user 24 | 25 | 26 | def _query_to_role(query): 27 | kwargs = _query_to_kwargs(query) 28 | role = role_manager.filter_by(**kwargs).one_or_none() 29 | if not role: 30 | click.secho( 31 | f"ERROR: Could not locate a role by {_format_query(query)}", 32 | fg="white", 33 | bg="red", 34 | ) 35 | sys.exit(1) 36 | return role 37 | 38 | 39 | def _query_to_kwargs(query): 40 | return dict(map(str.strip, pair.split("=")) for pair in query.split(",")) 41 | 42 | 43 | def _format_query(query): 44 | return ", ".join(f"{k!s}={v!r}" for k, v in _query_to_kwargs(query).items()) 45 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/decorators/__init__.py: -------------------------------------------------------------------------------- 1 | from .anonymous_user_required import anonymous_user_required 2 | from .auth_required import auth_required 3 | from .auth_required_same_user import auth_required_same_user 4 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/decorators/anonymous_user_required.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from http import HTTPStatus 3 | 4 | from flask import abort, flash, request 5 | 6 | from flask_unchained import redirect 7 | 8 | from ..utils import current_user 9 | 10 | 11 | def anonymous_user_required(*decorator_args, msg=None, category=None, redirect_url=None): 12 | """ 13 | Decorator requiring that there is no user currently logged in. 14 | 15 | Aborts with ``HTTP 403: Forbidden`` if there is an authenticated user. 16 | """ 17 | 18 | def wrapper(fn): 19 | @wraps(fn) 20 | def decorated(*args, **kwargs): 21 | if current_user.is_authenticated: 22 | if request.is_json: 23 | abort(HTTPStatus.FORBIDDEN) 24 | else: 25 | if msg: 26 | flash(msg, category) 27 | return redirect( 28 | "SECURITY_POST_LOGIN_REDIRECT_ENDPOINT", override=redirect_url 29 | ) 30 | return fn(*args, **kwargs) 31 | 32 | return decorated 33 | 34 | if decorator_args and callable(decorator_args[0]): 35 | return wrapper(decorator_args[0]) 36 | return wrapper 37 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/decorators/roles_accepted.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from http import HTTPStatus 3 | 4 | from flask import abort 5 | from flask_principal import Permission, RoleNeed 6 | 7 | 8 | def roles_accepted(*roles): 9 | """ 10 | Decorator which specifies that a user must have at least one of the 11 | specified roles. 12 | 13 | Aborts with HTTP: 403 if the user doesn't have at least one of the roles. 14 | 15 | Example:: 16 | 17 | @app.route('/create_post') 18 | @roles_accepted('ROLE_ADMIN', 'ROLE_EDITOR') 19 | def create_post(): 20 | return 'Create Post' 21 | 22 | The current user must have either the `ROLE_ADMIN` role or `ROLE_EDITOR` 23 | role in order to view the page. 24 | 25 | :param roles: The possible roles. 26 | """ 27 | 28 | def wrapper(fn): 29 | @wraps(fn) 30 | def decorated_view(*args, **kwargs): 31 | perm = Permission(*[RoleNeed(role) for role in roles]) 32 | if not perm.can(): 33 | abort(HTTPStatus.FORBIDDEN) 34 | return fn(*args, **kwargs) 35 | 36 | return decorated_view 37 | 38 | return wrapper 39 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/decorators/roles_required.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from http import HTTPStatus 3 | 4 | from flask import abort 5 | from flask_principal import Permission, RoleNeed 6 | 7 | 8 | def roles_required(*roles): 9 | """ 10 | Decorator which specifies that a user must have all the specified roles. 11 | 12 | Aborts with HTTP 403: Forbidden if the user doesn't have the required roles. 13 | 14 | Example:: 15 | 16 | @app.route('/dashboard') 17 | @roles_required('ROLE_ADMIN', 'ROLE_EDITOR') 18 | def dashboard(): 19 | return 'Dashboard' 20 | 21 | The current user must have both the `ROLE_ADMIN` and `ROLE_EDITOR` roles 22 | in order to view the page. 23 | 24 | :param roles: The required roles. 25 | """ 26 | 27 | def wrapper(fn): 28 | @wraps(fn) 29 | def decorated_view(*args, **kwargs): 30 | perms = [Permission(RoleNeed(role)) for role in roles] 31 | for perm in perms: 32 | if not perm.can(): 33 | abort(HTTPStatus.FORBIDDEN) 34 | return fn(*args, **kwargs) 35 | 36 | return decorated_view 37 | 38 | return wrapper 39 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/exceptions.py: -------------------------------------------------------------------------------- 1 | class SecurityException(Exception): 2 | pass 3 | 4 | 5 | class AuthenticationError(SecurityException): 6 | pass 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .security import Security 2 | 3 | 4 | security = Security() 5 | 6 | 7 | EXTENSIONS = {"security": (security, ["csrf", "db"])} 8 | 9 | 10 | __all__ = [ 11 | "security", 12 | "Security", 13 | ] 14 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .anonymous_user import AnonymousUser 2 | from .role import Role 3 | from .user import User 4 | from .user_role import UserRole 5 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/models/anonymous_user.py: -------------------------------------------------------------------------------- 1 | from flask_login import AnonymousUserMixin 2 | from werkzeug.datastructures import ImmutableList 3 | 4 | 5 | class AnonymousUser(AnonymousUserMixin): 6 | _sa_instance_state = None 7 | 8 | def __init__(self): 9 | self.roles = ImmutableList() 10 | 11 | @property 12 | def id(self): 13 | return None 14 | 15 | def has_role(self, *args): 16 | return False 17 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/models/role.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | from .user_role import UserRole 4 | 5 | 6 | class Role(db.Model): 7 | """ 8 | Base role model. Includes an :attr:`name` column and a many-to-many 9 | relationship with the :class:`~flask_unchained.bundles.security.User` model 10 | via the intermediary :class:`~flask_unchained.bundles.security.UserRole` 11 | join table. 12 | """ 13 | 14 | class Meta: 15 | lazy_mapped = True 16 | repr = ("id", "name") 17 | 18 | name = db.Column(db.String(64), unique=True, index=True) 19 | 20 | role_users = db.relationship( 21 | "UserRole", back_populates="role", cascade="all, delete-orphan" 22 | ) 23 | users = db.association_proxy( 24 | "role_users", "user", creator=lambda user: UserRole(user=user) 25 | ) 26 | 27 | def __hash__(self): 28 | return hash(self.name) 29 | 30 | def __str__(self): 31 | return self.name 32 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/models/user_role.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | 4 | class UserRole(db.Model): 5 | """ 6 | Join table between the :class:`~flask_unchained.bundles.security.User` and 7 | :class:`~flask_unchained.bundles.security.Role` models. 8 | """ 9 | 10 | class Meta: 11 | lazy_mapped = True 12 | repr = ("user_id", "role_id") 13 | 14 | user_id = db.foreign_key("User", primary_key=True) 15 | user = db.relationship("User", back_populates="user_roles") 16 | 17 | role_id = db.foreign_key("Role", primary_key=True) 18 | role = db.relationship("Role", back_populates="role_users") 19 | 20 | def __init__(self, user=None, role=None, **kwargs): 21 | super().__init__(**kwargs) 22 | if user: 23 | self.user = user 24 | if role: 25 | self.role = role 26 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | from .role_serializer import RoleSerializer 2 | from .user_serializer import UserSerializer 3 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/serializers/role_serializer.py: -------------------------------------------------------------------------------- 1 | try: 2 | from flask_unchained.bundles.api import ma 3 | except ImportError: 4 | from py_meta_utils import OptionalClass as ma 5 | 6 | from ..models import Role 7 | 8 | 9 | class RoleSerializer(ma.ModelSerializer): 10 | """ 11 | Marshmallow serializer for the :class:`~flask_unchained.bundles.security.Role` model. 12 | """ 13 | 14 | class Meta: 15 | model = Role 16 | fields = ("id", "name") 17 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/serializers/user_serializer.py: -------------------------------------------------------------------------------- 1 | try: 2 | from flask_unchained.bundles.api import ma 3 | except ImportError: 4 | from py_meta_utils import OptionalClass as ma 5 | 6 | from flask_unchained import injectable 7 | from flask_unchained import lazy_gettext as _ 8 | 9 | from ..models import User 10 | from ..services import UserManager 11 | 12 | 13 | class UserSerializer(ma.ModelSerializer): 14 | """ 15 | Marshmallow serializer for the :class:`~flask_unchained.bundles.security.User` model. 16 | """ 17 | 18 | user_manager: UserManager = injectable 19 | 20 | email = ma.Email(required=True) 21 | password = ma.String(required=True) 22 | roles = ma.Pluck("RoleSerializer", "name", many=True) 23 | 24 | class Meta: 25 | model = User 26 | exclude = ("confirmed_at", "created_at", "updated_at", "user_roles") 27 | dump_only = ("is_active", "roles") 28 | load_only = ("password",) 29 | 30 | @ma.validates("email") 31 | def validate_email(self, email): 32 | existing = self.user_manager.get_by(email=email) 33 | if existing and (self.is_create() or existing != self.instance): 34 | raise ma.ValidationError( 35 | _( 36 | "flask_unchained.bundles.security:error.email_already_associated", 37 | email=email, 38 | ) 39 | ) 40 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .role_manager import RoleManager 2 | from .security_service import SecurityService 3 | from .security_utils_service import SecurityUtilsService 4 | from .user_manager import UserManager 5 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/services/role_manager.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import ModelManager 2 | 3 | from ..models import Role 4 | 5 | 6 | class RoleManager(ModelManager): 7 | """ 8 | :class:`~flask_unchained.bundles.sqlalchemy.ModelManager` for the 9 | :class:`~flask_unchained.bundles.security.Role` model. 10 | """ 11 | 12 | class Meta: 13 | model = Role 14 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/services/user_manager.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import ModelManager 2 | 3 | from ..models import User 4 | 5 | 6 | class UserManager(ModelManager): 7 | """ 8 | :class:`~flask_unchained.bundles.sqlalchemy.ModelManager` for the 9 | :class:`~flask_unchained.bundles.security.User` model. 10 | """ 11 | 12 | class Meta: 13 | model = User 14 | 15 | def create(self, commit: bool = False, **kwargs) -> User: 16 | return super().create(commit=commit, **kwargs) 17 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/signals.py: -------------------------------------------------------------------------------- 1 | import blinker 2 | 3 | 4 | signals = blinker.Namespace() 5 | 6 | user_registered = signals.signal("user-registered") 7 | 8 | user_confirmed = signals.signal("user-confirmed") 9 | 10 | confirm_instructions_sent = signals.signal("confirm-instructions-sent") 11 | 12 | login_instructions_sent = signals.signal("login-instructions-sent") 13 | 14 | password_reset = signals.signal("password-reset") 15 | 16 | password_changed = signals.signal("password-changed") 17 | 18 | reset_password_instructions_sent = signals.signal("password-reset-instructions-sent") 19 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/_macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_form(form) %} 2 | {% set action = kwargs.get('action', url_for(kwargs['endpoint'])) %} 3 |
4 | {{ render_errors(form.errors.get('_error', [])) }} 5 | {% for field in form %} 6 | {{ render_field(field) }} 7 | {% endfor %} 8 |
9 | {% endmacro %} 10 | 11 | {% macro render_field(field) %} 12 | {% set input_type = field.widget.input_type %} 13 | {% if input_type in ('submit', 'hidden') %} 14 | {{ field(**kwargs)|safe }} 15 | {% else %} 16 |

17 | {{ field.label }} 18 | {{ field(**kwargs)|safe }} 19 | {% if field.description %} 20 | {{ field.description }} 21 | {% endif %} 22 | {{ render_errors(field.errors) }} 23 |

24 | {% endif %} 25 | {% endmacro %} 26 | 27 | {% macro render_errors(errors) %} 28 | {% if errors %} 29 |
    30 | {% for error in errors %} 31 |
  • {{ error }}
  • 32 | {% endfor %} 33 |
34 | {% endif %} 35 | {% endmacro %} 36 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/change_password.html: -------------------------------------------------------------------------------- 1 | {% extends 'security/layout.html' %} 2 | 3 | {% from 'security/_macros.html' import render_form %} 4 | 5 | {% block content %} 6 | {{ super() }} 7 |

{{ _('flask_unchained.bundles.security:heading.change_password') }}

8 | {{ render_form(change_password_form, endpoint='security_controller.change_password') }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/email/email_confirmation_instructions.html: -------------------------------------------------------------------------------- 1 | {% extends 'email/layout.html' %} 2 | 3 | {% block content %} 4 |

Please confirm your email through the link below:

5 |

{{ confirmation_link }}

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/email/password_changed_notice.html: -------------------------------------------------------------------------------- 1 | {% extends 'email/layout.html' %} 2 | 3 | {% block content %} 4 |

Your password has been changed.

5 | {% if security.recoverable %} 6 | {% set forgot_url = url_for('security_controller.forgot_password', _external=True) %} 7 |

If you did not change your password, click here to reset it or copy the following URL into your browser:

8 |

{{ forgot_url }}

9 | {% endif %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/email/password_reset_notice.html: -------------------------------------------------------------------------------- 1 | {% extends 'email/layout.html' %} 2 | 3 | {% block content %} 4 |

Your password has been reset.

5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/email/reset_password_instructions.html: -------------------------------------------------------------------------------- 1 | {% extends 'email/layout.html' %} 2 | 3 | {% block content %} 4 |

Click the link below to reset your password:

5 |

{{ reset_link }}

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/email/welcome.html: -------------------------------------------------------------------------------- 1 | {% extends 'email/layout.html' %} 2 | 3 | {% block content %} 4 |

Welcome, {{ user.email }}!

5 | 6 | {% if not security.confirmable %} 7 | {% set login_url = url_for('security_controller.login', _external=True) %} 8 |

You may now login at {{ login_url }}

9 | {% else %} 10 |

Please confirm your email by clicking the link below:

11 |

Confirm my account

12 |

Or copy and paste it into your browser: {{ confirmation_link }}

13 | {% endif %} 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/forgot_password.html: -------------------------------------------------------------------------------- 1 | {% extends 'security/layout.html' %} 2 | 3 | {% from 'security/_macros.html' import render_form %} 4 | 5 | {% block content %} 6 | {{ super() }} 7 |

{{ _('flask_unchained.bundles.security:heading.forgot_password') }}

8 | {{ render_form(forgot_password_form, endpoint='security_controller.forgot_password') }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}Flask Unchained{% endblock %} 8 | 9 | {% block stylesheets %} 10 | {% endblock stylesheets %} 11 | 12 | {% block extra_head %} 13 | {% endblock extra_head %} 14 | 15 | 16 | 17 | {% block body %} 18 | {% block flashes %} 19 | {% with messages = get_flashed_messages(with_categories=True) %} 20 | {% if messages %} 21 |
    22 | {% for category, message in messages %} 23 |
  • {{ message }}
  • 24 | {% endfor %} 25 |
26 | {% endif %} 27 | {% endwith %} 28 | {% endblock %} 29 | 30 | {% block content %} 31 | {% endblock content %} 32 | {% endblock body %} 33 | 34 | {% block javascripts %} 35 | {% endblock javascripts %} 36 | 37 | 38 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'security/layout.html' %} 2 | 3 | {% from 'security/_macros.html' import render_form %} 4 | 5 | {% block content %} 6 | {{ super() }} 7 |

{{ _('flask_unchained.bundles.security:heading.login') }}

8 | {{ render_form(login_user_form, endpoint='security_controller.login') }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/register.html: -------------------------------------------------------------------------------- 1 | {% extends 'security/layout.html' %} 2 | 3 | {% from 'security/_macros.html' import render_form %} 4 | 5 | {% block content %} 6 | {{ super() }} 7 |

{{ _('flask_unchained.bundles.security:heading.register') }}

8 | {{ render_form(register_user_form, endpoint='security_controller.register') }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/reset_password.html: -------------------------------------------------------------------------------- 1 | {% extends 'security/layout.html' %} 2 | 3 | {% from 'security/_macros.html' import render_form %} 4 | 5 | {% block content %} 6 | {{ super() }} 7 |

{{ _('flask_unchained.bundles.security:heading.reset_password') }}

8 | {{ render_form(reset_password_form, 9 | action=url_for('security_controller.reset_password', 10 | token=reset_password_token)) }} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/templates/security/send_confirmation_email.html: -------------------------------------------------------------------------------- 1 | {% extends 'security/layout.html' %} 2 | 3 | {% from 'security/_macros.html' import render_form %} 4 | 5 | {% block content %} 6 | {{ super() }} 7 |

{{ _('flask_unchained.bundles.security:heading.send_confirmation_email') }}

8 | {{ render_form(send_confirmation_form, endpoint='security_controller.send_confirmation_email') }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/translations/en/LC_MESSAGES/flask_unchained.bundles.security.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/bundles/security/translations/en/LC_MESSAGES/flask_unchained.bundles.security.mo -------------------------------------------------------------------------------- /flask_unchained/bundles/security/unchained_config.py: -------------------------------------------------------------------------------- 1 | # placing this file here allows the flask babel commands to work from this directory 2 | 3 | BUNDLES = [ 4 | "flask_unchained.bundles.api", 5 | "flask_unchained.bundles.mail", 6 | "flask_unchained.bundles.sqlalchemy", 7 | "flask_unchained.bundles.security", 8 | ] 9 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/utils.py: -------------------------------------------------------------------------------- 1 | from flask_login.utils import _get_user 2 | from werkzeug.local import LocalProxy 3 | 4 | 5 | current_user = LocalProxy(_get_user) 6 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .security_controller import SecurityController 2 | 3 | 4 | try: 5 | from .user_resource import UserResource 6 | except ImportError: 7 | UserResource = None 8 | -------------------------------------------------------------------------------- /flask_unchained/bundles/security/views/user_resource.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import injectable 2 | from flask_unchained.bundles.api import ModelResource 3 | from flask_unchained.bundles.controller.constants import CREATE, GET, PATCH 4 | 5 | from ..decorators import anonymous_user_required, auth_required_same_user 6 | from ..models import User 7 | from ..services import SecurityService 8 | 9 | 10 | class UserResource(ModelResource): 11 | """ 12 | RESTful API resource for the :class:`~flask_unchained.bundles.security.User` model. 13 | """ 14 | 15 | security_service: SecurityService = injectable 16 | 17 | class Meta: 18 | model = User 19 | include_methods = {CREATE, GET, PATCH} 20 | method_decorators = { 21 | CREATE: [anonymous_user_required], 22 | GET: [auth_required_same_user], 23 | PATCH: [auth_required_same_user], 24 | } 25 | 26 | def create(self, user, errors): 27 | if errors: 28 | return self.errors(errors) 29 | 30 | user_logged_in = self.security_service.register_user(user) 31 | if user_logged_in: 32 | return self.created( 33 | {"token": user.get_auth_token(), "user": user}, commit=False 34 | ) 35 | return self.created({"user": user}, commit=False) 36 | -------------------------------------------------------------------------------- /flask_unchained/bundles/session/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | from .extensions import Session, session 4 | 5 | 6 | class SessionBundle(Bundle): 7 | """ 8 | The Session Bundle. Integrates 9 | `Flask Session `_ with Flask Unchained. 10 | """ 11 | 12 | _has_views = False 13 | -------------------------------------------------------------------------------- /flask_unchained/bundles/session/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .session import Session 2 | 3 | 4 | session = Session() 5 | 6 | 7 | EXTENSIONS = { 8 | "session": (session, ["db"]), 9 | } 10 | 11 | 12 | __all__ = [ 13 | "session", 14 | "Session", 15 | ] 16 | -------------------------------------------------------------------------------- /flask_unchained/bundles/session/extensions/session.py: -------------------------------------------------------------------------------- 1 | import dill 2 | 3 | from flask_session import Session as BaseSession 4 | 5 | from ..session_interfaces import SqlAlchemySessionInterface 6 | 7 | 8 | class Session(BaseSession): 9 | """ 10 | The `Session` extension:: 11 | 12 | from flask_unchained.bundles.session import session 13 | """ 14 | 15 | def init_app(self, app): 16 | super().init_app(app) 17 | app.session_interface.serializer = dill 18 | 19 | def _get_interface(self, app): 20 | if app.config.SESSION_TYPE == "sqlalchemy": 21 | return SqlAlchemySessionInterface( 22 | db=app.extensions["sqlalchemy"], 23 | table=app.config.SESSION_SQLALCHEMY_TABLE, 24 | key_prefix=app.config.SESSION_KEY_PREFIX, 25 | use_signer=app.config.SESSION_USE_SIGNER, 26 | permanent=app.config.SESSION_PERMANENT, 27 | model_class=app.config.SESSION_SQLALCHEMY_MODEL, 28 | sid_length=app.config.SESSION_ID_LENGTH, 29 | ) 30 | 31 | return super()._get_interface(app) 32 | -------------------------------------------------------------------------------- /flask_unchained/bundles/session/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .register_session_model_hook import RegisterSessionModelHook 2 | 3 | 4 | __all__ = [ 5 | "RegisterSessionModelHook", 6 | ] 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/session/session_interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_session.sessions import ( 2 | FileSystemSessionInterface, 3 | MemcachedSessionInterface, 4 | MongoDBSessionInterface, 5 | NullSessionInterface, 6 | RedisSessionInterface, 7 | ) 8 | 9 | from .sqla import SqlAlchemySessionInterface 10 | 11 | 12 | __all__ = [ 13 | "NullSessionInterface", 14 | "RedisSessionInterface", 15 | "MemcachedSessionInterface", 16 | "FileSystemSessionInterface", 17 | "MongoDBSessionInterface", 18 | "SqlAlchemySessionInterface", 19 | ] 20 | -------------------------------------------------------------------------------- /flask_unchained/bundles/session/session_interfaces/sqla.py: -------------------------------------------------------------------------------- 1 | from flask_session import SqlAlchemySessionInterface as BaseSqlAlchemySessionInterface 2 | 3 | 4 | try: 5 | from sqlalchemy import types 6 | except ImportError: 7 | types = None 8 | 9 | 10 | class SqlAlchemySessionInterface(BaseSqlAlchemySessionInterface): 11 | def __init__( 12 | self, 13 | db, 14 | table, 15 | key_prefix, 16 | use_signer=False, 17 | permanent=True, 18 | model_class=None, 19 | sid_length=32, 20 | ): 21 | self.db = db 22 | self.key_prefix = key_prefix 23 | self.use_signer = use_signer 24 | self.permanent = permanent 25 | self.has_same_site_capability = hasattr(self, "get_cookie_samesite") 26 | self.sid_length = sid_length 27 | 28 | if model_class is not None: 29 | self.sql_session_model = model_class 30 | return 31 | 32 | class Session(db.Model): 33 | __tablename__ = table 34 | 35 | id = db.Column(db.Integer, primary_key=True) 36 | session_id = db.Column(db.String(255), unique=True) 37 | data = db.Column(db.LargeBinary) 38 | expiry = db.Column(types.DateTime, nullable=True) 39 | 40 | def __init__(self, session_id, data, expiry): 41 | self.session_id = session_id 42 | self.data = data 43 | self.expiry = expiry 44 | 45 | def __repr__(self): 46 | return "" % self.data 47 | 48 | self.sql_session_model = Session 49 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | from sqlalchemy_unchained import ValidationError, ValidationErrors 3 | 4 | from .base_model import BaseModel 5 | from .extensions import Migrate, SQLAlchemyUnchained, db, migrate 6 | from .forms import ModelForm, QuerySelectField, QuerySelectMultipleField 7 | from .model_registry import UnchainedModelRegistry 8 | from .services import ModelManager, SessionManager 9 | 10 | 11 | class SQLAlchemyBundle(Bundle): 12 | """ 13 | The SQLAlchemy Bundle. Integrates `SQLAlchemy `_ 14 | and `Flask-Migrate `_ 15 | with Flask Unchained. 16 | """ 17 | 18 | name = "sqlalchemy_bundle" 19 | """ 20 | The name of the SQLAlchemy Bundle. 21 | """ 22 | 23 | command_group_names = ["db"] 24 | """ 25 | Click groups for the SQLAlchemy Bundle. 26 | """ 27 | 28 | _has_views = False 29 | 30 | def __init__(self): 31 | self.models = {} 32 | """ 33 | A lookup of model classes keyed by class name. 34 | """ 35 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/alembic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/flask_unchained/bundles/sqlalchemy/alembic/__init__.py -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/alembic/migrations.py: -------------------------------------------------------------------------------- 1 | # import our sqlalchemy types into generated migrations when needed 2 | # http://alembic.zzzcomputing.com/en/latest/autogenerate.html#affecting-the-rendering-of-types-themselves 3 | def render_migration_item(type_, obj, autogen_context): 4 | if obj is None: 5 | return False 6 | 7 | sqla_bundle = "flask_unchained.bundles.sqlalchemy.sqla.types" 8 | if sqla_bundle in obj.__module__: 9 | autogen_context.imports.add(f"import {sqla_bundle} as sqla_bundle") 10 | return f"sqla_bundle.{repr(obj)}" 11 | 12 | elif not ( 13 | obj.__module__.startswith("sqlalchemy") 14 | or obj.__module__.startswith("flask_unchained") 15 | ): 16 | autogen_context.imports.add(f"import {obj.__module__}") 17 | return f"{obj.__module__}.{repr(obj)}" 18 | 19 | return False 20 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/alembic/reversible_op.py: -------------------------------------------------------------------------------- 1 | from alembic.operations import MigrateOperation 2 | 3 | 4 | # http://alembic.zzzcomputing.com/en/latest/cookbook.html#replaceable-objects 5 | class ReversibleOp(MigrateOperation): 6 | def __init__(self, target): 7 | self.target = target 8 | 9 | @classmethod 10 | def invoke_for_target(cls, operations, target): 11 | op = cls(target) 12 | return operations.invoke(op) 13 | 14 | def reverse(self): 15 | raise NotImplementedError() 16 | 17 | @classmethod 18 | def _get_object_from_version(cls, operations, ident): 19 | version, objname = ident.split(".") 20 | 21 | module = operations.get_context().script.get_revision(version).module 22 | obj = getattr(module, objname) 23 | return obj 24 | 25 | @classmethod 26 | def replace(cls, operations, target, replaces=None, replace_with=None): 27 | if replaces: 28 | old_obj = cls._get_object_from_version(operations, replaces) 29 | drop_old = cls(old_obj).reverse() 30 | create_new = cls(target) 31 | elif replace_with: 32 | old_obj = cls._get_object_from_version(operations, replace_with) 33 | drop_old = cls(target).reverse() 34 | create_new = cls(old_obj) 35 | else: 36 | raise TypeError("replaces or replace_with is required") 37 | 38 | operations.invoke(drop_old) 39 | operations.invoke(create_new) 40 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/alembic/templates/flask/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/alembic/templates/flask/alembic.ini.mako: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/alembic/templates/flask/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | ${'\n' + '\n'.join(migration_variables) + '\n' if migration_variables else ''} 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/base_model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declared_attr 2 | 3 | from flask_sqlalchemy_unchained import BaseModel as _BaseModel 4 | from flask_sqlalchemy_unchained import Query as BaseQuery 5 | from sqlalchemy_unchained import ModelMetaOptionsFactory 6 | 7 | from ...bundles.babel import lazy_gettext as _ 8 | from ...string_utils import pluralize, title_case 9 | 10 | 11 | class QueryAliasDescriptor: 12 | def __get__(self, instance, cls): 13 | return cls.query 14 | 15 | 16 | class BaseModel(_BaseModel): 17 | """ 18 | Base model class 19 | """ 20 | 21 | _meta_options_factory_class = ModelMetaOptionsFactory 22 | gettext_fn = _ 23 | 24 | query: BaseQuery 25 | q: BaseQuery = QueryAliasDescriptor() 26 | 27 | @declared_attr 28 | def __plural__(self): 29 | return pluralize(self.__name__) 30 | 31 | @declared_attr 32 | def __label__(self): 33 | return title_case(self.__name__) 34 | 35 | @declared_attr 36 | def __plural_label__(self): 37 | return title_case(pluralize(self.__name__)) 38 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/commands.py: -------------------------------------------------------------------------------- 1 | from alembic import command as alembic 2 | from flask.cli import with_appcontext 3 | from flask_migrate.cli import db 4 | 5 | from flask_unchained import click, unchained 6 | 7 | from .extensions import SQLAlchemyUnchained, migrate 8 | 9 | 10 | db_ext: SQLAlchemyUnchained = unchained.get_local_proxy("db") 11 | 12 | 13 | @db.command("drop") 14 | @click.option("--force", is_flag=True, expose_value=True, prompt="Drop DB tables?") 15 | @with_appcontext 16 | def drop_command(force): 17 | """Drop database tables.""" 18 | if not force: 19 | exit("Cancelled.") 20 | 21 | click.echo("Dropping DB tables.") 22 | drop_all() 23 | 24 | click.echo("Done.") 25 | 26 | 27 | def drop_all(): 28 | db_ext.drop_all() 29 | db_ext.engine.execute("DROP TABLE IF EXISTS alembic_version;") 30 | 31 | 32 | @db.command("reset") 33 | @click.option( 34 | "--force", 35 | is_flag=True, 36 | expose_value=True, 37 | prompt="Drop DB tables and run migrations?", 38 | ) 39 | @with_appcontext 40 | def reset_command(force): 41 | """Drop database tables and run migrations.""" 42 | if not force: 43 | exit("Cancelled.") 44 | 45 | click.echo("Dropping DB tables.") 46 | drop_all() 47 | 48 | click.echo("Running DB migrations.") 49 | alembic.upgrade(migrate.get_config(None), "head") 50 | 51 | click.echo("Done.") 52 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .migrate import Migrate 2 | from .sqlalchemy_unchained import SQLAlchemyUnchained 3 | 4 | 5 | db = SQLAlchemyUnchained() 6 | migrate = Migrate() 7 | 8 | 9 | EXTENSIONS = { 10 | "db": db, 11 | "migrate": (migrate, ["db"]), 12 | } 13 | 14 | 15 | __all__ = [ 16 | "db", 17 | "SQLAlchemyUnchained", 18 | "migrate", 19 | "Migrate", 20 | ] 21 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/extensions/migrate.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask_migrate import Config 4 | from flask_migrate import Migrate as BaseMigrate 5 | 6 | from flask_unchained import FlaskUnchained, injectable, unchained 7 | 8 | 9 | class Migrate(BaseMigrate): 10 | """ 11 | The `Migrate` extension:: 12 | 13 | from flask_unchained.bundles.sqlalchemy import migrate 14 | """ 15 | 16 | @unchained.inject("db") 17 | def init_app(self, app: FlaskUnchained, db=injectable): 18 | alembic_config = app.config.setdefault("ALEMBIC", {}) 19 | alembic_config.setdefault("script_location", "db/migrations") 20 | 21 | self.configure(Migrate.configure_alembic_template_directory) 22 | 23 | super().init_app( 24 | app, 25 | db=db, 26 | directory=alembic_config.get("script_location"), 27 | **app.config.get("ALEMBIC_CONTEXT", {}), 28 | ) 29 | 30 | @staticmethod 31 | def configure_alembic_template_directory(config: Config): 32 | bundle_root = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 33 | template_dir = os.path.join(bundle_root, "alembic", "templates") 34 | setattr(config, "get_template_directory", lambda: template_dir) 35 | return config 36 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .register_models_hook import RegisterModelsHook 2 | 3 | 4 | __all__ = [ 5 | "RegisterModelsHook", 6 | ] 7 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/meta_options.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | from flask_unchained import unchained 4 | from py_meta_utils import McsArgs, MetaOption 5 | from sqlalchemy_unchained import ModelMetaOptionsFactory as BaseModelMetaOptionsFactory 6 | 7 | 8 | class ModelMetaOption(MetaOption): 9 | """ 10 | The model class for a class. 11 | """ 12 | 13 | def __init__(self): 14 | super().__init__("model", default=None, inherit=True) 15 | 16 | def get_value(self, Meta: Type[object], base_classes_meta, mcs_args: McsArgs): 17 | value = super().get_value(Meta, base_classes_meta, mcs_args) 18 | if value and unchained._models_initialized: 19 | value = unchained.sqlalchemy_bundle.models.get(value.__name__, value) 20 | return value 21 | 22 | def check_value(self, value, mcs_args: McsArgs): 23 | if mcs_args.Meta.abstract: 24 | return 25 | 26 | from .base_model import BaseModel 27 | 28 | if not (isinstance(value, type) and issubclass(value, BaseModel)): 29 | raise TypeError(f"{mcs_args.name} is missing the model Meta attribute") 30 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/model_factory.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from flask_unchained import injectable, unchained 4 | 5 | from .services import SessionManager 6 | 7 | 8 | class ModelFactory(factory.Factory): 9 | class Meta: 10 | abstract = True 11 | 12 | @classmethod 13 | @unchained.inject("session_manager") 14 | def _create( 15 | cls, model_class, *args, session_manager: SessionManager = injectable, **kwargs 16 | ): 17 | # make sure we get the correct mapped class 18 | model_class = unchained.sqlalchemy_bundle.models[model_class.__name__] 19 | 20 | # try to query for existing by primary key or unique column(s) 21 | filter_kwargs = {} 22 | for col in model_class.__mapper__.columns: 23 | if col.name in kwargs and (col.primary_key or col.unique): 24 | filter_kwargs[col.name] = kwargs[col.name] 25 | 26 | # otherwise try by all simple type values 27 | if not filter_kwargs: 28 | filter_kwargs = { 29 | k: v 30 | for k, v in kwargs.items() 31 | if "__" not in k and (v is None or isinstance(v, (bool, int, str, float))) 32 | } 33 | 34 | instance = ( 35 | model_class.query.filter_by(**filter_kwargs).one_or_none() 36 | if filter_kwargs 37 | else None 38 | ) 39 | 40 | if not instance: 41 | instance = model_class(*args, **kwargs) 42 | session_manager.save(instance, commit=True) 43 | return instance 44 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/model_registry.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from sqlalchemy_unchained import ModelRegistry 4 | 5 | 6 | class UnchainedModelRegistry(ModelRegistry): 7 | enable_lazy_mapping = True 8 | 9 | def _reset(self): 10 | for model in self._registry: 11 | for model_module in self._registry[model]: 12 | try: 13 | del sys.modules[model_module] 14 | except KeyError: 15 | pass 16 | 17 | super()._reset() 18 | 19 | 20 | ModelRegistry.set_singleton_class(UnchainedModelRegistry) 21 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/pytest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | from .model_registry import UnchainedModelRegistry # isort: skip (required import) 5 | 6 | 7 | @pytest.fixture(autouse=True, scope="session") 8 | def db(app): 9 | # FIXME definitely need to create test database if it doesn't exist 10 | db_ext = app.unchained.extensions.db 11 | # FIXME might need to reflect the current db, drop, and then create... 12 | db_ext.create_all() 13 | yield db_ext 14 | db_ext.drop_all() 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def db_session(db): 19 | connection = db.engine.connect() 20 | transaction = connection.begin() 21 | session = db.create_scoped_session(options=dict(bind=connection, binds={})) 22 | db.session = session 23 | 24 | try: 25 | yield session 26 | finally: 27 | transaction.rollback() 28 | connection.close() 29 | session.remove() 30 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .model_manager import ModelManager 2 | from .session_manager import SessionManager 3 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/services/model_manager.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Service, unchained 2 | from flask_unchained.di import ServiceMetaclass, ServiceMetaOptionsFactory 3 | from sqlalchemy_unchained.model_manager import ModelManager as BaseModelManager 4 | from sqlalchemy_unchained.model_manager import ( 5 | ModelManagerMetaclass as BaseModelManagerMetaclass, 6 | ) 7 | 8 | from ..meta_options import ModelMetaOption 9 | 10 | 11 | class ModelManagerMetaOptionsFactory(ServiceMetaOptionsFactory): 12 | _allowed_properties = ["model"] 13 | _options = ServiceMetaOptionsFactory._options + [ModelMetaOption] 14 | 15 | def __init__(self): 16 | super().__init__() 17 | self._model = None 18 | 19 | @property 20 | def model(self): 21 | # make sure to always return the correct mapped model class 22 | if not unchained._models_initialized or not self._model: 23 | return self._model 24 | return unchained.sqlalchemy_bundle.models[self._model.__name__] 25 | 26 | @model.setter 27 | def model(self, model): 28 | self._model = model 29 | 30 | 31 | class ModelManagerMetaclass(ServiceMetaclass, BaseModelManagerMetaclass): 32 | pass 33 | 34 | 35 | class ModelManager(BaseModelManager, Service, metaclass=ModelManagerMetaclass): 36 | """ 37 | Base class for database model manager services. 38 | """ 39 | 40 | _meta_options_factory_class = ModelManagerMetaOptionsFactory 41 | 42 | class Meta: 43 | abstract = True 44 | model = None 45 | 46 | 47 | __all__ = [ 48 | "ModelManager", 49 | "ModelManagerMetaclass", 50 | "ModelManagerMetaOptionsFactory", 51 | ] 52 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/services/session_manager.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Service 2 | from flask_unchained.di import ServiceMetaclass 3 | from sqlalchemy_unchained.session_manager import SessionManager as BaseSessionManager 4 | from sqlalchemy_unchained.session_manager import ( 5 | SessionManagerMetaclass as BaseSessionManagerMetaclass, 6 | ) 7 | 8 | 9 | class SessionManagerMetaclass(ServiceMetaclass, BaseSessionManagerMetaclass): 10 | pass 11 | 12 | 13 | class SessionManager(BaseSessionManager, Service, metaclass=SessionManagerMetaclass): 14 | """ 15 | The database session manager service. 16 | """ 17 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/sqla/__init__.py: -------------------------------------------------------------------------------- 1 | # alias common names 2 | from sqlalchemy.ext.associationproxy import association_proxy 3 | from sqlalchemy.ext.declarative import declared_attr 4 | from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property 5 | 6 | # a bit of hackery to make type-hinting in PyCharm work correctly 7 | from sqlalchemy.orm.relationships import RelationshipProperty 8 | 9 | from .column import Column 10 | from .events import attach_events, on, slugify 11 | from .foreign_key import foreign_key 12 | from .types import BigInteger, DateTime 13 | 14 | 15 | class _relationship_type_hinter_(RelationshipProperty): 16 | # implement __call__ to silence PyCharm's "not callable" warning 17 | def __call__(self, *args, **kwargs): 18 | pass 19 | 20 | 21 | def _column_type_hinter_( 22 | name=None, 23 | type=None, 24 | *args, 25 | autoincrement="auto", 26 | default=None, 27 | doc=None, 28 | key=None, 29 | index=False, 30 | info=None, 31 | nullable=False, 32 | onupdate=None, 33 | primary_key=False, 34 | server_default=None, 35 | server_onupdate=None, 36 | quote=None, 37 | unique=False, 38 | system=False, 39 | ): 40 | pass 41 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/sqla/column.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column as BaseColumn 2 | 3 | 4 | class Column(BaseColumn): 5 | """ 6 | Overridden to make nullable False by default 7 | """ 8 | 9 | inherit_cache = True 10 | 11 | def __init__(self, *args, nullable=False, **kwargs): 12 | super().__init__(*args, nullable=nullable, **kwargs) 13 | -------------------------------------------------------------------------------- /flask_unchained/bundles/sqlalchemy/sqla/types.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | from sqlalchemy import types 4 | from sqlalchemy.dialects import sqlite 5 | 6 | 7 | class BigInteger(types.TypeDecorator): 8 | impl = types.BigInteger().with_variant(sqlite.INTEGER(), "sqlite") 9 | cache_ok = True 10 | 11 | @property 12 | def python_type(self): 13 | return int 14 | 15 | def __repr__(self): 16 | return "BigInteger()" 17 | 18 | 19 | class DateTime(types.TypeDecorator): 20 | impl = types.DateTime 21 | cache_ok = True 22 | 23 | def __init__(self, timezone=True): 24 | super().__init__(timezone=True) # force timezone always True 25 | 26 | def process_bind_param(self, value, dialect=None): 27 | if value is not None: 28 | if value.tzinfo is None: 29 | raise ValueError("Cannot persist timezone-naive datetime") 30 | return value.astimezone(dt.timezone.utc) 31 | 32 | def process_result_value(self, value, dialect=None): 33 | if not value: 34 | return 35 | if not value.tzinfo: 36 | return value.replace(tzinfo=dt.timezone.utc) 37 | return value.astimezone(tz=dt.timezone.utc) 38 | 39 | @property 40 | def python_type(self): 41 | return dt.datetime 42 | -------------------------------------------------------------------------------- /flask_unchained/bundles/webpack/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | from .extensions import Webpack, webpack 4 | 5 | 6 | class WebpackBundle(Bundle): 7 | """ 8 | The Webpack Bundle. 9 | """ 10 | 11 | name = "webpack_bundle" 12 | """ 13 | The name of the Webpack Bundle. 14 | """ 15 | -------------------------------------------------------------------------------- /flask_unchained/bundles/webpack/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask_unchained import BundleConfig 4 | 5 | 6 | class Config(BundleConfig): 7 | """ 8 | Default configuration options for the Webpack Bundle. 9 | """ 10 | 11 | WEBPACK_MANIFEST_PATH = ( 12 | None 13 | if not BundleConfig.current_app.static_folder 14 | else os.path.join( 15 | BundleConfig.current_app.static_folder, "assets", "manifest.json" 16 | ) 17 | ) 18 | """ 19 | The full path to the ``manifest.json`` file generated by Webpack Manifest Plugin. 20 | """ 21 | 22 | 23 | class ProdConfig(Config): 24 | """ 25 | Default production configuration options for the Webpack Bundle. 26 | """ 27 | 28 | # use relative paths by default, ie, the same host as the backend 29 | WEBPACK_ASSETS_HOST = "" 30 | """ 31 | The host where Webpack assets are served from. Defaults to the same server as the 32 | backend. 33 | """ 34 | 35 | 36 | class StagingConfig(ProdConfig): 37 | """ 38 | Inherit production settings. 39 | """ 40 | -------------------------------------------------------------------------------- /flask_unchained/bundles/webpack/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .webpack import Webpack 2 | 3 | 4 | webpack = Webpack() 5 | 6 | 7 | EXTENSIONS = { 8 | "webpack": webpack, 9 | } 10 | 11 | 12 | __all__ = [ 13 | "webpack", 14 | "Webpack", 15 | ] 16 | -------------------------------------------------------------------------------- /flask_unchained/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .clean import clean 2 | from .lint import lint 3 | from .new import new 4 | from .shell import shell 5 | from .unchained import unchained_group as unchained 6 | from .urls import url, urls 7 | -------------------------------------------------------------------------------- /flask_unchained/commands/clean.py: -------------------------------------------------------------------------------- 1 | # This command is adapted to click from Flask-Script 0.4.0 2 | import os 3 | 4 | from flask_unchained.cli import click 5 | 6 | 7 | @click.command() 8 | def clean(): 9 | """ 10 | Recursively remove \\*.pyc and \\*.pyo files. 11 | """ 12 | for dirpath, _, filenames in os.walk("."): 13 | for filename in filenames: 14 | if filename.endswith(".pyc") or filename.endswith(".pyo"): 15 | filepath = os.path.join(dirpath, filename) 16 | click.echo(f"Removing {filepath}") 17 | os.remove(filepath) 18 | -------------------------------------------------------------------------------- /flask_unchained/commands/lint.py: -------------------------------------------------------------------------------- 1 | # This command is adapted to click from Flask-Script 0.4.0 2 | import os 3 | 4 | from flask_unchained.cli import click 5 | 6 | 7 | @click.command() 8 | @click.option( 9 | "-f", 10 | "--fix-imports", 11 | default=False, 12 | is_flag=True, 13 | help="Fix imports using isort, before linting", 14 | ) 15 | def lint(fix_imports): 16 | """ 17 | Run flake8. 18 | """ 19 | from glob import glob 20 | from subprocess import call 21 | 22 | # FIXME: should support passing these in an option 23 | skip = [ 24 | "ansible", 25 | "db", 26 | "flask_sessions", 27 | "node_modules", 28 | "requirements", 29 | ] 30 | root_files = glob("*.py") 31 | root_dirs = [name for name in next(os.walk("."))[1] if not name.startswith(".")] 32 | files_and_dirs = [x for x in root_files + root_dirs if x not in skip] 33 | 34 | def execute_tool(desc, *args): 35 | command = list(args) + files_and_dirs 36 | click.echo(f"{desc}: {' '.join(command)}") 37 | ret = call(command) 38 | if ret != 0: 39 | exit(ret) 40 | 41 | if fix_imports: 42 | execute_tool("Fixing import order", "isort", "-rc") 43 | execute_tool("Checking code style", "flake8") 44 | -------------------------------------------------------------------------------- /flask_unchained/config.py: -------------------------------------------------------------------------------- 1 | from py_meta_utils import OptionalClass 2 | 3 | from .utils import get_boolean_env 4 | 5 | 6 | class BundleConfigMetaclass(type): 7 | current_app = OptionalClass() 8 | 9 | def _set_current_app(cls, app): 10 | cls.current_app = app 11 | 12 | 13 | class BundleConfig(metaclass=BundleConfigMetaclass): 14 | """ 15 | Base class for configuration settings. Allows access to the 16 | app-under-construction as it's currently configured. Example usage:: 17 | 18 | # your_bundle_root/config.py 19 | 20 | import os 21 | 22 | from flask_unchained import BundleConfig 23 | 24 | class Config(BundleConfig): 25 | SHOULD_PRETTY_PRINT_JSON = BundleConfig.current_app.config.DEBUG 26 | """ 27 | 28 | 29 | class _ConfigDefaults: 30 | DEBUG = get_boolean_env("FLASK_DEBUG", False) 31 | 32 | 33 | class _DevConfigDefaults: 34 | DEBUG = get_boolean_env("FLASK_DEBUG", True) 35 | 36 | 37 | class _TestConfigDefaults: 38 | TESTING = True 39 | """ 40 | Tell Flask we're in testing mode. 41 | """ 42 | 43 | WTF_CSRF_ENABLED = False 44 | """ 45 | Disable CSRF tokens in tests. 46 | """ 47 | 48 | 49 | __all__ = [ 50 | "BundleConfig", 51 | ] 52 | -------------------------------------------------------------------------------- /flask_unchained/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | DEV 3 | ~~~ 4 | 5 | .. data:: DEV 6 | 7 | Used to specify the development environment. 8 | 9 | PROD 10 | ~~~~ 11 | 12 | .. data:: PROD 13 | 14 | Used to specify the production environment. 15 | 16 | STAGING 17 | ~~~~~~~ 18 | 19 | .. data:: STAGING 20 | 21 | Used to specify the staging environment. 22 | 23 | TEST 24 | ~~~~ 25 | 26 | .. data:: TEST 27 | 28 | Used to specify the test environment. 29 | """ 30 | 31 | DEV = "development" 32 | PROD = "production" 33 | STAGING = "staging" 34 | TEST = "test" 35 | 36 | ENV_ALIASES = {"dev": DEV, "prod": PROD} 37 | VALID_ENVS = [DEV, PROD, STAGING, TEST] 38 | 39 | _INJECT_CLS_ATTRS = "__inject_cls_attrs__" 40 | _DI_AUTOMATICALLY_HANDLED = "__di_automatically_handled__" 41 | 42 | 43 | __all__ = [ 44 | "DEV", 45 | "PROD", 46 | "STAGING", 47 | "TEST", 48 | "ENV_ALIASES", 49 | "VALID_ENVS", 50 | ] 51 | -------------------------------------------------------------------------------- /flask_unchained/exceptions.py: -------------------------------------------------------------------------------- 1 | class BundleNotFoundError(Exception): 2 | pass 3 | 4 | 5 | class CWDImportError(ImportError): 6 | pass 7 | 8 | 9 | class NameCollisionError(Exception): 10 | pass 11 | 12 | 13 | class ServiceUsageError(Exception): 14 | pass 15 | 16 | 17 | class UnchainedConfigNotFoundError(Exception): 18 | pass 19 | -------------------------------------------------------------------------------- /flask_unchained/forms/__init__.py: -------------------------------------------------------------------------------- 1 | from . import fields, validators 2 | from .flask_form import FlaskForm 3 | -------------------------------------------------------------------------------- /flask_unchained/forms/_compat.py: -------------------------------------------------------------------------------- 1 | # compatibility with wtforms v3 for wtforms-alchemy 2 | 3 | text_type = str 4 | string_types = (str,) 5 | -------------------------------------------------------------------------------- /flask_unchained/forms/fields.py: -------------------------------------------------------------------------------- 1 | from wtforms.fields import * 2 | 3 | 4 | try: 5 | from wtforms.fields.html5 import * 6 | except ImportError: 7 | pass 8 | from flask_wtf.file import FileField 9 | -------------------------------------------------------------------------------- /flask_unchained/forms/validators.py: -------------------------------------------------------------------------------- 1 | from flask_wtf.file import FileAllowed, FileRequired, FileSize 2 | from wtforms.validators import * 3 | -------------------------------------------------------------------------------- /flask_unchained/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .configure_app_hook import ConfigureAppHook 2 | from .init_extensions_hook import InitExtensionsHook 3 | from .register_commands_hook import RegisterCommandsHook 4 | from .register_extensions_hook import RegisterExtensionsHook 5 | from .register_services_hook import RegisterServicesHook 6 | from .run_hooks_hook import RunHooksHook 7 | from .views_hook import ViewsHook 8 | -------------------------------------------------------------------------------- /flask_unchained/hooks/views_hook.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import AppFactoryHook 2 | 3 | 4 | class ViewsHook(AppFactoryHook): 5 | """ 6 | Allows configuring bundle views modules. 7 | """ 8 | 9 | name = "views" 10 | """ 11 | The name of this hook. 12 | """ 13 | 14 | bundle_module_names = ["views"] 15 | """ 16 | The default module this hook loads from. 17 | 18 | Override by setting the ``views_module_names`` attribute on your 19 | bundle class. 20 | """ 21 | 22 | run_after = ["blueprints"] 23 | 24 | def run_hook(self, app, bundles, unchained_config=None) -> None: 25 | pass # noop 26 | -------------------------------------------------------------------------------- /flask_unchained/routes.py: -------------------------------------------------------------------------------- 1 | from .bundles.controller.routes import ( 2 | controller, 3 | delete, 4 | func, 5 | get, 6 | include, 7 | patch, 8 | post, 9 | prefix, 10 | put, 11 | resource, 12 | rule, 13 | ) 14 | -------------------------------------------------------------------------------- /flask_unchained/templates/_flashes.html: -------------------------------------------------------------------------------- 1 | {% with messages = get_flashed_messages(with_categories=True) %} 2 | {% if messages %} 3 |
4 |
5 | {% for category, message in messages %} 6 | 12 | {% endfor %} 13 |
14 |
15 | {% endif %} 16 | {% endwith %} 17 | -------------------------------------------------------------------------------- /flask_unchained/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}Flask Unchained{% endblock %} 8 | 9 | {% block stylesheets %} 10 | 11 | {% endblock stylesheets %} 12 | 13 | {% block extra_head %} 14 | {% endblock extra_head %} 15 | 16 | 17 | 18 | {% block body %} 19 |
20 | {% include '_flashes.html' %} 21 | {% block content %} 22 | {% endblock content %} 23 |
24 | {% endblock body %} 25 | 26 | {% block javascripts %} 27 | 28 | 29 | 30 | {% endblock javascripts %} 31 | 32 | 33 | -------------------------------------------------------------------------------- /flask_unchained/views.py: -------------------------------------------------------------------------------- 1 | from .bundles.controller import ( 2 | Controller, 3 | Resource, 4 | no_route, 5 | param_converter, 6 | redirect, 7 | route, 8 | url_for, 9 | ) 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/__init__.py -------------------------------------------------------------------------------- /tests/_bundles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/_bundles/__init__.py -------------------------------------------------------------------------------- /tests/_bundles/app_bundle_in_module/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/_bundles/app_bundle_in_module/__init__.py -------------------------------------------------------------------------------- /tests/_bundles/app_bundle_in_module/bundle.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import AppBundle 2 | 3 | 4 | class AppBundleInModule(AppBundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/_bundles/app_bundle_in_module/config.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import BundleConfig 2 | 3 | 4 | class Config(BundleConfig): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/_bundles/bundle_in_module/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/_bundles/bundle_in_module/__init__.py -------------------------------------------------------------------------------- /tests/_bundles/bundle_in_module/bundle.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class ModuleBundle(Bundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/_bundles/empty_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class EmptyBundle(Bundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/_bundles/error_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | # intentionally nothing here 2 | -------------------------------------------------------------------------------- /tests/_bundles/myapp/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import AppBundle 2 | 3 | from .extensions import myext 4 | 5 | 6 | class MyAppBundle(AppBundle): 7 | command_group_names = ("goo_group",) 8 | -------------------------------------------------------------------------------- /tests/_bundles/myapp/commands.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.cli import cli, click 2 | 3 | from ..vendor_bundle.commands import foo_group 4 | 5 | 6 | @foo_group.command() 7 | def baz(): 8 | """myapp docstring""" 9 | click.echo("myapp") 10 | 11 | 12 | @click.group() 13 | def goo_group(): 14 | """myapp docstring""" 15 | 16 | 17 | @goo_group.command() 18 | def gar(): 19 | click.echo("myapp") 20 | 21 | 22 | @cli.command() 23 | def top_level(): 24 | click.echo("myapp") 25 | -------------------------------------------------------------------------------- /tests/_bundles/myapp/config.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import BundleConfig 2 | 3 | 4 | class Config(BundleConfig): 5 | SECRET_KEY = "change-me" 6 | APP_KEY = "app_key" 7 | VENDOR_KEY1 = "app_override" 8 | -------------------------------------------------------------------------------- /tests/_bundles/myapp/extensions.py: -------------------------------------------------------------------------------- 1 | class MyExtension: 2 | def __init__(self, app=None): 3 | self.app = app 4 | self.name = None 5 | 6 | if app: 7 | self.init_app(app) 8 | 9 | def init_app(self, app): 10 | self.app = app 11 | self.name = "my ext!" 12 | 13 | 14 | myext = MyExtension() 15 | 16 | 17 | EXTENSIONS = { 18 | "myext": (myext, ["awesome"]), 19 | } 20 | -------------------------------------------------------------------------------- /tests/_bundles/myapp/static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/_bundles/myapp/static/.gitkeep -------------------------------------------------------------------------------- /tests/_bundles/myapp/templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/_bundles/myapp/templates/.gitkeep -------------------------------------------------------------------------------- /tests/_bundles/override_vendor_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from .bundle import VendorBundle 2 | from .extension import awesome 3 | -------------------------------------------------------------------------------- /tests/_bundles/override_vendor_bundle/bundle.py: -------------------------------------------------------------------------------- 1 | from ..vendor_bundle import VendorBundle as BaseVendorBundle 2 | 3 | 4 | class VendorBundle(BaseVendorBundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/_bundles/override_vendor_bundle/commands.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.cli import cli, click 2 | 3 | from ..vendor_bundle.commands import foo_group 4 | 5 | 6 | @foo_group.command() 7 | def bar(): 8 | """override_vendor_bundle docstring""" 9 | click.echo("override_vendor_bundle") 10 | 11 | 12 | @foo_group.command() 13 | def baz(): 14 | """override_vendor_bundle docstring""" 15 | click.echo("override_vendor_bundle") 16 | 17 | 18 | @cli.command() 19 | def vendor_top_level(): 20 | """override_vendor_bundle docstring""" 21 | click.echo("override_vendor_bundle") 22 | -------------------------------------------------------------------------------- /tests/_bundles/override_vendor_bundle/config.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import BundleConfig 2 | 3 | 4 | class Config(BundleConfig): 5 | VENDOR_KEY1 = "override_vendor_key1" 6 | VENDOR_KEY2 = "override_vendor_key2" 7 | -------------------------------------------------------------------------------- /tests/_bundles/override_vendor_bundle/extension.py: -------------------------------------------------------------------------------- 1 | class MyAwesomeExtension: 2 | def __init__(self, app=None): 3 | self.app = app 4 | self.name = None 5 | 6 | if app: 7 | self.init_app(app) 8 | 9 | def init_app(self, app): 10 | self.app = app 11 | self.name = "override_awesome" 12 | 13 | 14 | awesome = MyAwesomeExtension() 15 | 16 | 17 | EXTENSIONS = { 18 | "awesome": awesome, 19 | } 20 | -------------------------------------------------------------------------------- /tests/_bundles/services_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class ServicesBundle(Bundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/_bundles/services_ext_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from tests._bundles.services_bundle import ServicesBundle as BaseBundle 2 | 3 | 4 | class ServicesBundle(BaseBundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/_bundles/vendor_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from .bundle import VendorBundle 2 | from .extension import awesome 3 | -------------------------------------------------------------------------------- /tests/_bundles/vendor_bundle/bundle.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class VendorBundle(Bundle): 5 | command_group_names = ["foo_group", "goo_group"] 6 | extensions_module_names = ["extension"] 7 | -------------------------------------------------------------------------------- /tests/_bundles/vendor_bundle/commands.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.cli import cli, click 2 | 3 | 4 | @cli.command() 5 | def vendor_top_level(): 6 | """vendor_bundle docstring""" 7 | click.echo("vendor_bundle") 8 | 9 | 10 | # this group will have its baz command overridden 11 | @cli.group() 12 | def foo_group(): 13 | """vendor_bundle docstring""" 14 | 15 | 16 | @foo_group.command() 17 | def bar(): 18 | """vendor_bundle docstring""" 19 | click.echo("vendor_bundle") 20 | 21 | 22 | @foo_group.command() 23 | def baz(): 24 | """vendor_bundle docstring""" 25 | click.echo("vendor_bundle") 26 | 27 | 28 | # this group should get overridden by the myapp bundle 29 | @cli.group() 30 | def goo_group(): 31 | """vendor_bundle docstring""" 32 | 33 | 34 | @goo_group.command() 35 | def gar(): 36 | """vendor_bundle docstring""" 37 | click.echo("vendor_bundle") 38 | 39 | 40 | @goo_group.command() 41 | def gaz(): 42 | """the overridden group should not contain this command""" 43 | click.echo("vendor_bundle") 44 | -------------------------------------------------------------------------------- /tests/_bundles/vendor_bundle/config.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import BundleConfig 2 | 3 | 4 | class Config(BundleConfig): 5 | VENDOR_KEY1 = "vendor_key1" 6 | VENDOR_KEY2 = "vendor_key2" 7 | -------------------------------------------------------------------------------- /tests/_bundles/vendor_bundle/extension.py: -------------------------------------------------------------------------------- 1 | class AwesomeExtension: 2 | def __init__(self, app=None): 3 | self.app = app 4 | self.name = None 5 | 6 | if app: 7 | self.init_app(app) 8 | 9 | def init_app(self, app): 10 | self.app = app 11 | self.name = "awesome!" 12 | 13 | 14 | awesome = AwesomeExtension() 15 | 16 | 17 | EXTENSIONS = { 18 | "awesome": awesome, 19 | } 20 | -------------------------------------------------------------------------------- /tests/_bundles/vendor_bundle/static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/_bundles/vendor_bundle/static/.gitkeep -------------------------------------------------------------------------------- /tests/_bundles/vendor_bundle/templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/_bundles/vendor_bundle/templates/.gitkeep -------------------------------------------------------------------------------- /tests/_bundles/vendor_bundle/views.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/_bundles/vendor_bundle/views.py -------------------------------------------------------------------------------- /tests/_unchained_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | TEMPLATE_FOLDER = os.path.join(os.path.dirname(__file__), "templates") 5 | 6 | BUNDLES = [ 7 | "flask_unchained.bundles.babel", 8 | "flask_unchained.bundles.controller", 9 | "flask_unchained.bundles.sqlalchemy", 10 | ] 11 | -------------------------------------------------------------------------------- /tests/bundles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/__init__.py -------------------------------------------------------------------------------- /tests/bundles/controller/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/controller/__init__.py -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/controller/fixtures/__init__.py -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/app_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import AppBundle as BaseAppBundle 2 | 3 | 4 | class AppBundle(BaseAppBundle): 5 | blueprint_names = ["one", "two"] 6 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/app_bundle/routes.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.controller.routes import func, include 2 | 3 | from .views import view_one, view_two 4 | 5 | 6 | routes = lambda: [ 7 | func(view_one), 8 | func(view_two), 9 | include("tests.bundles.controller.fixtures.vendor_bundle.routes"), 10 | include("tests.bundles.controller.fixtures.warning_bundle.routes"), 11 | ] 12 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/app_bundle/views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from flask_unchained.bundles.controller import route 4 | 5 | 6 | one = Blueprint("one", __name__) 7 | two = Blueprint("two", __name__, url_prefix="/two") 8 | 9 | 10 | @route(blueprint=one) 11 | def view_one(): 12 | return "view_one rendered" 13 | 14 | 15 | @route(blueprint=two) 16 | def view_two(): 17 | return "view_two rendered" 18 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/auto_route_app_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import AppBundle 2 | 3 | 4 | class AutoRouteAppBundle(AppBundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/auto_route_app_bundle/views.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.controller import Controller, include, route 2 | 3 | 4 | routes = lambda: [ 5 | include("tests.bundles.controller.fixtures.vendor_bundle.routes"), 6 | ] 7 | 8 | 9 | class SiteController(Controller): 10 | @route("/") 11 | def index(self): 12 | return "index rendered" 13 | 14 | def about(self): 15 | return "about rendered" 16 | 17 | 18 | @route(endpoint="views.view_one") 19 | def view_one(): 20 | return "view_one rendered" 21 | 22 | 23 | @route("/two", endpoint="views.view_two") 24 | def view_two(): 25 | return "view_two rendered" 26 | 27 | 28 | def should_be_ignored(): 29 | raise NotImplementedError 30 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/empty_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import AppBundle 2 | 3 | 4 | class EmptyBundle(AppBundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/empty_bundle/routes.py: -------------------------------------------------------------------------------- 1 | # intentionally nothing here 2 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/empty_bundle/views.py: -------------------------------------------------------------------------------- 1 | # intentionally empty 2 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/other_bp_routes.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.controller import func, include, prefix 2 | 3 | from .bp_views import one, three, two 4 | 5 | 6 | implicit = lambda: [ 7 | func(one), 8 | func(two), 9 | func(three), 10 | ] 11 | 12 | explicit = lambda: [ 13 | func("/one", one), 14 | func("/two", two), 15 | func("/three", three), 16 | ] 17 | 18 | recursive = lambda: [ 19 | include("tests.bundles.controller.fixtures.other_bp_routes", attr="explicit"), 20 | prefix( 21 | "/deep", 22 | [include("tests.bundles.controller.fixtures.other_bp_routes", attr="implicit")], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/other_routes.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.controller import func, include, prefix 2 | 3 | from .views import one, three, two 4 | 5 | 6 | implicit = lambda: [ 7 | func(one), 8 | func(two), 9 | func(three), 10 | ] 11 | 12 | explicit = lambda: [ 13 | func("/one", one), 14 | func("/two", two), 15 | func("/three", three), 16 | ] 17 | 18 | recursive = lambda: [ 19 | include("tests.bundles.controller.fixtures.other_routes", attr="explicit"), 20 | prefix( 21 | "/deep", 22 | [include("tests.bundles.controller.fixtures.other_routes", attr="implicit")], 23 | ), 24 | ] 25 | 26 | routes = lambda: [ 27 | func(one), 28 | func(two), 29 | func(three), 30 | ] 31 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/vendor_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class VendorBundle(Bundle): 5 | blueprint_names = ["three", "four"] 6 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/vendor_bundle/routes.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.controller import func 2 | 3 | from .views import view_four, view_three 4 | 5 | 6 | routes = lambda: [ 7 | func(view_three), 8 | func(view_four), 9 | ] 10 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/vendor_bundle/views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from flask_unchained.bundles.controller import route 4 | 5 | 6 | three = Blueprint("three", __name__) 7 | four = Blueprint("four", __name__, url_prefix="/four") 8 | 9 | 10 | @route(blueprint=three) 11 | def view_three(): 12 | return "view_three rendered" 13 | 14 | 15 | @route(blueprint=four) 16 | def view_four(): 17 | return "view_four rendered" 18 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/warning_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class WarningBundle(Bundle): 5 | blueprint_names = ["fail"] 6 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/warning_bundle/routes.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.controller import func 2 | 3 | from .views import silly_condition 4 | 5 | 6 | routes = lambda: [ 7 | func(silly_condition), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/bundles/controller/fixtures/warning_bundle/views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from flask_unchained.bundles.controller import route 4 | 5 | 6 | not_loaded = Blueprint("not_loaded", __name__) 7 | 8 | 9 | @route(only_if=False) 10 | def silly_condition(): 11 | return "silly_condition" 12 | -------------------------------------------------------------------------------- /tests/bundles/controller/test_route.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_unchained.bundles.controller import Controller 4 | from flask_unchained.bundles.controller.attr_constants import CONTROLLER_ROUTES_ATTR 5 | from flask_unchained.bundles.controller.route import Route 6 | 7 | 8 | class TestRoute: 9 | def test_should_register_defaults_to_true(self): 10 | route = Route("/path", lambda: "view_func") 11 | assert route.should_register(None) is True 12 | 13 | def test_should_register_with_boolean(self): 14 | route = Route("/path", lambda: "view_func", only_if=True) 15 | assert route.should_register(None) is True 16 | 17 | def test_should_register_with_callable(self): 18 | route = Route("/path", lambda: "view_func", only_if=lambda x: x) 19 | assert route.should_register(True) is True 20 | assert route.should_register(False) is False 21 | 22 | def test_full_name_with_controller(self): 23 | class SomeController(Controller): 24 | def index(self): 25 | pass 26 | 27 | route = getattr(SomeController, CONTROLLER_ROUTES_ATTR)["index"][0] 28 | assert ( 29 | route.full_name == "tests.bundles.controller.test_route.SomeController.index" 30 | ) 31 | 32 | def test_full_name_with_func(self): 33 | def a_view(): 34 | pass 35 | 36 | route = Route("/foo", a_view) 37 | assert route.full_name == "tests.bundles.controller.test_route.a_view" 38 | -------------------------------------------------------------------------------- /tests/bundles/graphene/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/graphene/__init__.py -------------------------------------------------------------------------------- /tests/bundles/graphene/_bundles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/graphene/_bundles/__init__.py -------------------------------------------------------------------------------- /tests/bundles/graphene/_bundles/graphene_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class MyGrapheneBundle(Bundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/graphene/_bundles/graphene_bundle/graphene/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/graphene/_bundles/graphene_bundle/graphene/__init__.py -------------------------------------------------------------------------------- /tests/bundles/graphene/_bundles/graphene_bundle/graphene/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from flask_unchained.bundles.graphene import MutationsObjectType, QueriesObjectType 4 | 5 | from . import mutations, types 6 | 7 | 8 | class GrapheneBundleQueries(QueriesObjectType): 9 | parent = graphene.Field(types.Parent, id=graphene.ID(required=True)) 10 | parents = graphene.List(types.Parent) 11 | 12 | child = graphene.Field(types.Child, id=graphene.ID(required=True)) 13 | children = graphene.List(types.Child) 14 | 15 | 16 | class GrapheneBundleMutations(MutationsObjectType): 17 | create_parent = mutations.CreateParent.Field() 18 | delete_parent = mutations.DeleteParent.Field() 19 | edit_parent = mutations.EditParent.Field() 20 | -------------------------------------------------------------------------------- /tests/bundles/graphene/_bundles/graphene_bundle/graphene/types.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from flask_unchained.bundles.graphene import SQLAlchemyObjectType 4 | 5 | from .. import models 6 | 7 | 8 | class Parent(SQLAlchemyObjectType): 9 | class Meta: 10 | model = models.Parent 11 | only_fields = ("id", "name", "created_at", "updated_at") 12 | 13 | children = graphene.List(lambda: Child) 14 | 15 | 16 | class Child(SQLAlchemyObjectType): 17 | class Meta: 18 | model = models.Child 19 | only_fields = ("id", "name", "created_at", "updated_at") 20 | 21 | parent = graphene.Field(Parent) 22 | -------------------------------------------------------------------------------- /tests/bundles/graphene/_bundles/graphene_bundle/models.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | 4 | class Parent(db.Model): 5 | name = db.Column(db.String) 6 | 7 | children = db.relationship( 8 | "Child", back_populates="parent", cascade="all,delete,delete-orphan" 9 | ) 10 | 11 | 12 | class Child(db.Model): 13 | name = db.Column(db.String) 14 | 15 | parent_id = db.foreign_key("Parent") 16 | parent = db.relationship("Parent", back_populates="children") 17 | -------------------------------------------------------------------------------- /tests/bundles/graphene/_bundles/graphene_bundle/services.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import ModelManager 2 | 3 | from . import models 4 | 5 | 6 | class ParentManager(ModelManager): 7 | class Meta: 8 | model = models.Parent 9 | 10 | 11 | class ChildManager(ModelManager): 12 | class Meta: 13 | model = models.Child 14 | -------------------------------------------------------------------------------- /tests/bundles/graphene/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_unchained import unchained 4 | from flask_unchained.bundles.graphene.pytest import * 5 | 6 | from ..sqlalchemy.conftest import * 7 | 8 | 9 | parent_manager = unchained.get_local_proxy("parent_manager") 10 | child_manager = unchained.get_local_proxy("child_manager") 11 | session_manager = unchained.get_local_proxy("session_manager") 12 | 13 | 14 | @pytest.fixture(autouse=True) 15 | def parents(): 16 | parent_one = parent_manager.create(name="parent_one") 17 | child_one = child_manager.create(name="child_one", parent=parent_one) 18 | child_two = child_manager.create(name="child_two", parent=parent_one) 19 | 20 | parent_two = parent_manager.create(name="parent_two") 21 | child_three = child_manager.create(name="child_three", parent=parent_two) 22 | child_four = child_manager.create(name="child_four", parent=parent_two) 23 | 24 | session_manager.commit() 25 | yield [parent_one, parent_two] 26 | -------------------------------------------------------------------------------- /tests/bundles/graphene/hooks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/graphene/hooks/__init__.py -------------------------------------------------------------------------------- /tests/bundles/mail/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/mail/__init__.py -------------------------------------------------------------------------------- /tests/bundles/mail/_unchained_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | TEMPLATE_FOLDER = os.path.join(os.path.dirname(__file__), "_templates") 5 | 6 | BUNDLES = [ 7 | "flask_mail_bundle", 8 | ] 9 | -------------------------------------------------------------------------------- /tests/bundles/security/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/security/__init__.py -------------------------------------------------------------------------------- /tests/bundles/security/_app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import AppBundle as BaseAppBundle 2 | 3 | 4 | class AppBundle(BaseAppBundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/security/_app/config.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import BundleConfig 2 | 3 | 4 | class Config(BundleConfig): 5 | SECRET_KEY = "not-secret-key" 6 | 7 | SECURITY_SEND_REGISTER_EMAIL = True 8 | SECURITY_SEND_PASSWORD_CHANGED_EMAIL = True 9 | SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL = True 10 | -------------------------------------------------------------------------------- /tests/bundles/security/_app/templates/email/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}email/layout.html{% endblock %} 6 | 7 | 8 | 9 | {% block content %} 10 | {% endblock %} 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/bundles/security/_app/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}layout.html{% endblock %} 6 | 7 | 8 | 9 | {% block content %} 10 | {% block flashes %} 11 | {%- with messages = get_flashed_messages(with_categories=true) -%} 12 | {% if messages %} 13 |
    14 | {% for category, message in messages %} 15 |
  • {{ message }}
  • 16 | {% endfor %} 17 |
18 | {% endif %} 19 | {%- endwith %} 20 | {% endblock flashes %} 21 | {% endblock content %} 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/bundles/security/_app/templates/site/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block content %} 4 | {{ super() }} 5 |

index.html

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /tests/bundles/security/_app/views.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Controller, route 2 | 3 | 4 | class SiteController(Controller): 5 | @route("/") 6 | def index(self): 7 | return self.render("index") 8 | -------------------------------------------------------------------------------- /tests/bundles/security/_bundles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/security/_bundles/__init__.py -------------------------------------------------------------------------------- /tests/bundles/security/_bundles/security/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.security import SecurityBundle as BaseSecurityBundle 2 | 3 | 4 | class SecurityBundle(BaseSecurityBundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/security/_bundles/security/config.py: -------------------------------------------------------------------------------- 1 | class TestConfig: 2 | TESTING = True 3 | SECURITY_PASSWORD_SALT = "not-secret-salt" 4 | 5 | SESSION_TYPE = "sqlalchemy" 6 | -------------------------------------------------------------------------------- /tests/bundles/security/_bundles/security/forms.py: -------------------------------------------------------------------------------- 1 | from wtforms import fields 2 | 3 | from flask_unchained.bundles.security.forms import RegisterForm as BaseRegisterForm 4 | 5 | 6 | class RegisterForm(BaseRegisterForm): 7 | username = fields.StringField("Username") 8 | first_name = fields.StringField("First Name") 9 | last_name = fields.StringField("Last Name") 10 | -------------------------------------------------------------------------------- /tests/bundles/security/_bundles/security/models/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.security.models import UserRole 2 | 3 | from .role import Role 4 | from .user import User 5 | -------------------------------------------------------------------------------- /tests/bundles/security/_bundles/security/models/role.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.security.models import Role as BaseRole 2 | from flask_unchained.bundles.sqlalchemy import db 3 | 4 | 5 | class Role(BaseRole): 6 | description = db.Column(db.Text, nullable=True) 7 | -------------------------------------------------------------------------------- /tests/bundles/security/_bundles/security/models/user.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.security.models import User as BaseUser 2 | from flask_unchained.bundles.sqlalchemy import db 3 | 4 | 5 | class User(BaseUser): 6 | username = db.Column(db.String(64), nullable=True) 7 | first_name = db.Column(db.String(64), nullable=True) 8 | last_name = db.Column(db.String(64), nullable=True) 9 | -------------------------------------------------------------------------------- /tests/bundles/security/_bundles/security/serializers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/security/_bundles/security/serializers/__init__.py -------------------------------------------------------------------------------- /tests/bundles/security/_bundles/security/serializers/user_serializer.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from flask_unchained.bundles.api import ma 4 | from flask_unchained.bundles.security.serializers import ( 5 | UserSerializer as BaseUserSerializer, 6 | ) 7 | 8 | 9 | NON_ALPHANUMERIC_RE = re.compile(r"[^\w]") 10 | 11 | 12 | class UserSerializer(BaseUserSerializer): 13 | username = ma.String(required=True) 14 | first_name = ma.String(required=True) 15 | last_name = ma.String(required=True) 16 | 17 | @ma.validates("username") 18 | def validate_username(self, username): 19 | if re.search(NON_ALPHANUMERIC_RE, username): 20 | raise ma.ValidationError( 21 | "Usernames can only contain letters, " 22 | "numbers, and/or underscore characters." 23 | ) 24 | 25 | existing = self.user_manager.get_by(username=username) 26 | if existing and (self.is_create() or existing != self.instance): 27 | raise ma.ValidationError("Sorry, that username is already taken.") 28 | -------------------------------------------------------------------------------- /tests/bundles/security/_bundles/security/services.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.security.services import ( 2 | SecurityService as BaseSecurityService, 3 | ) 4 | 5 | 6 | class SecurityService(BaseSecurityService): 7 | pass 8 | -------------------------------------------------------------------------------- /tests/bundles/security/decorators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/security/decorators/__init__.py -------------------------------------------------------------------------------- /tests/bundles/security/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/security/views/__init__.py -------------------------------------------------------------------------------- /tests/bundles/security/views/security_controller/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/security/views/security_controller/__init__.py -------------------------------------------------------------------------------- /tests/bundles/security/views/security_controller/test_logout.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_unchained import url_for 4 | from flask_unchained.bundles.security import AnonymousUser, current_user 5 | 6 | 7 | @pytest.mark.usefixtures("user") 8 | class TestLogout: 9 | def test_html_get(self, client): 10 | client.login_user() 11 | r = client.get("security_controller.logout") 12 | assert r.status_code == 302 13 | assert r.path == url_for("SECURITY_POST_LOGOUT_REDIRECT_ENDPOINT") 14 | assert isinstance(current_user._get_current_object(), AnonymousUser) 15 | 16 | def test_api_get(self, api_client): 17 | api_client.login_user() 18 | r = api_client.get("security_api.logout") 19 | assert r.status_code == 204 20 | assert isinstance(current_user._get_current_object(), AnonymousUser) 21 | -------------------------------------------------------------------------------- /tests/bundles/security/views/user_resource/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/security/views/user_resource/__init__.py -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/sqlalchemy/__init__.py -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/sqlalchemy/_bundles/__init__.py -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import AppBundle 2 | 3 | 4 | class MyAppBundle(AppBundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/app/models.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | 4 | class TwoBasic(db.Model): 5 | app = db.Column(db.String) 6 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/backref/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class BackrefBundle(Bundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/backref/models.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | 4 | class OneRelationship(db.Model): 5 | class Meta: 6 | lazy_mapped = True 7 | 8 | name = db.Column(db.String) 9 | backrefs = db.relationship("OneBackref", backref=db.backref("relationship")) 10 | 11 | 12 | class OneBackref(db.Model): 13 | class Meta: 14 | lazy_mapped = True 15 | 16 | name = db.Column(db.String) 17 | relationship_id = db.foreign_key("OneRelationship") 18 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/custom_extension/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import unchained 2 | from flask_unchained.bundles.sqlalchemy import SQLAlchemyBundle 3 | 4 | from .extensions import db 5 | 6 | 7 | unchained.extensions.db = db 8 | 9 | 10 | class CustomSQLAlchemyBundle(SQLAlchemyBundle): 11 | pass 12 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/custom_extension/base_model.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy.base_model import BaseModel 2 | from py_meta_utils import McsArgs, MetaOption 3 | from sqlalchemy_unchained import ModelMetaOptionsFactory 4 | 5 | 6 | class ExtendExisting(MetaOption): 7 | def __init__(self): 8 | super().__init__(name="extend_existing", default=True, inherit=False) 9 | 10 | def check_value(self, value, mcs_args: McsArgs): 11 | msg = f"{self.name} Meta option on {mcs_args.qualname} must be True or False" 12 | assert isinstance(value, bool), msg 13 | 14 | def contribute_to_class(self, mcs_args, value): 15 | if not value: 16 | return 17 | 18 | table_args = mcs_args.clsdict.get("__table_args__", {}) 19 | table_args["extend_existing"] = True 20 | mcs_args.clsdict["__table_args__"] = table_args 21 | 22 | 23 | class CustomModelMetaOptions(ModelMetaOptionsFactory): 24 | def _get_meta_options(self): 25 | return super()._get_meta_options() + [ 26 | ExtendExisting(), 27 | ] 28 | 29 | 30 | class Model(BaseModel): 31 | _meta_options_factory_class = CustomModelMetaOptions 32 | 33 | class Meta: 34 | _testing_ = "overriding the default" 35 | abstract = True 36 | extend_existing = True 37 | pk = "pk" 38 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/custom_extension/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import MetaData 2 | 3 | from ..base_model import Model 4 | from .sqlalchemy import SQLAlchemyUnchained 5 | 6 | 7 | # normally these would go directly in the constructor; this is for testing 8 | kwargs = dict( 9 | model_class=Model, 10 | metadata=MetaData( 11 | naming_convention={ 12 | "ix": "ix_%(column_0_label)s", 13 | "uq": "uq_%(table_name)s_%(column_0_name)s", 14 | "ck": "ck_%(table_name)s_%(constraint_name)s", 15 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 16 | "pk": "pk_%(table_name)s", 17 | } 18 | ), 19 | ) 20 | 21 | 22 | db = SQLAlchemyUnchained(**kwargs) 23 | 24 | 25 | EXTENSIONS = { 26 | "db": db, 27 | } 28 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/custom_extension/extensions/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy.extensions import SQLAlchemyUnchained as Base 2 | 3 | 4 | class SQLAlchemyUnchained(Base): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/ext_ext_vendor_one/__init__.py: -------------------------------------------------------------------------------- 1 | from ..ext_vendor_one import ExtVendorOneBundle 2 | 3 | 4 | class ExtExtVendorOneBundle(ExtVendorOneBundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/ext_ext_vendor_one/models.py: -------------------------------------------------------------------------------- 1 | from ..vendor_one.models import OneRole as BaseOneRole 2 | from ..vendor_one.models import OneUser as BaseOneUser 3 | 4 | 5 | class OneUser(BaseOneUser): 6 | class Meta: 7 | lazy_mapped = True 8 | 9 | 10 | class OneRole(BaseOneRole): 11 | class Meta: 12 | lazy_mapped = True 13 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/ext_vendor_one/__init__.py: -------------------------------------------------------------------------------- 1 | from tests.bundles.sqlalchemy._bundles.vendor_one import VendorOneBundle 2 | 3 | 4 | class ExtVendorOneBundle(VendorOneBundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/ext_vendor_one/models.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | from ..vendor_one.models import OneBasic as BaseOneBasic 4 | 5 | 6 | # test extending OneBasic to add an extra column 7 | class OneBasic(BaseOneBasic): 8 | class Meta: 9 | lazy_mapped = True 10 | 11 | ext = db.Column(db.String) 12 | 13 | 14 | # test overriding OneParent to remove the children relationship 15 | class OneParent(db.Model): 16 | class Meta: 17 | lazy_mapped = True 18 | 19 | name = db.Column(db.String) 20 | 21 | 22 | # test overriding OneUser and OneRole to change the roles relationship to be 23 | # one-to-many instead of many-to-many 24 | class OneUser(db.Model): 25 | class Meta: 26 | lazy_mapped = True 27 | 28 | name = db.Column(db.String) 29 | 30 | roles = db.relationship("OneRole", back_populates="user") 31 | 32 | 33 | class OneRole(db.Model): 34 | class Meta: 35 | lazy_mapped = True 36 | 37 | name = db.Column(db.String) 38 | 39 | user_id = db.foreign_key("OneUser") 40 | user = db.relationship("OneUser", back_populates="roles") 41 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/polymorphic/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class PolymorphicBundle(Bundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/polymorphic/models.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | 4 | class Person(db.Model): 5 | class Meta: 6 | polymorphic = True 7 | 8 | name = db.Column(db.String) 9 | 10 | 11 | class Employee(Person): 12 | company = db.Column(db.String) 13 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/vendor_one/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class VendorOneBundle(Bundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/vendor_three/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class VendorThreeBundle(Bundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/vendor_three/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .basic import Basic, KeyedAndTimestamped, Timestamped 2 | from .many_to_many_model import AssetDataVendor, DataVendor 3 | from .many_to_many_table import Index 4 | from .one_to_many import Exchange, Market 5 | from .one_to_one import User, UserProfile 6 | from .polymorphic import Asset, Equity 7 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/vendor_three/models/many_to_many_table.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | 4 | index_equities = db.Table( 5 | "index_equity", 6 | db.foreign_key("Index", column_name=True, primary_key=True), 7 | db.foreign_key("Equity", column_name=True, primary_key=True), 8 | ) 9 | 10 | 11 | class Index(db.Model): 12 | name = db.Column(db.String(64), index=True, unique=True) 13 | ticker = db.Column(db.String(16), index=True, unique=True) 14 | 15 | equities = db.relationship( 16 | "Equity", secondary=index_equities, lazy="subquery", back_populates="indexes" 17 | ) 18 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/vendor_three/models/one_to_many.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | 4 | class Exchange(db.Model): 5 | class Meta: 6 | repr = ("id", "abbrev", "name") 7 | 8 | abbrev = db.Column(db.String(10), index=True, unique=True) 9 | name = db.Column(db.String(64), index=True, unique=True) 10 | 11 | markets = db.relationship("Market", back_populates="exchange") 12 | 13 | 14 | class Market(db.Model): 15 | class Meta: 16 | repr = ("id", "abbrev", "name") 17 | 18 | abbrev = db.Column(db.String(10), index=True, unique=True) 19 | name = db.Column(db.String(64), index=True, unique=True) 20 | 21 | exchange_id = db.foreign_key("Exchange") 22 | exchange = db.relationship("Exchange", back_populates="markets") 23 | 24 | assets = db.relationship("Asset", back_populates="market") 25 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/vendor_three/models/one_to_one.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | 4 | class User(db.Model): 5 | username = db.Column(db.String(64), index=True, unique=True) 6 | 7 | profile_id = db.foreign_key("UserProfile") 8 | profile = db.relationship("UserProfile", back_populates="user") 9 | 10 | 11 | class UserProfile(db.Model): 12 | first_name = db.Column(db.String(32)) 13 | last_name = db.Column(db.String(64)) 14 | 15 | user = db.relationship("User", uselist=False, back_populates="profile") 16 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/vendor_three/models/polymorphic.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | from .many_to_many_model import AssetDataVendor 4 | from .many_to_many_table import index_equities 5 | 6 | 7 | class Asset(db.Model): 8 | class Meta: 9 | polymorphic = True 10 | repr = ("id", "ticker") 11 | 12 | ticker = db.Column(db.String(16), index=True, unique=True) 13 | 14 | market_id = db.foreign_key("Market") 15 | market = db.relationship("Market", back_populates="assets") 16 | exchange = db.association_proxy("market", "exchange") 17 | 18 | asset_data_vendors = db.relationship( 19 | "AssetDataVendor", back_populates="asset", cascade="all, delete-orphan" 20 | ) 21 | data_vendors = db.association_proxy( 22 | "asset_data_vendors", 23 | "data_vendor", 24 | creator=lambda data_vendor: AssetDataVendor(data_vendor=data_vendor), 25 | ) 26 | 27 | 28 | class Equity(Asset): 29 | class Meta: 30 | repr = ("id", "ticker", "company_name") 31 | 32 | company_name = db.Column(db.String(64), index=True) 33 | 34 | indexes = db.relationship( 35 | "Index", secondary=index_equities, lazy="subquery", back_populates="equities" 36 | ) 37 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/vendor_two/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_unchained import Bundle 2 | 3 | 4 | class VendorTwoBundle(Bundle): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/_bundles/vendor_two/models.py: -------------------------------------------------------------------------------- 1 | from flask_unchained.bundles.sqlalchemy import db 2 | 3 | 4 | class TwoBasic(db.Model): 5 | class Meta: 6 | lazy_mapped = True 7 | 8 | name = db.Column(db.String, index=True) 9 | -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/sqlalchemy/services/__init__.py -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/sqla/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/bundles/sqlalchemy/sqla/__init__.py -------------------------------------------------------------------------------- /tests/bundles/sqlalchemy/test_factory_relationships.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | # 5 | # 6 | # @pytest.mark.basic(name='foobar') 7 | # def test_basic_works(basic): 8 | # assert basic.name == 'foobar' 9 | # 10 | # 11 | # @pytest.mark.timestamped(name='foobar2') 12 | # def test_timestamped_works(timestamped): 13 | # assert timestamped.name == 'foobar2' 14 | # 15 | # 16 | # @pytest.mark.market(abbrev='AMEX', name='NYSE American') 17 | # @pytest.mark.equity(company_name='hello', market__abbrev='AMEX', market__name='NYSE American') 18 | # def test_equity_works(market, equity): 19 | # print(market, equity.market) 20 | -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/commands/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_unchained import TEST, AppFactory, unchained 4 | 5 | 6 | @pytest.fixture() 7 | def bundles(request): 8 | """ 9 | Fixture that allows marking tests as using a specific list of bundles: 10 | 11 | @pytest.mark.bundles(['tests._bundles.one', 'tests._bundles.two']) 12 | def test_something(): 13 | pass 14 | """ 15 | try: 16 | return request.node.get_closest_marker("bundles").args[0] 17 | except AttributeError: 18 | from ._unchained_config import BUNDLES 19 | 20 | return BUNDLES 21 | 22 | 23 | @pytest.fixture(autouse=True) 24 | def app(request, bundles): 25 | """ 26 | Automatically used test fixture. Returns the application instance-under-test with 27 | a valid app context. 28 | """ 29 | unchained._reset() 30 | 31 | options = {} 32 | for mark in request.node.iter_markers("options"): 33 | kwargs = getattr(mark, "kwargs", {}) 34 | options.update({k.upper(): v for k, v in kwargs.items()}) 35 | 36 | app = AppFactory().create_app(TEST, bundles=bundles, _config_overrides=options) 37 | ctx = app.app_context() 38 | ctx.push() 39 | yield app 40 | ctx.pop() 41 | -------------------------------------------------------------------------------- /tests/hooks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/hooks/__init__.py -------------------------------------------------------------------------------- /tests/hooks/test_configure_app_hook.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_unchained.constants import DEV 4 | from flask_unchained.hooks.configure_app_hook import ConfigureAppHook 5 | from flask_unchained.unchained import Unchained 6 | 7 | from .._bundles.empty_bundle import EmptyBundle 8 | from .._bundles.myapp import MyAppBundle 9 | from .._bundles.vendor_bundle import VendorBundle 10 | 11 | 12 | @pytest.fixture 13 | def hook(): 14 | return ConfigureAppHook(Unchained(DEV)) 15 | 16 | 17 | class TestConfigureAppHook: 18 | def test_later_bundle_configs_override_earlier_ones( 19 | self, app, hook: ConfigureAppHook 20 | ): 21 | hook.run_hook(app, [VendorBundle(), EmptyBundle(), MyAppBundle()]) 22 | 23 | assert app.config.APP_KEY == "app_key" 24 | assert app.config.VENDOR_KEY1 == "app_override" 25 | assert app.config.VENDOR_KEY2 == "vendor_key2" 26 | 27 | def test_the_app_bundle_config_module_is_named_config(self, hook: ConfigureAppHook): 28 | assert hook.get_bundle_module_names(MyAppBundle()) == ["config"] 29 | -------------------------------------------------------------------------------- /tests/hooks/test_register_services_hook.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_unchained import unchained 4 | 5 | 6 | class TestRegisterServicesHook: 7 | @pytest.mark.bundles(["tests._bundles.services_bundle"]) 8 | def test_services_get_detected_and_initialized(self): 9 | from tests._bundles.services_bundle.services import ( 10 | FunkyService, 11 | OneService, 12 | TwoService, 13 | ) 14 | 15 | assert isinstance(unchained.services.one_service, OneService) 16 | assert isinstance(unchained.services.two_service, TwoService) 17 | assert isinstance(unchained.services.funky_service, FunkyService) 18 | 19 | @pytest.mark.bundles(["tests._bundles.services_ext_bundle"]) 20 | def test_bundle_overriding_services_works(self, app): 21 | from tests._bundles.services_ext_bundle.services import ( 22 | FunkyService, 23 | OneService, 24 | TwoService, 25 | ) 26 | 27 | assert isinstance(unchained.services.one_service, OneService) 28 | assert isinstance(unchained.services.two_service, TwoService) 29 | assert isinstance(unchained.services.funky_service, FunkyService) 30 | -------------------------------------------------------------------------------- /tests/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/briancappello/flask-unchained/49fe7951cb7c174a1f3198275baeb5c46b4f1287/tests/templates/__init__.py -------------------------------------------------------------------------------- /tests/templates/default/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

DefaultController:index

5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/templates/foobar/index.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

FoobarController:index

5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/templates/send_mail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

one fine body

4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/templates/site/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

SiteController:about

5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/templates/site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

SiteController:index

5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/templates/site/terms.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

SiteController:terms

5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from flask_unchained.utils import get_boolean_env, safe_import_module, utcnow 6 | 7 | 8 | def test_get_boolean_env(monkeypatch): 9 | for truthy in ["TRUE", "YES", "True", "Yes", "true", "yes", "Y", "y", "1"]: 10 | monkeypatch.setenv("MY_ENV", truthy) 11 | assert get_boolean_env("MY_ENV", False) is True 12 | monkeypatch.undo() 13 | 14 | for falsy in [ 15 | "any", 16 | "THING", 17 | "else", 18 | "should", 19 | "be", 20 | "false", 21 | "FALSE", 22 | "F", 23 | "N", 24 | "n", 25 | "0", 26 | "", 27 | ]: 28 | monkeypatch.setenv("MY_ENV", falsy) 29 | assert get_boolean_env("MY_ENV", True) is False 30 | monkeypatch.undo() 31 | 32 | 33 | def test_safe_import_module(): 34 | assert safe_import_module("gobblygook") is None 35 | assert safe_import_module("should.not.exist") is None 36 | 37 | module = safe_import_module("flask_unchained") 38 | assert module.__name__ == "flask_unchained" 39 | 40 | 41 | def test_utc_now(): 42 | assert utcnow().tzinfo == datetime.timezone.utc 43 | with pytest.raises(TypeError) as e: 44 | assert utcnow() <= datetime.datetime.utcnow() 45 | assert "can't compare offset-naive and offset-aware datetimes" in str(e.value) 46 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = py{310,311,312} 4 | 5 | [testenv] 6 | skip_install = true 7 | allowlist_externals = poetry 8 | commands_pre = 9 | poetry install --sync 10 | setenv = 11 | SQLALCHEMY_SILENCE_UBER_WARNING=1 12 | commands = 13 | poetry run pytest tests/ --import-mode importlib 14 | -------------------------------------------------------------------------------- /unchained_config.py: -------------------------------------------------------------------------------- 1 | BUNDLES = [] 2 | --------------------------------------------------------------------------------