├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README-PyPi.md ├── README.md ├── boiler ├── __init__.py ├── _requirements │ ├── all.txt │ ├── api.txt │ ├── flask.txt │ ├── localization.txt │ ├── mail.txt │ ├── orm.txt │ └── testing.txt ├── _static │ ├── css │ │ └── style.css │ └── js │ │ └── backgrounds.js ├── abstract │ ├── __init__.py │ └── abstract_service.py ├── boiler_template │ ├── backend │ │ ├── app.py │ │ ├── config.py │ │ ├── templates │ │ │ ├── index │ │ │ │ └── home.j2 │ │ │ └── layout.j2 │ │ ├── urls.py │ │ └── views.py │ ├── cli │ ├── dist.env │ ├── dist.gitignore │ ├── nose.ini │ ├── uwsgi.ini │ ├── var │ │ ├── .gitkeep │ │ ├── data │ │ │ └── .gitkeep │ │ └── logs │ │ │ └── .gitkeep │ └── wsgi.py ├── bootstrap.py ├── cli │ ├── __init__.py │ ├── boiler.py │ ├── cli.py │ ├── colors.py │ ├── db.py │ └── readme.md ├── collections │ ├── __init__.py │ ├── api_collection.py │ ├── paginated_collection.py │ └── pagination.py ├── config.py ├── errors.py ├── events.py ├── exceptions.py ├── feature │ ├── __init__.py │ ├── localization.py │ ├── logging.py │ ├── mail.py │ ├── orm.py │ └── routing.py ├── jinja │ ├── __init__.py │ └── functions.py ├── log │ ├── __init__.py │ ├── datadog.py │ ├── file.py │ └── mail.py ├── migrations │ ├── __init__.py │ ├── config.py │ └── templates │ │ ├── __init__.py │ │ └── project │ │ ├── __init__.py │ │ ├── alembic.ini.mako │ │ ├── env.py │ │ └── script.py.mako ├── routes │ ├── __init__.py │ ├── lazy_views.py │ ├── regex.py │ └── route.py ├── templates │ ├── errors │ │ ├── 400.j2 │ │ ├── 401.j2 │ │ ├── 403.j2 │ │ ├── 404.j2 │ │ ├── 405.j2 │ │ ├── 406.j2 │ │ ├── 408.j2 │ │ ├── 409.j2 │ │ ├── 410.j2 │ │ ├── 411.j2 │ │ ├── 412.j2 │ │ ├── 413.j2 │ │ ├── 414.j2 │ │ ├── 415.j2 │ │ ├── 416.j2 │ │ ├── 417.j2 │ │ ├── 418.j2 │ │ ├── 422.j2 │ │ ├── 423.j2 │ │ ├── 428.j2 │ │ ├── 429.j2 │ │ ├── 431.j2 │ │ ├── 451.j2 │ │ ├── 500.j2 │ │ ├── 501.j2 │ │ ├── 502.j2 │ │ ├── 503.j2 │ │ ├── 504.j2 │ │ ├── 505.j2 │ │ └── styles.j2 │ ├── kernel_layout.j2 │ └── partials │ │ └── flash-messages.j2 ├── testing │ ├── __init__.py │ └── testcase.py ├── timer │ ├── __init__.py │ └── restart_timer.py └── version.py ├── docs ├── changelog.md ├── config.md ├── features.md ├── quickstart.md └── testing.md ├── release.sh ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── base_testcase.py ├── boiler_test_app ├── __init__.py ├── app.py ├── models.py └── urls.py └── collections_tests ├── api_collection_test.py ├── paginated_collection_test.py └── pagination_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | # os specific files 2 | .DS_Store 3 | ehthumbs.db 4 | Thumbs.db 5 | __pycache__ 6 | 7 | # ide ignores 8 | .buildpath 9 | .project 10 | .settings 11 | .idea 12 | nbproject 13 | www.esproj 14 | 15 | # virtual environment 16 | /env 17 | 18 | 19 | # build ignores 20 | /build 21 | /dist 22 | /data 23 | /*.egg-info 24 | .coverage 25 | /config.yml 26 | 27 | 28 | 29 | # install ignores 30 | /dist.gitignore 31 | /config 32 | /LICENSE 33 | /migrations 34 | /nose.ini 35 | /backend 36 | /requirements 37 | /requirements.txt 38 | /var 39 | /uwsgi.ini 40 | /wsgi.py 41 | /cli 42 | /web 43 | /.env 44 | /dist.env 45 | /boil 46 | 47 | # temporary ignores for extracting users into separate package 48 | /shiftuser 49 | 50 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Dmitry Belyakov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include boiler/boiler_template * 2 | recursive-include boiler/migrations * 3 | recursive-include boiler/templates * 4 | recursive-include boiler/_requirements * 5 | 6 | global-exclude *.py[co] -------------------------------------------------------------------------------- /README-PyPi.md: -------------------------------------------------------------------------------- 1 | # shift-boiler 2 | 3 | ![boiler](https://s3-eu-west-1.amazonaws.com/public-stuff-cdn/boiler.png) 4 | 5 | Boiler is a best-practices setup of [flask framework](http://flask.pocoo.org/) integrated with a number of libraries to quickly bootstrap app development. You can do console applications, web apps or apis with boiler. It is also a good example of how to set up flask framework for large projects. 6 | 7 | [Full documentation available on GitHub](https://github.com/projectshift/shift-boiler) 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # shift-boiler 2 | 3 | ![boiler](https://s3-eu-west-1.amazonaws.com/public-stuff-cdn/boiler.png) 4 | 5 | Boiler is a best-practices setup of [flask framework](http://flask.pocoo.org/) integrated with a number of libraries to quickly bootstrap app development. You can do console applications, web apps or apis with boiler. It is also a good example of how to set up flask framework for large projects or scale from micro- to relatively big system by adding features and extensions as the project grows. 6 | 7 | 8 | Here are some main features all of which are pluggable and optional: 9 | 10 | * [Click](http://click.pocoo.org/) integration for CLI apps 11 | * Web app scaffolding 12 | * API app scaffolding and [Restfulness](https://flask-restful.readthedocs.io/) 13 | * ORM with [SQLAlchemy](http://www.sqlalchemy.org/) 14 | * Database migrations with [Alembic](https://bitbucket.org/zzzeek/alembic) 15 | * Entity/model validation framework with [shift-schema](https://github.com/projectshift/shift-schema) 16 | * Localization and translations with [Babel](https://pythonhosted.org/Flask-Babel/) 17 | * Web forms with [WTForms](https://wtforms.readthedocs.io/en/latest/) 18 | * Routing with lazy-views and on-demand view import 19 | * Set of useful Jinja additions and filters including support for versioned static assets. 20 | * All of the features are pluggable and optional. Use whatever you need. 21 | 22 | 23 | ## Ridiculously quick start 24 | 25 | Create virtual environment: 26 | 27 | ``` 28 | mkdir boiler-testdrive && cd boiler-testdrive 29 | virtualenv -p python3 env 30 | source env/bin/activate 31 | ``` 32 | 33 | Install and run boiler: 34 | 35 | ``` 36 | pip install shiftboiler 37 | boiler init . 38 | boiler dependencies flask 39 | ./cli run 40 | ``` 41 | 42 | This was quickstart for robots. We also have a [quickstart for humans](docs/quickstart.md), with some further exaplanations. 43 | 44 | ## Versioning 45 | 46 | We loosely follow [semver](https://semver.org/) except we did not have a major 47 | release yet to indicate the fact that boiler is still not entirely production ready. 48 | however we did successfully used it in production on multiple occasions for 49 | webapps and apis. Just remember to freeze your boiler version in requirements 50 | file and expect minor versions to introduce breaking changes. 51 | 52 | 53 | ## Documentation 54 | 55 | * [Quickstart for humans](docs/quickstart.md) 56 | * [Configurtion best practices](docs/config.md) 57 | * [Boiler features](docs/features.md) 58 | * [Testing: helpers and environment](docs/testing.md) 59 | * Working with collections 60 | * Working with forms: entity validation and recaptcha 61 | * [Changelog](docs/changelog.md) 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /boiler/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def load_dotenvs(): 5 | """ 6 | Load dotenvs 7 | Loads .env and .flaskenv files from project root directory. 8 | :return: 9 | """ 10 | if not os.getenv('DOTENVS_LOADED'): 11 | envs = ['.env', '.flaskenv'] 12 | for env in envs: 13 | path = os.path.join(os.getcwd(), env) 14 | if os.path.isfile(path): 15 | dotenvs(path) 16 | os.environ['DOTENVS_LOADED'] = 'yes' 17 | 18 | 19 | # run immediately 20 | dotenvs = True 21 | try: 22 | from dotenv import load_dotenv as dotenvs 23 | load_dotenvs() 24 | except ImportError: 25 | pass 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /boiler/_requirements/all.txt: -------------------------------------------------------------------------------- 1 | shiftboiler 2 | 3 | 4 | # tetsing 5 | rednose>=1.3.0,<2.0.0 6 | nose==1.3.7 7 | Faker>=13.13.0,<14.0.0 8 | coverage>=6.4.1,<7.0.0 9 | 10 | # flask 11 | blinker==1.4 12 | speaklater==1.3 13 | Flask>=2.1.2,<2.2.0 14 | Flask-WTF>=1.0.1,<1.1.0 15 | python-dotenv>=0.17.1,<1.0.0 16 | pyOpenSSL>=20.0.1,<21.0.0 17 | 18 | # mail 19 | Flask-Mail==0.9.1 20 | 21 | # orm 22 | Flask-SQLAlchemy>=2.5.1,<2.6.0 23 | alembic>=1.8.0,<2.0.0 24 | # PyMySQL>=1.0.2,<2.0.0 25 | # mysqlclient>=2.1.1,<3.0.0 26 | # mysql-connector-python>=8.0.25,<9.0.0 27 | 28 | # api 29 | Flask-RESTful>=0.3.9,<1.0.0 30 | 31 | # localize and translate 32 | Flask-Babel>=2.0.0,<3.0.0 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /boiler/_requirements/api.txt: -------------------------------------------------------------------------------- 1 | Flask-RESTful>=0.3.9,<1.0.0 -------------------------------------------------------------------------------- /boiler/_requirements/flask.txt: -------------------------------------------------------------------------------- 1 | blinker==1.4 2 | speaklater==1.3 3 | Flask>=2.1.2,<2.2.0 4 | Flask-WTF>=1.0.1,<1.1.0 5 | python-dotenv>=0.17.1,<1.0.0 6 | pyOpenSSL>=20.0.1,<21.0.0 7 | -------------------------------------------------------------------------------- /boiler/_requirements/localization.txt: -------------------------------------------------------------------------------- 1 | Flask-Babel>=2.0.0,<3.0.0 -------------------------------------------------------------------------------- /boiler/_requirements/mail.txt: -------------------------------------------------------------------------------- 1 | Flask-Mail==0.9.1 -------------------------------------------------------------------------------- /boiler/_requirements/orm.txt: -------------------------------------------------------------------------------- 1 | Flask-SQLAlchemy>=2.5.1,<2.6.0 2 | alembic>=1.8.0,<2.0.0 3 | # PyMySQL>=1.0.2,<2.0.0 4 | # mysqlclient>=2.1.1,<3.0.0 5 | # mysql-connector-python>=8.0.25,<9.0.0 6 | 7 | -------------------------------------------------------------------------------- /boiler/_requirements/testing.txt: -------------------------------------------------------------------------------- 1 | rednose>=1.3.0,<2.0.0 2 | nose==1.3.7 3 | Faker>=13.13.0,<14.0.0 4 | coverage>=6.4.1,<7.0.0 5 | -------------------------------------------------------------------------------- /boiler/_static/css/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Globals 3 | */ 4 | * { 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | font-smooth: 2em; 8 | } 9 | html {height: 100%;} 10 | body { 11 | height: 100%; 12 | background-color: #fff; 13 | background-size: 100% auto; 14 | background-position: center; 15 | background-repeat: no-repeat; 16 | color: #000; 17 | text-align: center; 18 | } 19 | 20 | @media (max-width: 992px) { 21 | body { 22 | background-size: auto; 23 | } 24 | } 25 | 26 | .alert { 27 | border-radius: 0; 28 | } 29 | 30 | 31 | .site-wrapper { 32 | display: table; 33 | width: 100%; 34 | height: 100%; 35 | min-height: 100%; 36 | vertical-align: middle; 37 | 38 | box-shadow: inset 0 0 5rem rgba(0,0,0,.5); 39 | -webkit-box-shadow: inset 0 0 5rem rgba(0,0,0,.5); 40 | } 41 | .site-wrapper-inner { 42 | display: table-cell; 43 | /*vertical-align: top;*/ 44 | vertical-align: middle; 45 | } 46 | .cover-container { 47 | margin-right: auto; 48 | margin-left: auto; 49 | width: 80vh; 50 | } 51 | 52 | .cover-container-profile { 53 | width: 120vh; 54 | } 55 | @media (max-width: 780px) { 56 | .cover-container-profile { 57 | width: 90%; 58 | } 59 | } 60 | 61 | .form-box { 62 | margin: 30px 0; 63 | padding: 10px 50px 30px 50px; 64 | background: #fff; 65 | border-radius: 10px; 66 | box-shadow: 0 0 2rem rgba(0,0,0,0.7); 67 | text-align: left; 68 | } 69 | 70 | .form-box h2 { 71 | display: block; 72 | margin-bottom: 20px; 73 | padding-bottom: 10px; 74 | font-size: 19px; 75 | border: solid #c8c8c8; 76 | border-width: 0 0 1px 0; 77 | letter-spacing: -0.7px; 78 | } 79 | 80 | .form-box h2 .login-alternative { 81 | font-size: 17px; 82 | color: #828282; 83 | } 84 | 85 | .form-box h2 .login-alternative a { 86 | color: #828282; 87 | } 88 | 89 | .form-box .alert { 90 | padding: 12px; 91 | } 92 | 93 | .form-box p.user-result-message { 94 | display: block; 95 | margin: 20px 0 40px 0; 96 | } 97 | .form-box p.user-result-message-form { 98 | display: block; 99 | margin: 20px 0 20px 0; 100 | } 101 | 102 | .form-box label { 103 | color: #828282; 104 | font-weight: normal; 105 | font-size: 13px; 106 | } 107 | 108 | .checkbox input[type="checkbox"] { 109 | margin-left: 0; 110 | } 111 | 112 | 113 | .checkbox label { 114 | color: #000; 115 | font-size: 14px; 116 | } 117 | 118 | .form-box .invitation-text { 119 | display: block; 120 | margin-top: 20px; 121 | text-align: center; 122 | font-size: 13px; 123 | color: #9c9c9c; 124 | } 125 | 126 | .form-box .invitation-text a { 127 | text-decoration: underline; 128 | } 129 | 130 | .form-box div.errors { 131 | display: block; 132 | padding-top: 3px; 133 | color: #ff0066; 134 | font-size: 12px; 135 | } 136 | 137 | .form-box .social-login-buttons .btn { 138 | margin-bottom: 15px; 139 | } 140 | 141 | .form-box .social-login-buttons .fa { 142 | display: inline-block; 143 | margin-right: 20px; 144 | /*vertical-align: middle;*/ 145 | font-size: 22px; 146 | } 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | /* 156 | * Profile data 157 | * Various user profile styles 158 | */ 159 | 160 | .profile-data .nav { 161 | margin-top: 20px; 162 | margin-bottom: 30px; 163 | font-size: 12px; 164 | } 165 | 166 | .profile-data .nav li a { 167 | padding: 12px; 168 | } 169 | 170 | .profile-data h2 { 171 | margin-top: 0; 172 | } 173 | 174 | .profile-data .avatar { 175 | margin-bottom: 40px; 176 | max-width: 100%; 177 | } 178 | 179 | .profile-data table.user-summary { 180 | margin: 20px 0; 181 | width: 100%; 182 | } 183 | .profile-data table.user-summary .field { 184 | width: 30%; 185 | font-size: 13px; 186 | text-align: right; 187 | color: grey; 188 | } 189 | 190 | .profile-data .change-warning { 191 | position: relative; 192 | margin-bottom: 20px; 193 | padding: 10px; 194 | font-size: 12px; 195 | color: grey; 196 | } 197 | 198 | .profile-data .change-warning .message { 199 | margin-left: 30px; 200 | } 201 | 202 | .profile-data .change-warning .fa { 203 | position: absolute; 204 | display: inline-block; 205 | font-size: 16px; 206 | padding-right: 10px; 207 | vertical-align: middle; 208 | color: #ff0066; 209 | } 210 | 211 | 212 | 213 | /* 214 | * Social providers 215 | * Social providers in user's profile 216 | */ 217 | table.profile-social { 218 | width: 100%; 219 | border-collapse: collapse; 220 | } 221 | 222 | table.profile-social tbody tr { 223 | border: solid #ececec; 224 | border-width: 1px 0; 225 | } 226 | table.profile-social tbody tr:first-of-type { 227 | border-top-width: 0; 228 | } 229 | table.profile-social tbody td { 230 | padding: 20px; 231 | } 232 | 233 | table.profile-social .tumbler { 234 | width: 70%; 235 | text-align: center 236 | } 237 | table.profile-social .network { 238 | width: 30%; 239 | vertical-align: middle; 240 | font-size: 13px; 241 | color: #8c8c8c 242 | } 243 | table.profile-social i.fa { 244 | display: inline-block; 245 | margin-right: 10px; 246 | vertical-align: middle; 247 | font-size: 25px; 248 | } 249 | table.profile-social .network.network-facebook .fa { 250 | color: #3a47a5; 251 | } 252 | table.profile-social .network.network-google .fa { 253 | color: #da0035; 254 | } 255 | table.profile-social .network.network-vkontakte .fa { 256 | color: #5471a3; 257 | vertical-align: bottom 258 | } 259 | 260 | 261 | 262 | /* 263 | * Switch 264 | * UI switch element 265 | */ 266 | /*$switchHeight: 25px;*/ 267 | .profile-social .switch { 268 | display: inline-block; 269 | vertical-align: middle; 270 | margin: 0; 271 | padding: 1px; 272 | height: 29px; 273 | width: 64px; 274 | background: black; 275 | border: 1px solid black; 276 | border-radius: 100px; 277 | text-align: right; 278 | overflow: hidden; 279 | cursor: pointer; 280 | } 281 | 282 | .profile-social .switch span { 283 | display: inline-block; 284 | margin: 0 0; padding: 0; 285 | height: 25px; width: 25px; 286 | background: #fff; 287 | border-radius: 100px; 288 | box-shadow: 0 0 2px rgba(0,0,0,0.4); 289 | } 290 | 291 | .profile-social .switch-on { 292 | background: #52d66a; 293 | border-color: #4ec562; 294 | text-align: right; 295 | } 296 | 297 | .profile-social .switch-off { 298 | background: #ececec; 299 | border-color: #d6d6d6; 300 | text-align: left; 301 | } 302 | 303 | 304 | 305 | 306 | 307 | 308 | /* 309 | * Recaptcha 310 | * This holds stying for the custom recaptcha widget 311 | */ 312 | 313 | 314 | .form-control.recaptcha-input { 315 | height: 60px; 316 | border: 1px solid #dfdfdf; 317 | text-align: center; 318 | font-size: 18px; 319 | } 320 | 321 | .recaptcha { 322 | margin-bottom: 10px; 323 | } 324 | 325 | .recaptcha .recaptcha-image { 326 | width: 100% !important; 327 | height: 60px !important; 328 | border: 4px solid #dfdfdf; 329 | text-align: center; 330 | vertical-align: top; 331 | line-height: 12px; 332 | } 333 | 334 | .recaptcha .recaptcha-image span { 335 | cursor: pointer; 336 | display: inline-block; 337 | vertical-align: top; 338 | font-size: 11px; 339 | } 340 | 341 | .recaptcha .recaptcha-image span a {color: darkgreen; text-decoration: none} 342 | .recaptcha .recaptcha-image span a:hover {color: green; text-decoration: underline} 343 | 344 | 345 | .recaptcha .recaptcha-image img { 346 | width: 100% !important; 347 | height: 100% !important; 348 | } 349 | 350 | .recaptcha .recaptcha-controls { 351 | display: block; 352 | margin-top: 3px; 353 | } 354 | 355 | .recaptcha .recaptcha-controls a {color: #b2b2b2} 356 | .recaptcha .recaptcha-controls a:hover {color: darkgreen} 357 | 358 | .recaptcha .recaptcha-controls .google { 359 | float: right; 360 | font-size: 11px; 361 | } 362 | 363 | .google a, .google a:hover {text-decoration: none; color: #b2b2b2} 364 | .google p {display: inline-block; padding: 0; margin: 0;} 365 | .google p.blue {color: #3a53e8} 366 | .google p.red {color: #cd3a2c} 367 | .google p.yellow {color: #f8ba08} 368 | .google p.green {color: #1f9a54} 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | -------------------------------------------------------------------------------- /boiler/_static/js/backgrounds.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function(event) { 2 | var body = document.getElementsByTagName('body')[0]; 3 | body.style.background = 'url(' + randomBackground() + ')'; 4 | }); 5 | 6 | function randomBackground() { 7 | var items = [ 8 | 1003, 1004, 1006, 1007, 1008, 1010, 1012, 1014, 1017, 1018, 1019, 1021, 9 | 1022, 1023, 1024, 1026, 1027, 1032, 1033, 1034, 1035, 1036, 1037, 1038, 10 | 1039, 1040, 1041, 1046, 1047, 1048, 1049, 1050, 1052, 1053, 1054, 1055, 11 | 1056, 1057, 1063, 1064, 1065, 1066, 1067, 1068, 1069, 1070, 1071, 1074, 12 | 1075, 1077, 1078, 1080, 1081, 1082, 1084, 1085, 1086, 1087, 1089, 1091, 13 | 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1101, 1102, 1103, 1104, 14 | 1105, 1107, 1109, 1110, 1114, 1115, 1116, 1118, 1119, 1121, 1122, 1123, 15 | 1125, 1127, 1128, 1131, 1132, 1133, 1134, 1135, 1138, 1139, 1140, 1141, 16 | 1143, 1147, 1148, 1151, 1152, 1154, 1155, 1156, 1157, 1158, 1159, 1160, 17 | 1161, 1163, 1164, 1165, 1166, 1167, 1168, 1169, 1170, 1172, 1173, 1174, 18 | 1176, 1177, 1178, 1180, 1181, 1183, 1184, 1186, 1190, 1191, 1192, 1195, 19 | 1196, 1197, 1198, 1199, 1206, 1207, 1209, 1211, 1212, 1215, 1216, 1217, 20 | 1221, 1222, 1224, 1225, 1226, 1229, 1230, 1231, 1233, 1237, 1238, 1239, 21 | 1240, 1241, 1242, 1243, 1245, 1247, 1248, 1251, 1253, 1254, 1255, 1256, 22 | 1257, 1258, 1259, 1260, 1265, 1267, 1268, 1269, 1270, 1273, 1274, 1277, 23 | 1280, 1282, 1285, 1286, 1287, 1289, 1290, 1292, 1293, 1297, 1298, 1300, 24 | 1301, 1302, 1308, 1309, 1312, 1316, 1317, 1323, 1324, 1325, 1326, 1329, 25 | 1332, 1336, 1337, 1338, 1341, 1342, 1343, 1345, 1348, 1349, 1350, 1351, 26 | 1352, 1353, 1354, 1355, 1356, 1358, 1359, 1363, 1364, 1368, 1369, 1370, 27 | 1371, 1373, 1374, 1375, 1377, 1378, 1381, 1383, 1385, 1388, 1393, 1394, 28 | 1396, 1397, 1398, 1399, 1400, 1402, 1403, 1406, 1407, 1408, 1409, 1413, 29 | 1414, 1416, 1417, 1418, 1419, 1420, 1421, 1423, 1427, 1429, 1430, 1432, 30 | 1434, 1435, 1436, 1437, 1438, 1440, 1443, 1444, 1446, 1447, 1448, 1449, 31 | 1450, 1451, 1456, 1457, 1463, 1464, 1466, 1468, 1470, 1471, 1472, 1474, 32 | 1475, 1476, 1477, 1478, 1484, 1485, 1487, 1488, 1490, 1491, 1492, 1494, 33 | 1495, 1496, 1498, 1500, 1501, 1502, 1505, 1506, 1508, 1509, 1510, 1511, 34 | 1512, 1514, 1515, 1516, 1517, 1518, 1519, 1521, 1523, 1525, 1526, 1527, 35 | 1528, 1529, 1530, 1531, 1534, 1537, 1538, 1539, 1540, 1541, 1542, 1543, 36 | 1544, 1545, 1546, 1548, 1550, 1551, 1553, 1556, 1557, 1558, 1559, 1560, 37 | 1561, 1563, 1565, 1567, 1568, 1569, 1572, 1574, 1578, 1579, 1582, 1583, 38 | 1584, 1585, 1588, 1589, 1591, 1594, 1595, 1598, 1600, 1606, 1607, 1608, 39 | 1609, 1610, 1611, 1612, 1613, 1614, 1615, 1617, 1618, 1620, 1623, 1626, 40 | 1628, 1629, 1630, 1634, 1636, 1637, 1639, 1640, 1641, 1643, 1644, 1645, 41 | 1646, 1648, 1652, 1653, 1655, 1657, 1660, 1661, 1662, 1663, 1664, 1666, 42 | 1668, 1669, 1672, 1673, 1674, 1675, 1676, 1681, 1683, 1684, 1685, 1686, 43 | 1687, 1688, 1689, 1690, 1692, 1694, 1695, 1697, 1698, 1699, 1701, 1702, 44 | 1703, 1704, 1707, 1710, 1711, 1712, 1713, 1714, 1716, 1718, 1719, 1720, 45 | 1721, 1722, 1724, 1725, 1727, 1728, 1729, 1730, 1731, 1732, 1734, 1735, 46 | 1737, 1738, 1739, 1740, 1741, 1742, 1746, 1750, 1754, 1756, 1758, 1759, 47 | 1760, 1761, 1762, 1763, 1766, 1767, 1768, 1770, 1771, 1772, 1774, 1775, 48 | 1776, 1777, 1778, 1779, 1780, 1782, 1783, 1784, 1785, 1786, 1787, 1788, 49 | 1790, 1792, 1793, 1796, 1797, 1799, 1800, 1801, 1804, 1805, 1806, 1807, 50 | 1808, 1809, 1810, 1811, 1812, 1816, 1817, 1820, 1821, 1822, 1823, 1824, 51 | 1825, 1826, 1828, 1829, 1831, 1832, 1833, 1834, 1835, 1836, 1837, 1838, 52 | 1839, 1840, 1841, 1842, 1843, 1844, 1845, 1846, 1849, 1852, 1853, 1854, 53 | 1855, 1857, 1858, 1859, 1860, 1861, 1863, 1864, 1868, 1870, 1872, 1873, 54 | 1875, 1883, 1884, 1885, 1887, 1888, 1889, 1890, 1891, 1893, 1894, 1897, 55 | 1901, 1902, 1903, 1904, 1905, 1907, 1908, 1909, 1910, 1911, 1912, 1913, 56 | 1915, 1919, 1920, 1921, 1922, 1923, 1924, 1925, 1926, 1927, 1934, 1935, 57 | 1936, 1937, 1938, 1939, 1940, 1942, 1943, 1945, 1946, 1947, 1948, 1949, 58 | 1951, 1952, 1954, 1955, 1956, 1957, 1959, 1960, 1961, 1962, 1964, 1965, 59 | 1966, 1967, 1968, 1969, 1970, 1971, 1972, 1973, 1974, 1975, 1976, 1977, 60 | 1978, 1979, 1980, 1981, 1982, 1983, 1984, 1986, 1987, 1989, 1990, 1991, 61 | 1992, 1993, 1994, 1995, 1998, 1999, 2000, 2001, 2002, 2003, 2007, 2009, 62 | 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2021, 2022, 2023, 63 | 2024, 2025, 2026, 2027, 2028, 2029, 2030, 2031, 2032, 2033, 2034, 2035, 64 | 2036, 2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 65 | 2048, 2049, 2050, 2051, 2052, 2054, 2055, 2056, 2057, 2058, 2059, 2060, 66 | 2061, 2062, 2063, 2064, 2065, 2066, 2067, 2068, 2069, 2070, 2071, 2072, 67 | 2074, 2075, 2076, 2078, 2081, 2082, 2083, 2084, 2088, 2090, 2091, 2093, 68 | 2095, 2096, 2097, 2098, 2100, 2102, 2103, 2109, 2112, 2113, 2116, 2118, 69 | 2120, 2121, 2124, 2125, 2126, 2131, 2132, 2135, 2137, 2138, 2139, 2140, 70 | 2141, 2142, 2145, 2147, 2148, 2149, 2150, 2151, 2152, 2154, 2156, 2157, 71 | 2159, 2160, 2161, 2162, 2165, 2166, 2167, 2168, 2169, 2170, 2171, 2175, 72 | 2176, 2177, 2179, 2180, 2181, 2182, 2183, 2186, 2187, 2188, 2190, 2191, 73 | 2192, 2194, 2195, 2197, 2198, 2199, 2202, 2203, 2204, 2205, 2206, 2207, 74 | 2209, 2211, 2212, 2213, 2216, 2218, 2220, 2222, 2223, 2224, 2227, 2228, 75 | 2229, 2230, 2231, 2237, 2239, 2240, 2241, 2243, 2244, 2246, 2247, 2248, 76 | 2249, 2251, 2252, 2253, 2256, 2258, 2259, 2260, 2263, 2264, 2265, 2266, 77 | 2268, 2269, 2270, 2272, 2273, 2274, 2275, 2276, 2277, 2278, 2280, 2281, 78 | 2284, 2287, 2288, 2290, 2291, 2292, 2293, 2294, 2295, 2296, 2297, 2299, 79 | 2303, 2304, 2305, 2307, 2308, 2311, 2312, 2313, 2314, 2315, 2316, 2317, 80 | 2318, 2319, 2321, 2322, 2323, 2324, 2325, 2326, 2327, 2329, 2330, 2331, 81 | 2332, 2333, 2334, 2337, 2340, 2341, 2342, 2343, 2344, 2345, 2346, 2347, 82 | 2350, 2357, 2360, 2361, 2364, 2367, 2368, 2371, 2372, 2374, 2375, 2377, 83 | 2378, 2379, 2380, 2381, 2382, 2383, 2385, 2386, 2388, 2389, 2390, 2391, 84 | 2392, 2393, 2395, 2397, 2398, 2399, 2401, 2402, 2403, 2405, 2406, 2407, 85 | 2408, 2409, 2410, 2411, 2412, 2413, 2414, 2416, 2418, 2419, 2421, 2422, 86 | 2423, 2426, 2430, 2431, 2432, 2433, 2434, 2435, 2436, 2437, 2438, 2439, 87 | 2442, 2443, 2444, 2446, 2447, 2448, 5003, 5004, 5005, 5007, 5008, 5012, 88 | 5015, 5016, 5019, 5022, 5023, 5027, 5028, 5035, 5037, 5038, 5039, 5040, 89 | 5041, 5043, 5044, 5045, 5046, 5047, 5048, 5051, 5052, 5053, 5056, 5057, 90 | 5060, 5062, 5063, 5064, 5065, 5066, 5071, 5072, 5073, 5076, 5077, 5078, 91 | 5079, 5080, 5081, 5082, 5097, 5103, 5104, 5105, 5111, 5121, 5126, 5147, 92 | 5163, 5164, 5165, 5167, 5168, 5172, 5173, 5178, 5179, 5181, 5182, 5183, 93 | 5188, 5189, 5192, 5198, 5199, 5206, 5207, 5215, 5216, 5217, 5228, 5231, 94 | 5234, 5237, 5238, 5242, 5243, 5244, 5245, 5253, 5254, 5255, 5290, 5296, 95 | 5302, 5304, 5310, 5314, 5319, 5325, 5329, 5330, 5333, 5334, 5338, 5355, 96 | 5361, 5365, 5375, 5382, 5389, 5396, 5403, 5412, 5422, 5423, 5424, 5425, 97 | 5426, 5438, 5445, 5451, 5452, 5454, 5456, 5457, 5460, 5461, 5462, 5464, 98 | 5474, 5477, 5478, 5479, 5480, 5481, 5484, 5485, 5487, 5502, 5508, 5511, 99 | 5513, 5527, 5528, 5529, 5530, 5531, 5533, 5536, 5538, 5542, 5551, 5553, 100 | 5555, 5556, 5560, 5562, 5563, 5564, 5565, 5569, 5575, 5577, 5583, 5584, 101 | 5586, 5587, 5588, 5589, 5591, 5594, 5595, 5596, 5597, 5604, 5607, 5609, 102 | 5611, 5612, 5614, 5616, 5617, 5618, 5619, 5620, 5624, 5626, 5628, 5629, 103 | 5630, 5634, 5635, 5636, 5641, 5646, 5651, 5653, 5654, 5660, 5663, 5666, 104 | 5668, 5674, 5675, 5676, 5686, 5688, 5689, 5692, 5705, 5720, 5723, 5724, 105 | 5729, 5741, 5744, 5749, 5755, 5761, 5762, 5763, 5766, 5767, 5770, 5773, 106 | 5778, 5786, 5787, 5790, 5792, 5795, 5796, 5797, 5809, 5810, 5818, 5822, 107 | 5828, 5829, 5836, 5855, 5859, 5862, 5864, 5870, 5874, 5882, 5884, 5890, 108 | 5898, 5901, 5924, 5933, 5938, 5941, 5944, 5945, 5951, 5952, 5954, 5955, 109 | 5957, 5958, 5959, 5960, 5964, 5975, 5977, 5978, 5979, 5981, 5982, 5984, 110 | 5989, 5992, 5995, 5999, 6001, 6002, 6003, 6004, 6005, 6006, 6007, 6008, 111 | 6011, 6013, 6014, 6015, 6016, 6017, 6018, 6019, 6022, 6025, 6032, 6041, 112 | 6043, 6044, 6045, 6046, 6048, 6049, 6050, 6051, 6052, 6053, 6054, 6055, 113 | 6056, 6057, 6058, 6059, 6060, 6062, 6063, 6065, 6066, 6068, 6069, 6070, 114 | 6072, 6072, 6073, 6074, 6078, 6079, 6080, 6081, 6082, 6095, 6096, 6097, 115 | 6099, 6100, 6101, 6102, 6103, 6105, 6107, 6108, 6109, 6110, 6112, 6114, 116 | 6116, 6117, 6119, 6120, 6121, 6122, 6124, 6125, 6134, 6135, 6136, 6137, 117 | 6138, 6139, 6140, 6141, 6143, 6144, 6146, 6149, 6150, 6151, 6152, 6153, 118 | 6155, 6160, 6161, 6167, 6170, 6175, 6176, 6177, 6178, 6180, 6181, 6182, 119 | 6183, 6184, 6186, 6187, 6189, 6201, 6202, 6204, 6205, 6206, 6207, 6208, 120 | 6209, 6210, 6211, 6213, 6215, 6216, 6217, 6218, 6222, 6228, 6229, 6230, 121 | 6231, 6232, 6233, 6234, 6235, 6241, 6244, 6248, 6254, 6255, 6256, 6257, 122 | 6258, 6259, 6260, 6262, 6263, 6264, 6265, 6266, 6267, 6269, 6271, 6272, 123 | 6275, 6276, 6279, 6280, 6281, 6282, 6283, 6284, 6285, 6287, 6290, 6291, 124 | 6292, 6293, 6294, 6295, 6296, 6298, 6300, 6301, 6302, 6303, 6304, 6311, 125 | 6313, 6315, 6316, 6317, 6318, 6319, 6320, 6321, 6324, 6325, 6326, 6339, 126 | 6340, 6341, 6342, 6344, 6345, 6346, 6347, 6348, 6349, 6350, 6351, 6352, 127 | 6355, 6356, 6358, 6359, 6360, 6361, 6362, 6363, 6364, 6365, 6366, 6367, 128 | 6368, 6371, 6372, 6374, 6375, 6376, 6377, 6378, 6379, 6380, 6381, 6382, 129 | 6383, 6384, 6386, 6387, 6388, 6389, 6402, 6409, 6410, 6424, 6435, 6436, 130 | 6441, 6443, 6457, 6459, 6460, 6465, 6470, 6473, 6488, 6491, 6495, 6496, 131 | 6498, 6500, 6503, 6504, 6510, 6512, 6519, 6524, 6525, 6527, 6528, 6531, 132 | 6543, 6545, 6561, 6565, 6566, 6575, 6578, 6579, 6587, 6588, 6589, 6590, 133 | 6600, 6607, 7001, 7002, 7003, 7004, 7005, 7006, 7007, 7008, 7009, 7010, 134 | 7011, 7012, 7013, 7014, 7015, 7016, 7017, 7018, 7019, 7020, 7021, 7022, 135 | 7023 136 | ]; 137 | 138 | var item = items[Math.floor(Math.random() * items.length)]; 139 | return 'https://www.gstatic.com/prettyearth/assets/full/' + item + '.jpg'; 140 | } 141 | -------------------------------------------------------------------------------- /boiler/abstract/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/abstract/__init__.py -------------------------------------------------------------------------------- /boiler/abstract/abstract_service.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask import current_app 3 | from boiler.feature.orm import db 4 | 5 | class AbstractService: 6 | """ 7 | Abstract service 8 | Base class for services that encapsulates common model operations. 9 | Extend your concrete services from this class and define __model__ 10 | """ 11 | __model__ = None 12 | __create_validator__ = None 13 | __persist_validator__ = None 14 | 15 | def log(self, message, level=None): 16 | """ Write a message to log """ 17 | if level is None: 18 | level = logging.INFO 19 | 20 | current_app.logger.log(msg=message, level=level) 21 | 22 | def is_instance(self, model): 23 | """ 24 | Is instance? 25 | Checks if provided object is instance of this service's model. 26 | 27 | :param model: object 28 | :return: bool 29 | """ 30 | result = isinstance(model, self.__model__) 31 | if result is True: 32 | return True 33 | 34 | err = 'Object {} is not of type {}' 35 | raise ValueError(err.format(model, self.__model__)) 36 | 37 | def commit(self): 38 | """ 39 | Commit 40 | Commits orm transaction. Used mostly for bulk operations when 41 | flush is of to commit multiple items at once. 42 | 43 | :return: None 44 | """ 45 | db.session.commit() 46 | 47 | def new(self, **kwargs): 48 | """ 49 | New 50 | Returns a new unsaved instance of model, populated from the 51 | provided arguments. 52 | 53 | :param kwargs: varargs, data to populate with 54 | :return: object, fresh unsaved model 55 | """ 56 | return self.__model__(**kwargs) 57 | 58 | def create(self, **kwargs): 59 | """ 60 | Create 61 | Instantiates and persists new model populated from provided 62 | arguments 63 | 64 | :param kwargs: varargs, data to populate with 65 | :return: object, persisted new instance of model 66 | """ 67 | model = self.new(**kwargs) 68 | return self.save(model) 69 | 70 | def save(self, model, commit=True): 71 | """ 72 | Save 73 | Puts model into unit of work for persistence. Can optionally 74 | commit transaction. Returns persisted model as a result. 75 | 76 | :param model: object, model to persist 77 | :param commit: bool, commit transaction? 78 | :return: object, saved model 79 | """ 80 | self.is_instance(model) 81 | db.session.add(model) 82 | if commit: 83 | db.session.commit() 84 | 85 | return model 86 | 87 | def delete(self, model, commit=True): 88 | """ 89 | Delete 90 | Puts model for deletion into unit of work and optionall commits 91 | transaction 92 | 93 | :param model: object, model to delete 94 | :param commit: bool, commit? 95 | :return: object, deleted model 96 | """ 97 | self.is_instance(model) 98 | db.session.delete(model) 99 | if commit: 100 | db.session.commit() 101 | 102 | return model 103 | 104 | def get(self, id): 105 | """ 106 | Get 107 | Returns single entity found by id, or None if not found 108 | 109 | :param id: int, entity id 110 | :return: object or None 111 | """ 112 | return self.__model__.query.get(id) 113 | 114 | def get_or_404(self, id): 115 | """ 116 | Get or 404 117 | Returns single entity found by its unique id, or raises 118 | htp 404 exception if nothing is found. 119 | 120 | :param id: int, entity id 121 | :return: object 122 | """ 123 | return self.__model__.query.get_or_404(id) 124 | 125 | def get_multiple(self, ids): 126 | m = self.__model__ 127 | query = m.query.filter(m.id.in_(ids)) 128 | return query.all() 129 | 130 | def find(self, **kwargs): 131 | return self.__model__.query.filter_by(**kwargs).all() 132 | 133 | def first(self, **kwargs): 134 | return self.__model__.query.filter_by(**kwargs).first() 135 | 136 | def collection(self, page=None, per_page=None, serialized=None, **kwargs): 137 | pass 138 | 139 | -------------------------------------------------------------------------------- /boiler/boiler_template/backend/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from boiler import bootstrap 3 | 4 | # create app 5 | flask_app = os.environ.get('FLASK_APP') 6 | app = bootstrap.create_app(name=flask_app, config=bootstrap.get_config()) 7 | 8 | # enable features 9 | bootstrap.add_routing(app) 10 | # bootstrap.add_orm(app) 11 | # bootstrap.add_logging(app) 12 | # bootstrap.add_mail(app) 13 | # bootstrap.add_localization(app) 14 | -------------------------------------------------------------------------------- /boiler/boiler_template/backend/config.py: -------------------------------------------------------------------------------- 1 | from boiler import config 2 | import os 3 | 4 | 5 | class BaseConfig(config.Config): 6 | """ 7 | Base config 8 | Use this to store configuration options that a shared among 9 | environment-specific configs below. 10 | """ 11 | pass 12 | 13 | 14 | class ProductionConfig(config.ProductionConfig, BaseConfig): 15 | """ Production config """ 16 | pass 17 | 18 | 19 | class DevConfig(config.DevConfig, BaseConfig): 20 | """ Local development config """ 21 | 22 | # static assets 23 | ASSETS_PATH = '/' 24 | FLASK_STATIC_PATH = os.path.realpath(os.getcwd() + '/web') 25 | 26 | 27 | class TestingConfig(config.TestingConfig, BaseConfig): 28 | """ Local testing config """ 29 | pass 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /boiler/boiler_template/backend/templates/index/home.j2: -------------------------------------------------------------------------------- 1 | {% extends 'layout.j2' %} 2 | {% block body %} 3 |

Welcome to flask!

4 | {% endblock %} -------------------------------------------------------------------------------- /boiler/boiler_template/backend/templates/layout.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Welcome to flask 9 | 10 | 11 | 12 | {# icons #} 13 | 14 | 15 | {# styles #} 16 | {% block stylesheets %}{% endblock %} 17 | 18 | 19 | 20 | 21 | {# body content #} 22 | {% block body %}{% endblock %} 23 | 24 | {# scripts #} 25 | {% block javascripts %}{%- endblock -%} 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /boiler/boiler_template/backend/urls.py: -------------------------------------------------------------------------------- 1 | from boiler.routes.route import route 2 | """ 3 | A note on URLs: please define your URLS with a trailing slash (unless it has 4 | an extension of course)! This way they will work both with and without trailing 5 | slash. If it's missing - Flask will just add it. 6 | 7 | """ 8 | 9 | urls = dict() 10 | urls['/'] = route('backend.views.home', 'home') 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /boiler/boiler_template/backend/views.py: -------------------------------------------------------------------------------- 1 | from flask import render_template 2 | 3 | 4 | def home(): 5 | """ 6 | Home action 7 | Displays project homepage 8 | :return: string 9 | """ 10 | return render_template('index/home.j2') 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /boiler/boiler_template/cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from boiler.cli import cli as kernel 3 | 4 | # root project command 5 | cli = kernel.cli 6 | 7 | # add orm cli 8 | # from boiler.cli import db 9 | # cli.add_command(db.cli, name='db') 10 | 11 | 12 | # and run 13 | cli() 14 | 15 | -------------------------------------------------------------------------------- /boiler/boiler_template/dist.env: -------------------------------------------------------------------------------- 1 | FLASK_APP=backend.app 2 | FLASK_CONFIG=backend.config.DevConfig 3 | 4 | # secrets 5 | APP_SECRET_KEY=SET_ME 6 | APP_USER_JWT_SECRET=SET_ME -------------------------------------------------------------------------------- /boiler/boiler_template/dist.gitignore: -------------------------------------------------------------------------------- 1 | # OS specific files 2 | .DS_Store 3 | ehthumbs.db 4 | Thumbs.db 5 | __pycache__ 6 | 7 | # IDE files 8 | .buildpath 9 | .project 10 | .settings 11 | .idea 12 | nbproject 13 | www.esproj 14 | 15 | # Byte-compiled / optimized / DLL files 16 | __pycache__/ 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | pip-wheel-metadata/ 38 | share/python-wheels/ 39 | *.egg-info/ 40 | .installed.cfg 41 | *.egg 42 | MANIFEST 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Virtualenv ignores 50 | /env 51 | /bin 52 | /lib 53 | /share 54 | /include 55 | /man 56 | 57 | # Unit test / coverage reports 58 | htmlcov/ 59 | .tox/ 60 | .nox/ 61 | .coverage 62 | .coverage.* 63 | .cache 64 | nosetests.xml 65 | coverage.xml 66 | *.cover 67 | *.py,cover 68 | .hypothesis/ 69 | .pytest_cache/ 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | #Pipfile.lock 92 | 93 | # pyflow 94 | __pypackages__/ 95 | 96 | # Celery stuff 97 | celerybeat-schedule 98 | celerybeat.pid 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # pyre type checker 128 | .pyre/ 129 | 130 | # flask 131 | instance/ 132 | .webassets-cache 133 | 134 | # Project ignores 135 | node_modules 136 | npm-debug.log 137 | .sass-cache 138 | .tmp 139 | /config/* 140 | !/config/config.dist.py 141 | !/config/app.py 142 | /var/data/* 143 | !/var/data/.gitkeep 144 | /var/logs/* 145 | !/var/logs/.gitkeep 146 | 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /boiler/boiler_template/nose.ini: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | verbosity=2 3 | nocapture=1 4 | nologcapture=1 5 | rednose=1 6 | 7 | ;with-coverage=1 8 | ;cover-branches=1 9 | cover-package=backend 10 | cover-html=1 11 | cover-html-dir=var/data/tests 12 | -------------------------------------------------------------------------------- /boiler/boiler_template/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | 3 | # environment (required for module imports) 4 | virtualenv = env 5 | 6 | # set wsgi callable 7 | module = wsgi:app 8 | 9 | # http socket 10 | ;http = :5000 11 | ;stats = :8001 12 | #http-socket = :8090 13 | 14 | # unix socket 15 | socket = var/flask.sock 16 | chmod-socket = 777 17 | vacuum = true 18 | 19 | # concurrency 20 | master = true 21 | processes = 1 22 | threads = 2 23 | thunder-lock=1 24 | 25 | # logging 26 | disable-logging=1 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /boiler/boiler_template/var/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/boiler_template/var/.gitkeep -------------------------------------------------------------------------------- /boiler/boiler_template/var/data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/boiler_template/var/data/.gitkeep -------------------------------------------------------------------------------- /boiler/boiler_template/var/logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/boiler_template/var/logs/.gitkeep -------------------------------------------------------------------------------- /boiler/boiler_template/wsgi.py: -------------------------------------------------------------------------------- 1 | from backend.app import app -------------------------------------------------------------------------------- /boiler/bootstrap.py: -------------------------------------------------------------------------------- 1 | import os 2 | from os import path 3 | from flask import Flask 4 | from flask import g 5 | from flask import request 6 | from werkzeug.utils import import_string 7 | from werkzeug.utils import ImportStringError 8 | from jinja2 import ChoiceLoader, FileSystemLoader 9 | from flask_wtf import CSRFProtect 10 | 11 | from boiler.config import DefaultConfig 12 | from boiler.timer import restart_timer 13 | from boiler.errors import register_error_handler 14 | from boiler.jinja import functions as jinja_functions 15 | from boiler import exceptions as x 16 | 17 | 18 | def get_config(): 19 | """ 20 | Imports config based on environment. 21 | :return: 22 | """ 23 | flask_config = os.getenv('FLASK_CONFIG') 24 | if not flask_config: 25 | err = 'Unable to bootstrap application FLASK_CONFIG is not defined' 26 | raise x.BootstrapException(err) 27 | 28 | try: 29 | config_class = import_string(flask_config) 30 | except ImportError: 31 | err = 'Failed importing config file [{}]' 32 | raise x.BootstrapException(err.format(flask_config)) 33 | 34 | # and return 35 | config = config_class() 36 | return config 37 | 38 | 39 | def get_app(): 40 | """ 41 | Get app 42 | When run, it returns flask app that was previously created in userspace, 43 | or creates one if required thus avoiding recreating the app every time. 44 | Used in CLI commands, tests or other places requiring an instance 45 | of the app. 46 | :return: flask.Flask 47 | """ 48 | flask_app = os.getenv('FLASK_APP') 49 | if not flask_app: 50 | err = 'FLASK_APP undefined. Have you created a .env file?' 51 | raise x.BootstrapException(err) 52 | 53 | # check if importable (gives us good errors if not) 54 | test_import_name(flask_app) 55 | 56 | # import 57 | app = import_string(flask_app + '.app') 58 | return app 59 | 60 | 61 | def test_import_name(name): 62 | """ 63 | Test import name 64 | Checks the name of the module containing a flask app to detect whether it's 65 | a regular module or a namspace in which case flask won't be able to 66 | bootsrap and will give us a rather cryptic exception. This instead will 67 | provide a better explanation why the app cannot start. 68 | 69 | :param name: name of module containing flask app 70 | :return: bool 71 | """ 72 | # check FLASK_APP is importable 73 | imported = None 74 | try: 75 | imported = import_string(name) 76 | except ImportStringError or ModuleNotFoundError: 77 | pass 78 | 79 | # report if not 80 | if not imported: 81 | err = 'Unable to import FLASK_APP defined as "{}". ' 82 | err += 'Please verify this package exists.' 83 | raise x.BootstrapException(err.format(name)) 84 | 85 | # check if imported module is a namespace 86 | is_namespace = not imported.__file__ and type(imported.__path__) is not list 87 | if is_namespace: 88 | err = '\n\nProvided FLASK_APP "{}" is a namespace package.\n' 89 | err += 'Please verify that you are importing the app from a regular ' 90 | err += 'package and not a namespace.\n\n' 91 | err += 'For more info see:\n' 92 | err += 'Related ticket: https://bit.ly/package-vs-namespace:\n' 93 | err += 'Packages and namespaces in Python docs: ' 94 | err += 'https://docs.python.org/3/reference/import.html#packages\n' 95 | raise x.BootstrapException(err.format(name)) 96 | 97 | 98 | def create_app(name, config=None, flask_params=None): 99 | """ 100 | Create app 101 | Generalized way of creating a flask app. Use it in your concrete apps and 102 | do further configuration there: add app-specific options, extensions, 103 | listeners and other features. 104 | 105 | Note: application name should be its fully qualified __name__, something 106 | like project.api.app. This is how we fetch routing settings. 107 | """ 108 | # check import name 109 | test_import_name(name) 110 | 111 | # check config 112 | if not config: 113 | config = DefaultConfig() 114 | if config.__class__ is type: 115 | err = 'Config must be an object, got class instead.' 116 | raise x.BootstrapException(err) 117 | 118 | # check flask params 119 | flask_params = flask_params or dict() 120 | flask_params['import_name'] = name 121 | 122 | # configure static assets 123 | if config.get('FLASK_STATIC_URL') is not None: 124 | flask_params['static_url_path'] = config.get('FLASK_STATIC_URL') 125 | if config.get('FLASK_STATIC_PATH') is not None: 126 | flask_params['static_folder'] = config.get('FLASK_STATIC_PATH') 127 | 128 | # create an app with default config 129 | app = Flask(**flask_params) 130 | app.config.from_object(DefaultConfig()) 131 | 132 | # apply custom config 133 | if config: 134 | app.config.from_object(config) 135 | 136 | # enable csrf protection 137 | CSRFProtect(app) 138 | 139 | # register error handler 140 | register_error_handler(app) 141 | 142 | # use kernel templates 143 | kernel_templates_path = path.realpath(path.dirname(__file__)+'/templates') 144 | fallback_loader = FileSystemLoader([kernel_templates_path]) 145 | custom_loader = ChoiceLoader([app.jinja_loader, fallback_loader]) 146 | app.jinja_loader = custom_loader 147 | 148 | # register custom jinja functions 149 | app.jinja_env.globals.update(dict( 150 | asset=jinja_functions.asset, 151 | dev_proxy=jinja_functions.dev_proxy 152 | )) 153 | 154 | # time restarts? 155 | if app.config.get('TIME_RESTARTS'): 156 | restart_timer.time_restarts(os.path.join(os.getcwd(), 'var', 'data')) 157 | 158 | # detect dev proxy 159 | @app.before_request 160 | def detect_dev_proxy(): 161 | g.dev_proxy = False 162 | proxy_header = app.config.get('DEV_PROXY_HEADER') 163 | if proxy_header: 164 | g.dev_proxy = bool(request.headers.get(proxy_header)) 165 | 166 | return app 167 | 168 | # ------------------------------------------------------------------------------ 169 | # Feature toggles 170 | # ------------------------------------------------------------------------------ 171 | 172 | 173 | def add_routing(app): 174 | """ Add routing and lazy-views feature """ 175 | from boiler.feature.routing import routing_feature 176 | routing_feature(app) 177 | 178 | 179 | def add_mail(app): 180 | """ Add mailing functionality """ 181 | from boiler.feature.mail import mail_feature 182 | mail_feature(app) 183 | 184 | 185 | def add_orm(app): 186 | """ Add SQLAlchemy ORM integration """ 187 | from boiler.feature.orm import orm_feature 188 | orm_feature(app) 189 | 190 | 191 | def add_logging(app): 192 | """ Add logging functionality """ 193 | from boiler.feature.logging import logging_feature 194 | logging_feature(app) 195 | 196 | 197 | def add_localization(app): 198 | """ Enable support for localization and translations""" 199 | from boiler.feature.localization import localization_feature 200 | localization_feature(app) 201 | 202 | 203 | 204 | 205 | 206 | 207 | -------------------------------------------------------------------------------- /boiler/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/cli/__init__.py -------------------------------------------------------------------------------- /boiler/cli/boiler.py: -------------------------------------------------------------------------------- 1 | import click, os, sys, shutil 2 | from boiler.cli.colors import * 3 | from click import echo 4 | from boiler.version import version as boiler_version 5 | 6 | # ----------------------------------------------------------------------------- 7 | # Group setup 8 | # ----------------------------------------------------------------------------- 9 | 10 | 11 | @click.group(help=yellow('Boiler project tools')) 12 | def cli(): 13 | pass 14 | 15 | 16 | # ----------------------------------------------------------------------------- 17 | # Show version number 18 | # ----------------------------------------------------------------------------- 19 | 20 | @cli.command(name='version', help='Display current boiler version') 21 | def version(): 22 | """ 23 | Version 24 | Imports and displays current boiler version. 25 | :return: 26 | """ 27 | echo(green('\nshift-boiler:')) 28 | echo(green('-' * 40)) 29 | echo(yellow('Version: ') + '{}'.format(boiler_version)) 30 | echo(yellow('GitHub: ') + 'https://github.com/projectshift/shift-boiler') 31 | echo(yellow('PyPi: ') + 'https://pypi.org/project/shiftboiler/') 32 | echo() 33 | 34 | 35 | # ----------------------------------------------------------------------------- 36 | # Init project 37 | # ----------------------------------------------------------------------------- 38 | 39 | 40 | @cli.command(name='init') 41 | @click.argument('destination', type=click.Path(exists=True)) 42 | @click.option('--force', '-f', 43 | default=False, 44 | is_flag=True, 45 | help='Skip existing objects in destination' 46 | ) 47 | @click.option('--skip', '-s', 48 | default=False, 49 | is_flag=True, 50 | help='Skip existing objects in destination' 51 | ) 52 | def init(destination, force=False, skip=True): 53 | """ Initialise new project """ 54 | import os 55 | from uuid import uuid1 56 | import fileinput 57 | 58 | ignores = ['.DS_Store', '__pycache__', ] 59 | 60 | echo(green('\nInitialise project:')) 61 | echo(green('-' * 40)) 62 | 63 | destination = os.path.realpath(destination) 64 | source = os.path.realpath(os.path.dirname(__file__) + '/../boiler_template') 65 | 66 | # dry run first 67 | exist_in_dst = [] 68 | for path, dirs, files in os.walk(source): 69 | for dir in dirs: 70 | if dir in ignores: 71 | continue 72 | dst = os.path.join(path, dir).replace(source, destination) 73 | if os.path.exists(dst): 74 | exist_in_dst.append(dst) 75 | 76 | for file in files: 77 | if file in ignores: 78 | continue 79 | dst = os.path.join(path, file).replace(source, destination) 80 | if os.path.exists(dst): 81 | exist_in_dst.append(dst) 82 | 83 | # require force option if existing files found 84 | if exist_in_dst and not force and not skip: 85 | 86 | msg = 'The following objects were found in destination.' 87 | msg += 'What do you want to do with these?' 88 | echo(red(msg)) 89 | echo(red('Use either --force or --skip option \n')) 90 | 91 | for index,path in enumerate(exist_in_dst): 92 | echo(yellow('{}. {}'.format(index, path))) 93 | 94 | echo() 95 | return 96 | 97 | for path, dirs, files in os.walk(source): 98 | for dir in dirs: 99 | if dir in ignores: 100 | continue 101 | src = os.path.join(path, dir) 102 | dst = src.replace(source, destination) 103 | if('__pycache__' in src): 104 | continue 105 | 106 | if dst in exist_in_dst and force: 107 | echo(red('OVERWRITING: ' + dst)) 108 | if os.path.exists(dst): 109 | shutil.rmtree(dst, ignore_errors=True) 110 | os.makedirs(dst) 111 | elif dst in exist_in_dst and skip: 112 | echo(yellow('SKIPPING: ' + dst)) 113 | else: 114 | echo('CREATING: ' + dst) 115 | os.makedirs(dst) 116 | 117 | for file in files: 118 | if file in ignores: 119 | continue 120 | src = os.path.join(path, file) 121 | dst = src.replace(source, destination) 122 | if('__pycache__' in src): 123 | continue 124 | 125 | if dst in exist_in_dst and force: 126 | echo(red('OVERWRITING: ' + dst)) 127 | if os.path.exists(dst): 128 | os.remove(dst) 129 | shutil.copy(src, dst) 130 | elif dst in exist_in_dst and skip: 131 | echo(yellow('SKIPPING: ' + dst)) 132 | else: 133 | echo('CREATING: ' + dst) 134 | shutil.copy(src, dst) 135 | 136 | # create secret keys 137 | path = os.path.join(os.getcwd(), 'dist.env') 138 | secrets = ['USER_JWT_SECRET', 'SECRET_KEY'] 139 | for line in fileinput.input(path, inplace=True): 140 | line = line.strip('\n') 141 | found = False 142 | for secret in secrets: 143 | if secret in line: 144 | found = True 145 | break 146 | 147 | if not found: 148 | echo(line) 149 | else: 150 | echo(line.replace('SET_ME', '\'' + str(uuid1()) + '\'')) 151 | 152 | # create .env 153 | dotenv_dist = os.path.join(os.getcwd(), 'dist.env') 154 | dotenv = os.path.join(os.getcwd(), '.env') 155 | 156 | if not os.path.isfile(dotenv): 157 | shutil.copy(dotenv_dist, dotenv) 158 | 159 | # rename gitignore 160 | ignore_src = os.path.join(os.getcwd(), 'dist.gitignore') 161 | ignore_dst = os.path.join(os.getcwd(), '.gitignore') 162 | if os.path.isfile(ignore_src) and not os.path.exists(ignore_dst): 163 | shutil.move(ignore_src, ignore_dst) 164 | 165 | # create requirements file 166 | reqs = os.path.join(os.getcwd(), 'requirements.txt') 167 | if not os.path.exists(reqs): 168 | with open(reqs, 'a') as file: 169 | file.write('shiftboiler=={}\n'.format(boiler_version)) 170 | 171 | 172 | echo() 173 | return 174 | 175 | 176 | # ----------------------------------------------------------------------------- 177 | # Install feature dependencies 178 | # ----------------------------------------------------------------------------- 179 | 180 | @cli.command(name='dependencies') 181 | @click.argument('feature', default=None, required=False) 182 | def install_dependencies(feature=None): 183 | """ Install dependencies for a feature """ 184 | import subprocess 185 | 186 | echo(green('\nInstall dependencies:')) 187 | echo(green('-' * 40)) 188 | 189 | req_path = os.path.realpath(os.path.dirname(__file__) + '/../_requirements') 190 | 191 | # list features 192 | features = sorted(os.listdir(req_path)) 193 | 194 | # list all features if no feature name 195 | if not feature: 196 | echo(yellow('Please specify a feature to install. \n')) 197 | for index, item in enumerate(features): 198 | item = item.replace('.txt', '') 199 | echo(green('{}. {}'.format(index + 1, item))) 200 | 201 | echo() 202 | return 203 | 204 | # install if got feature name 205 | feature_file = feature.lower() + '.txt' 206 | feature_reqs = os.path.join(req_path, feature_file) 207 | 208 | # check existence 209 | if not os.path.isfile(feature_reqs): 210 | msg = 'Unable to locate feature requirements file [{}]' 211 | echo(red(msg.format(feature_file)) + '\n') 212 | return 213 | 214 | msg = 'Now installing dependencies for "{}" feature...'.format(feature) 215 | echo(yellow(msg)) 216 | 217 | subprocess.check_call([ 218 | sys.executable, '-m', 'pip', 'install', '-r', feature_reqs] 219 | ) 220 | 221 | # update requirements file with dependencies 222 | reqs = os.path.join(os.getcwd(), 'requirements.txt') 223 | if os.path.exists(reqs): 224 | with open(reqs) as file: 225 | existing = [x.strip().split('==')[0] for x in file.readlines() if x] 226 | 227 | lines = ['\n'] 228 | with open(feature_reqs) as file: 229 | incoming = file.readlines() 230 | 231 | for line in incoming: 232 | if not(len(line)) or line.startswith('#'): 233 | lines.append(line) 234 | continue 235 | 236 | package = line.strip().split('==')[0] 237 | if package not in existing: 238 | lines.append(line) 239 | 240 | with open(reqs, 'a') as file: 241 | file.writelines(lines) 242 | 243 | echo(green('DONE\n')) 244 | 245 | 246 | -------------------------------------------------------------------------------- /boiler/cli/cli.py: -------------------------------------------------------------------------------- 1 | import click, os 2 | from boiler.cli.colors import * 3 | 4 | # ----------------------------------------------------------------------------- 5 | # Group setup 6 | # ----------------------------------------------------------------------------- 7 | 8 | 9 | @click.group(help=yellow('Welcome to project console!')) 10 | def cli(): 11 | pass 12 | 13 | 14 | # ----------------------------------------------------------------------------- 15 | # Commands 16 | # ----------------------------------------------------------------------------- 17 | 18 | 19 | @cli.command(name='run') 20 | @click.option('--host', '-h', default='0.0.0.0', help='Bind to') 21 | @click.option('--port', '-p', default=5000, help='Listen on port') 22 | @click.option('--reload/--no-reload', default=True, help='Reload on change?') 23 | @click.option('--debug/--no-debug', default=True, help='Use debugger?') 24 | @click.option('--ssl', default=None, help='SSL context') 25 | def run(host='0.0.0.0', port=5000, reload=True, debug=True, ssl=None): 26 | """ Run development server """ 27 | from werkzeug.serving import run_simple 28 | from boiler import bootstrap 29 | 30 | # run with ssl context? 31 | ssl = ssl.lower() if ssl else None 32 | ssl_context = None 33 | if ssl == 'adhoc': 34 | ssl_context = ssl 35 | elif ssl and ssl.find(','): 36 | ssl = ssl.split(',') 37 | ssl_context = (ssl[0], ssl[1]) 38 | 39 | app = bootstrap.get_app() 40 | return run_simple( 41 | hostname=host, 42 | port=port, 43 | application=app, 44 | use_reloader=reload, 45 | use_debugger=debug, 46 | ssl_context=ssl_context 47 | ) 48 | 49 | 50 | @cli.command(name='shell') 51 | def shell(): 52 | """ Start application-aware shell """ 53 | import importlib 54 | from boiler import bootstrap 55 | 56 | app = bootstrap.get_app() 57 | context = dict(app=app) 58 | 59 | # and push app context 60 | app_context = app.app_context() 61 | app_context.push() 62 | 63 | # got ipython? 64 | ipython = importlib.util.find_spec("IPython") 65 | 66 | # run now 67 | if ipython: 68 | from IPython import embed 69 | embed(user_ns=context) 70 | else: 71 | import code 72 | code.interact(local=context) 73 | 74 | 75 | # ----------------------------------------------------------------------------- 76 | # Testing commands 77 | # ----------------------------------------------------------------------------- 78 | 79 | @cli.command(name='test',context_settings=dict(ignore_unknown_options=True)) 80 | @click.argument('nose_argsuments', nargs=-1, type=click.UNPROCESSED) 81 | def test(nose_argsuments): 82 | """ Run application tests """ 83 | from nose import run 84 | 85 | params = ['__main__', '-c', 'nose.ini'] 86 | params.extend(nose_argsuments) 87 | run(argv=params) 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /boiler/cli/colors.py: -------------------------------------------------------------------------------- 1 | from click import style 2 | 3 | 4 | def colour(colour, message, bold=False): 5 | """ Color a message """ 6 | return style(fg=colour, text=message, bold=bold) 7 | 8 | 9 | def yellow(message, bold=False): 10 | """ Color in yellow """ 11 | return colour('yellow', message, bold) 12 | 13 | 14 | def red(message, bold=False): 15 | """ Color in red """ 16 | return colour('red', message, bold) 17 | 18 | 19 | def green(message, bold=False): 20 | """ Color in green """ 21 | return colour('green', message, bold) 22 | 23 | 24 | def blue(message, bold=False): 25 | """ Color in blue """ 26 | return colour('blue', message, bold) 27 | 28 | 29 | def magenta(message, bold=False): 30 | """ Color in magenta """ 31 | return colour('magenta', message, bold) 32 | 33 | 34 | def cyan(message, bold=False): 35 | """ Color in cyan """ 36 | return colour('cyan', message, bold) 37 | 38 | 39 | def white(message, bold=False): 40 | """ Color in white """ 41 | return colour('white', message, bold) 42 | -------------------------------------------------------------------------------- /boiler/cli/db.py: -------------------------------------------------------------------------------- 1 | import click 2 | from alembic import command as alembic_command 3 | from alembic.util import CommandError 4 | from boiler.cli.colors import * 5 | 6 | from boiler.feature.orm import db 7 | from boiler import bootstrap 8 | 9 | 10 | def get_config(): 11 | """ 12 | Prepare and return alembic config 13 | These configurations used to live in alembic config initialiser, but that 14 | just tight coupling. Ideally we should move that to userspace and find a 15 | way to pass these into alembic commands. 16 | 17 | @todo: think about it 18 | """ 19 | from boiler.migrations.config import MigrationsConfig 20 | 21 | # used for errors 22 | map = dict( 23 | path='MIGRATIONS_PATH', 24 | db_url='SQLALCHEMY_DATABASE_URI', 25 | metadata='SQLAlchemy metadata' 26 | ) 27 | 28 | app = bootstrap.get_app() 29 | params = dict() 30 | params['path'] = app.config.get(map['path'], 'migrations') 31 | params['db_url'] = app.config.get(map['db_url']) 32 | params['metadata'] = db.metadata 33 | 34 | for param, value in params.items(): 35 | if not value: 36 | msg = 'Configuration error: [{}] is undefined' 37 | raise Exception(msg.format(map[param])) 38 | 39 | config = MigrationsConfig(**params) 40 | return config 41 | 42 | 43 | # ----------------------------------------------------------------------------- 44 | # Group setup 45 | # ----------------------------------------------------------------------------- 46 | 47 | 48 | @click.group(help=yellow('Database management commands')) 49 | def cli(): 50 | pass 51 | 52 | 53 | # ----------------------------------------------------------------------------- 54 | # Commands 55 | # ----------------------------------------------------------------------------- 56 | 57 | 58 | @cli.command(name='init') 59 | def init(): 60 | """ Initialize new migrations directory """ 61 | try: 62 | config = get_config() 63 | alembic_command.init(config, config.dir, 'project') 64 | except CommandError as e: 65 | click.echo(red(str(e))) 66 | 67 | 68 | @cli.command(name='revision') 69 | @click.option('--revision', type=str, default=None, help='Specify a hardcoded revision id instead of generating one') 70 | @click.option('--path', type=str, default=None, help='Specify a hardcoded revision id instead of generating one') 71 | @click.option('--branch-label', type=str, default=None, help='Specify a branch label to apply to the new revision') 72 | @click.option('--splice', type=bool, is_flag=True, default=False, help='Allow a non-head revision as the "head" to splice onto') 73 | @click.option('--head', type=str, default=None, help='Specify head revision or @head to base new revision on') 74 | @click.option('--sql', type=bool, is_flag=True, default=False, help='Do not execute SQL - dump to standard output instead') 75 | @click.option('--autogenerate', type=bool, is_flag=True, default=False, help='Populate revision with autoganerated diff') 76 | @click.option('--message', '-m', type=str, default=None, help='Migration title') 77 | def revision(revision, path, branch_label, splice, head, sql, autogenerate, message): 78 | """ Create new revision file """ 79 | alembic_command.revision( 80 | config=get_config(), 81 | rev_id=revision, 82 | version_path=path, 83 | branch_label=branch_label, 84 | splice=splice, 85 | head=head, 86 | sql=sql, 87 | autogenerate=autogenerate, 88 | message=message 89 | ) 90 | 91 | 92 | @cli.command('generate') 93 | @click.option('--revision', type=str, default=None, help='Specify a hardcoded revision id instead of generating one') 94 | @click.option('--path', type=str, default=None, help='Specify a hardcoded revision id instead of generating one') 95 | @click.option('--branch-label', type=str, default=None, help='Specify a branch label to apply to the new revision') 96 | @click.option('--splice', type=bool, is_flag=True, default=False, help='Allow a non-head revision as the "head" to splice onto') 97 | @click.option('--head', type=str, default=None, help='Specify head revision or @head to base new revision on') 98 | @click.option('--sql', type=bool, is_flag=True, default=False, help='Do not execute SQL - dump to standard output instead') 99 | @click.option('--message', '-m', type=str, default=None, help='Migration title') 100 | def generate(revision, path, branch_label, splice, head, sql, message): 101 | """ Autogenerate new revision file """ 102 | alembic_command.revision( 103 | config=get_config(), 104 | rev_id=revision, 105 | version_path=path, 106 | branch_label=branch_label, 107 | splice=splice, 108 | head=head, 109 | sql=sql, 110 | autogenerate=True, 111 | message=message 112 | ) 113 | 114 | 115 | @cli.command(name='merge') 116 | @click.option('--revision', type=str, default=None, help='Specify a hardcoded revision id instead of generating one') 117 | @click.option('--branch-label', type=str, default=None, help='Specify a branch label to apply to the new revision') 118 | @click.option('--message', '-m', type=str, default=None, help='Migration title') 119 | @click.option('--list-revisions', type=str, default=None, help='One or more revisions, or "heads" for all heads') 120 | def merge(revision, branch_label, message, list_revisions=''): 121 | """ Merge two revision together, create new revision file """ 122 | alembic_command.merge( 123 | config=get_config(), 124 | revisions=list_revisions, 125 | message=message, 126 | branch_label=branch_label, 127 | rev_id=revision 128 | ) 129 | 130 | 131 | @cli.command(name='up') 132 | @click.option('--tag', type=str, default=None, help='Arbitrary tag name (used by custom env.py)') 133 | @click.option('--sql', type=bool, is_flag=True, default=False, help='Do not execute SQL - dump to standard output instead') 134 | @click.option('--revision', type=str, default='head', help='Revision id') 135 | def up(tag, sql, revision): 136 | """ Upgrade to revision """ 137 | alembic_command.upgrade( 138 | config=get_config(), 139 | revision=revision, 140 | sql=sql, 141 | tag=tag 142 | ) 143 | 144 | 145 | @cli.command(name='down') 146 | @click.option('--tag', type=str, default=None, help='Arbitrary tag name (used by custom env.py)') 147 | @click.option('--sql', type=bool, is_flag=True, default=False, help='Do not execute SQL - dump to standard output instead') 148 | @click.option('--revision', type=str, default='-1', help='Revision id') 149 | def down(tag, sql, revision): 150 | """ Downgrade to revision """ 151 | alembic_command.downgrade( 152 | config=get_config(), 153 | revision=revision, 154 | sql=sql, 155 | tag=tag 156 | ) 157 | 158 | 159 | @cli.command(name='show') 160 | @click.option('--revision', type=str, default='head', help='Revision id') 161 | def show(revision): 162 | """ Show the revisions """ 163 | alembic_command.show( 164 | config=get_config(), 165 | rev=revision 166 | ) 167 | 168 | 169 | @cli.command(name='history') 170 | @click.option('--verbose', '-v', type=bool, is_flag=True, default=False, help='Use more verbose output') 171 | @click.option('--range', '-r', type=str, default=None, help='Specify a revision range; format is [start]:[end]') 172 | def history(verbose, range): 173 | """ List revision changesets chronologically """ 174 | alembic_command.history( 175 | config=get_config(), 176 | rev_range=range, 177 | verbose=verbose 178 | ) 179 | 180 | 181 | @cli.command(name='heads') 182 | @click.option('--resolve', '-r', type=bool, is_flag=True, default=False, help='Treat dependency versions as down revisions') 183 | @click.option('--verbose', '-v', type=bool, is_flag=True, default=False, help='Use more verbose output') 184 | def heads(resolve, verbose): 185 | """ Show available heads """ 186 | alembic_command.heads( 187 | config=get_config(), 188 | verbose=verbose, 189 | resolve_dependencies=resolve 190 | ) 191 | 192 | 193 | @cli.command(name='branches') 194 | @click.option('--verbose', '-v', type=bool, is_flag=True, default=False, help='Use more verbose output') 195 | def branches(verbose): 196 | """ Show current branch points """ 197 | alembic_command.branches( 198 | config=get_config(), 199 | verbose=verbose 200 | ) 201 | 202 | 203 | @cli.command(name='current') 204 | @click.option('--verbose', '-v', type=bool, is_flag=True, default=False, help='Use more verbose output') 205 | def current(verbose): 206 | """ Display current revision """ 207 | alembic_command.current( 208 | config=get_config(), 209 | verbose=verbose 210 | ) 211 | 212 | 213 | @cli.command() 214 | @click.option('--tag', type=str, default=None, help='Arbitrary tag name (used by custom env.py)') 215 | @click.option('--sql', type=bool, is_flag=True, default=False, help='Do not execute SQL - dump to standard output instead') 216 | @click.option('--revision', type=str, default='head', help='Revision id') 217 | def stamp(revision, sql, tag): 218 | """ Stamp db to given revision without migrating """ 219 | alembic_command.stamp( 220 | config=get_config(), 221 | revision=revision, 222 | sql=sql, 223 | tag=tag 224 | ) 225 | 226 | -------------------------------------------------------------------------------- /boiler/cli/readme.md: -------------------------------------------------------------------------------- 1 | ### Application CLI entrypoint. 2 | 3 | Here we import and assemble our cli tool from other cli modules provided by application components. Basically we either use or create a project cli and then have an option to mount or merge-in additional commands or command groups. 4 | 5 | For example: 6 | 7 | ```python 8 | 9 | # mount single command: 10 | module1.cli.add_command(module2.command) 11 | 12 | # mount another cli as sub command 13 | module1.cli.add_command(module2.cli, name='module2') 14 | 15 | #merge commands from modules into single cli 16 | merged_cli = click.CommandCollection(sources=[module1.cli, module2.cli]) 17 | ``` -------------------------------------------------------------------------------- /boiler/collections/__init__.py: -------------------------------------------------------------------------------- 1 | from .paginated_collection import PaginatedCollection 2 | from .api_collection import ApiCollection 3 | from .pagination import paginate 4 | -------------------------------------------------------------------------------- /boiler/collections/api_collection.py: -------------------------------------------------------------------------------- 1 | from json import dumps 2 | from boiler.collections import PaginatedCollection 3 | 4 | 5 | class ApiCollection(PaginatedCollection): 6 | """ 7 | API Collection 8 | Works the same way as a paginated collection, but also applies 9 | serializer to each item. Useful in API responses. 10 | """ 11 | def __init__(self, query, *_, serialize_function, **kwargs): 12 | self.serializer = serialize_function 13 | super().__init__(query, **kwargs) 14 | 15 | def __iter__(self): 16 | """ Performs generator-based iteration through page items """ 17 | offset = 0 18 | while offset < len(self.items): 19 | item = self.items[offset] 20 | offset += 1 21 | yield self.serializer(item) 22 | 23 | def dict(self): 24 | """ Returns current collection as a dictionary """ 25 | collection = super().dict() 26 | serialized_items = [] 27 | for item in collection['items']: 28 | serialized_items.append(self.serializer(item)) 29 | 30 | collection['items'] = serialized_items 31 | return collection 32 | 33 | def json(self): 34 | """ Returns a json representation of collection """ 35 | return dumps(self.dict()) -------------------------------------------------------------------------------- /boiler/collections/paginated_collection.py: -------------------------------------------------------------------------------- 1 | from math import ceil 2 | from boiler.collections.pagination import paginate 3 | from pprint import pprint as pp 4 | 5 | 6 | class PaginatedCollection: 7 | """ 8 | Paginated collection 9 | Accepts an SQLAlchemy query object on initialization along with some 10 | pagination settings and then allows you to iterate over itself in a 11 | paginated manner: iterate over items in current page then call next_page() 12 | to fetch next slice of data. 13 | """ 14 | def __init__(self, query, *_, page=1, per_page=10, pagination_range=5): 15 | """ 16 | Initialise collection 17 | Creates an instance of collection. Requires an query object to 18 | iterate through. Will issue 2 queries: one to count total items and 19 | second to fetch actual items. Optionally generates a page range 20 | to print range-like paginations of a given slice size. 21 | 22 | :param query: 23 | :param _: args, ignored 24 | :param page: int, page to fetch 25 | :param per_page: int, items per page 26 | :param pagination_range: int, number of pages in pagination 27 | """ 28 | self._query = query 29 | self.page = page 30 | self.per_page = per_page 31 | self.total_items = self._query.count() 32 | self.total_pages = ceil(self.total_items / per_page) 33 | 34 | # paginate 35 | self.pagination = paginate( 36 | page=page, 37 | total_pages=self.total_pages, 38 | total_items=self.total_items, 39 | slice_size=pagination_range 40 | )['pagination'] 41 | 42 | # fetch items 43 | self.items = self.fetch_items() 44 | 45 | def __repr__(self): 46 | """ Get printable representation of collection """ 47 | data = 'page="{}" per_page="{}" total_items="{}" total_pages="{}" ' 48 | data += 'items="[...]"' if len(list(self.items)) > 0 else 'items="[]"' 49 | class_name = self.__class__.__name__ 50 | printable = '<{} {}>'.format(class_name, data) 51 | return printable.format( 52 | self.page, 53 | self.per_page, 54 | self.total_items, 55 | self.total_pages 56 | ) 57 | 58 | def __iter__(self): 59 | """ Performs generator-based iteration through page items """ 60 | offset = 0 61 | while offset < len(self.items): 62 | item = self.items[offset] 63 | offset += 1 64 | yield item 65 | 66 | def fetch_items(self): 67 | """ 68 | Fetch items 69 | Performs a query to retrieve items based on current query and 70 | pagination settings. 71 | """ 72 | offset = self.per_page * (self.page - 1) 73 | items = self._query.limit(self.per_page).offset(offset).all() 74 | return items 75 | 76 | def dict(self): 77 | """ Returns current collection as a dictionary """ 78 | collection = dict( 79 | page=self.page, 80 | per_page=self.per_page, 81 | total_items=self.total_items, 82 | total_pages=self.total_pages, 83 | pagination=self.pagination, 84 | items=list(self.items) 85 | ) 86 | return collection 87 | 88 | def is_first_page(self): 89 | """ Check if we are on the first page """ 90 | return self.page == 1 91 | 92 | def is_last_page(self): 93 | """ Checks if we are on the last page """ 94 | return self.page == self.total_pages 95 | 96 | def next_page(self): 97 | """ 98 | Next page 99 | Uses query object to fetch next slice of items unless on last page in 100 | which case does nothing 101 | """ 102 | if self.is_last_page(): 103 | return False 104 | 105 | self.page += 1 106 | self.items = self.fetch_items() 107 | return True 108 | 109 | def previous_page(self): 110 | """ 111 | Previous page 112 | Uses query object to fetch previous slice of items unless on first 113 | page in which case does nothing 114 | """ 115 | if self.is_first_page(): 116 | return False 117 | 118 | self.page -= 1 119 | self.items = self.fetch_items() 120 | return True 121 | 122 | 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /boiler/collections/pagination.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | 4 | def paginate(page, total_items, total_pages, slice_size=5): 5 | """ 6 | Paginate 7 | Does some maths to generate ranged pagination. Returns a dictionary 8 | of page numbers to be used in url builders that allows to go to first 9 | page, previous page, next page, last page and one of the pages in 10 | range around current page with possibility to jump in slices. The 11 | result will look like this: 12 | 13 | { 14 | page: 2, 15 | total_pages: 100, 16 | total_items: 1000, 17 | pagination: { 18 | first: 1 19 | previous: 1, 20 | previous_slice: 1 21 | pages: [1, 2, 3, 4, 5, 6, 7 ... etc] 22 | next_slice: 14 23 | next: 3, 24 | last: 100 25 | } 26 | 27 | } 28 | :return: boiler.collections.paginated_collection.PaginatedCollection 29 | """ 30 | if slice_size > total_pages: 31 | slice_size = total_pages 32 | 33 | # paginate (can be out of bounds for now) 34 | first = 1 35 | previous = page - 1 36 | next = page + 1 37 | last = total_pages 38 | previous_slice = page - slice_size 39 | next_slice = page + slice_size 40 | 41 | # assemble 42 | links = dict( 43 | first=None, 44 | previous=None, 45 | next=None, 46 | last=None 47 | ) 48 | 49 | # previous/next 50 | if total_pages > 1: 51 | if page == 1: 52 | links['next'] = next 53 | links['last'] = last 54 | elif page == total_pages: 55 | links['first'] = first 56 | links['previous'] = previous 57 | else: 58 | links['first'] = first 59 | links['previous'] = previous 60 | links['next'] = next 61 | links['last'] = last 62 | 63 | # previous_slice 64 | links['previous_slice'] = previous_slice 65 | if page - slice_size <= 0: 66 | links['previous_slice'] = None 67 | if page != 1: 68 | links['previous_slice'] = first 69 | 70 | # next slice 71 | links['next_slice'] = next_slice 72 | if page + slice_size > total_pages: 73 | links['next_slice'] = None 74 | if page != total_pages and total_pages != 0: 75 | links['next_slice'] = last 76 | 77 | # slice pages 78 | delta = math.ceil(slice_size / 2) 79 | if page - delta > total_pages - slice_size: 80 | left_bound = total_pages - slice_size + 1 81 | right_bound = total_pages 82 | else: 83 | if page - delta < 0: 84 | delta = page 85 | 86 | offset = page - delta 87 | left_bound = offset + 1 88 | right_bound = offset + slice_size 89 | 90 | # append page range 91 | links['pages'] = list(range(left_bound, right_bound + 1)) 92 | 93 | # discard slice navigation if no next/prev slice 94 | if links['pages']: 95 | if links['previous_slice'] == links['pages'][0]: 96 | links['previous_slice'] = None 97 | if links['next_slice'] == links['pages'][-1]: 98 | links['next_slice'] = None 99 | 100 | # and return 101 | pagination = dict( 102 | page=page, 103 | total_pages=total_pages, 104 | total_items=total_items, 105 | pagination=links 106 | ) 107 | 108 | return pagination -------------------------------------------------------------------------------- /boiler/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Config: 5 | """ 6 | Base Config 7 | The purpose of this is to provide convenient property 8 | getter, just like a dictionary 9 | """ 10 | def get(self, what, default=None): 11 | if hasattr(self, what): 12 | if not what.startswith('__'): 13 | what = getattr(self, what) 14 | if not callable(what): 15 | return what 16 | 17 | return default 18 | 19 | 20 | class DefaultConfig(Config): 21 | """ 22 | Default project configuration 23 | Sets up defaults used and/or overridden in environments and deployments 24 | """ 25 | ENV = 'production' 26 | 27 | SERVER_NAME = None 28 | 29 | # secret key 30 | SECRET_KEY = os.getenv('APP_SECRET_KEY') 31 | 32 | TIME_RESTARTS = False 33 | TESTING = False 34 | DEBUG = False 35 | DEBUG_TB_ENABLED = False 36 | DEBUG_TB_PROFILER_ENABLED = False 37 | DEBUG_TB_INTERCEPT_REDIRECTS = False 38 | 39 | # where built-in server and url_for look for static files (None for default) 40 | FLASK_STATIC_URL = None 41 | FLASK_STATIC_PATH = None 42 | 43 | # asset helper settings (server must be capable of serving these files) 44 | ASSETS_VERSION = None 45 | ASSETS_PATH = None # None falls back to url_for('static') 46 | 47 | # do not expose our urls on 404s 48 | ERROR_404_HELP = False 49 | 50 | # uploads 51 | MAX_CONTENT_LENGTH = 1024 * 1024 * 16 # megabytes 52 | 53 | # database 54 | # 'mysql://user:password@server/db?charset=utf8mb4' 55 | # 'mysql+pymysql://user:password@server/db?charset=utf8mb4' 56 | # 'mysql+mysqlconnector://user:password@host:3306/database?charset=utf8mb4' 57 | SQLALCHEMY_ECHO = False 58 | SQLALCHEMY_TRACK_MODIFICATIONS = False 59 | MIGRATIONS_PATH = os.path.join(os.getcwd(), 'migrations') 60 | SQLALCHEMY_DATABASE_URI = os.getenv('APP_DATABASE_URI') 61 | TEST_DB_PATH = os.path.join( 62 | os.getcwd(), 'var', 'data', 'test-db', 'sqlite.db' 63 | ) 64 | 65 | # mail server settings 66 | MAIL_DEBUG = False 67 | MAIL_SERVER = 'smtp.gmail.com' 68 | MAIL_PORT = 587 69 | MAIL_USE_TLS = True 70 | MAIL_USE_SSL = False 71 | MAIL_USERNAME = None 72 | MAIL_PASSWORD = None 73 | MAIL_DEFAULT_SENDER = ('Webapp Mailer', 'mygmail@gmail.com') 74 | 75 | # logging 76 | ADMINS = ['you@domain'] 77 | LOGGING_EMAIL_EXCEPTIONS_TO_ADMINS = False 78 | 79 | # localization (babel) 80 | DEFAULT_LOCALE = 'en_GB' 81 | DEFAULT_TIMEZONE = 'UTC' 82 | 83 | # csrf protection 84 | WTF_CSRF_ENABLED = True 85 | 86 | # recaptcha 87 | RECAPTCHA_PUBLIC_KEY = os.getenv('APP_RECAPTCHA_PUBLIC_KEY') 88 | RECAPTCHA_PRIVATE_KEY = os.getenv('APP_RECAPTCHA_PRIVATE_KEY') 89 | 90 | 91 | class ProductionConfig(Config): 92 | """ 93 | Production config 94 | Extend this config from your concrete app config. It should set only 95 | the stuff you want to override from default config below. 96 | """ 97 | 98 | # make cookies secure 99 | SESSION_COOKIE_SECURE = True 100 | SESSION_COOKIE_HTTPONLY = True 101 | REMEMBER_COOKIE_SECURE = True 102 | REMEMBER_COOKIE_HTTPONLY = True 103 | 104 | 105 | class DevConfig(Config): 106 | """ Default development config """ 107 | ENV = 'development' 108 | TIME_RESTARTS = False 109 | DEBUG = True 110 | DEBUG_TB_ENABLED=True 111 | DEBUG_TB_PROFILER_ENABLED = True 112 | DEBUG_TB_INTERCEPT_REDIRECTS = False 113 | 114 | 115 | class TestingConfig(Config): 116 | """ Default testing config """ 117 | ENV = 'testing' 118 | TESTING = True 119 | MAIL_DEBUG = True 120 | 121 | # use sqlite in testing 122 | test_db = 'sqlite:///{}'.format(DefaultConfig.TEST_DB_PATH) 123 | SQLALCHEMY_DATABASE_URI = test_db 124 | 125 | # hash quickly in testing 126 | WTF_CSRF_ENABLED = False 127 | PASSLIB_ALGO = 'md5_crypt' 128 | 129 | 130 | -------------------------------------------------------------------------------- /boiler/errors.py: -------------------------------------------------------------------------------- 1 | from werkzeug import exceptions 2 | from flask import current_app, render_template, request, jsonify 3 | from flask import has_app_context, has_request_context 4 | 5 | 6 | def register_error_handler(app, handler=None): 7 | """ 8 | Register error handler 9 | Registers an exception handler on the app instance for every type of 10 | exception code werkzeug is aware about. 11 | 12 | :param app: flask.Flask - flask application instance 13 | :param handler: function - the handler 14 | :return: None 15 | """ 16 | if not handler: 17 | handler = default_error_handler 18 | 19 | for code in exceptions.default_exceptions.keys(): 20 | app.register_error_handler(code, handler) 21 | 22 | 23 | def default_error_handler(exception): 24 | """ 25 | Default error handler 26 | Will display an error page with the corresponding error code from template 27 | directory, for example, a not found will load a 404.html etc. 28 | Will first look in userland app templates and if not found, fallback to 29 | boiler templates to display a default page. 30 | 31 | :param exception: Exception 32 | :return: string 33 | """ 34 | http_exception = isinstance(exception, exceptions.HTTPException) 35 | code = exception.code if http_exception else 500 36 | 37 | # log exceptions only (app debug should be off) 38 | if code == 500: 39 | current_app.logger.error(exception) 40 | 41 | # jsonify error if json requested via accept header 42 | if has_app_context() and has_request_context(): 43 | headers = request.headers 44 | if 'Accept' in headers and headers['Accept'] == 'application/json': 45 | return json_error_handler(exception) 46 | 47 | # otherwise render template 48 | return template_error_handler(exception) 49 | 50 | 51 | def json_error_handler(exception): 52 | """ 53 | Json error handler 54 | Returns a json message for the exception with appropriate response code and 55 | application/json content type. 56 | :param exception: 57 | :return: 58 | """ 59 | http_exception = isinstance(exception, exceptions.HTTPException) 60 | code = exception.code if http_exception else 500 61 | 62 | # log exceptions only (app debug should be off) 63 | if code == 500: 64 | current_app.logger.error(exception) 65 | 66 | response = jsonify(dict(message=str(exception))) 67 | response.status_code = code 68 | return response 69 | 70 | 71 | def template_error_handler(exception): 72 | """ 73 | Template error handler 74 | Renders a template for the error code if exception is know by werkzeug. 75 | Will attempt to load a template from userland app templates and then 76 | fall back to boiler templates if not found. 77 | :param exception: Exception 78 | :return: 79 | """ 80 | http_exception = isinstance(exception, exceptions.HTTPException) 81 | code = exception.code if http_exception else 500 82 | 83 | # log exceptions only (app debug should be off) 84 | if code == 500: 85 | current_app.logger.error(exception) 86 | 87 | template = 'errors/{}.j2'.format(code) 88 | return render_template(template, error=exception), code 89 | 90 | 91 | def json_url_error_handler(urls=()): 92 | """ 93 | Closure: Json URL error handler 94 | Checks if request matches provided url prefix and returns json response if 95 | it does. Otherwise falls back to template error handler. This is usefull 96 | if you want to return json errors for a subset of your routes. 97 | 98 | Register this by calling a closure: 99 | register_error_handler(app, json_url_handler([ 100 | '/url/path/one/', 101 | '/some/other/relative/url/', 102 | ])) 103 | 104 | :param urls: iterable or string, list of url prefixes 105 | :return: 106 | """ 107 | if type(urls) is str: 108 | urls = [urls] 109 | 110 | # the handler itself 111 | def handler(exception): 112 | http_exception = isinstance(exception, exceptions.HTTPException) 113 | code = exception.code if http_exception else 500 114 | 115 | # log exceptions only (app debug should be off) 116 | if code == 500: 117 | current_app.logger.error(exception) 118 | 119 | # if matches one of urls, return json 120 | if has_app_context() and has_request_context(): 121 | for prefix in urls: 122 | prefix = prefix.strip('/') 123 | prefix = '/' + prefix + '/' if len(prefix) else '/' 124 | if request.path.startswith(prefix): 125 | return json_error_handler(exception) 126 | 127 | # otherwise fall back to error page handler 128 | return template_error_handler(exception) 129 | 130 | return handler 131 | 132 | -------------------------------------------------------------------------------- /boiler/events.py: -------------------------------------------------------------------------------- 1 | from blinker import Namespace as BlinkerNamespace 2 | from contextlib import contextmanager 3 | 4 | 5 | class Namespace(BlinkerNamespace): 6 | """ 7 | Namespace 8 | An extension to blinker namespace that provides a context manager for 9 | testing, which allows to temporarily disconnect all receivers. 10 | """ 11 | @contextmanager 12 | def disconnect_receivers(self): 13 | receivers = {} 14 | try: 15 | for name in self: 16 | event = self[name] 17 | receivers[name] = event.receivers 18 | event.receivers = {} 19 | yield {} 20 | 21 | finally: 22 | for name in self: 23 | event = self[name] 24 | event.receivers = receivers[name] 25 | 26 | 27 | -------------------------------------------------------------------------------- /boiler/exceptions.py: -------------------------------------------------------------------------------- 1 | class BoilerException(Exception): 2 | """ Generic boiler exception marker """ 3 | pass 4 | 5 | 6 | class BootstrapException(BoilerException, RuntimeError): 7 | """ Raised on errors during app startup """ 8 | pass 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /boiler/feature/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/feature/__init__.py -------------------------------------------------------------------------------- /boiler/feature/localization.py: -------------------------------------------------------------------------------- 1 | from flask_babel import Babel 2 | 3 | 4 | def localization_feature(app): 5 | """ 6 | Localization feature 7 | This will initialize support for translations and localization of values 8 | such as numbers, money, dates and formatting timezones. 9 | """ 10 | 11 | # apply app default to babel 12 | app.config['BABEL_DEFAULT_LOCALE'] = app.config['DEFAULT_LOCALE'] 13 | app.config['BABEL_DEFAULT_TIMEZONE'] = app.config['DEFAULT_TIMEZONE'] 14 | 15 | # init babel 16 | babel = Babel() 17 | babel.init_app(app) -------------------------------------------------------------------------------- /boiler/feature/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from boiler.log.file import file_logger 3 | from boiler.log.mail import mail_logger 4 | 5 | 6 | def logging_feature(app): 7 | """ 8 | Add logging 9 | Accepts flask application and registers logging functionality within it 10 | """ 11 | 12 | # this is important because otherwise only log warn, err and crit 13 | app.logger.setLevel(logging.INFO) 14 | 15 | # enable loggers 16 | email_exceptions = app.config.get('LOGGING_EMAIL_EXCEPTIONS_TO_ADMINS') 17 | if email_exceptions and not app.debug and not app.testing: 18 | # config.debug=False 19 | mail_handler = mail_logger(app) 20 | app.logger.addHandler(mail_handler) 21 | 22 | if not app.testing: 23 | file_handler = file_logger(app) 24 | app.logger.addHandler(file_handler) 25 | 26 | 27 | # test logging 28 | # app.logger.info("testing info.") 29 | # app.logger.warn("testing warn.") 30 | # app.logger.error("testing error.") 31 | # app.logger.emerg("testing error.") -------------------------------------------------------------------------------- /boiler/feature/mail.py: -------------------------------------------------------------------------------- 1 | from flask_mail import Mail 2 | 3 | # init mail 4 | mail = Mail() 5 | 6 | def mail_feature(app): 7 | """ 8 | Mail feature 9 | This will enable mailer feature for the given application. It sets up 10 | integration with FlaskMail and relies on mailer credentials config to 11 | be present. Many other features may rely on this one to send emails. 12 | """ 13 | mail.init_app(app) -------------------------------------------------------------------------------- /boiler/feature/orm.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | db = SQLAlchemy(session_options=dict(autoflush=False, autocommit=False)) 4 | 5 | 6 | def orm_feature(app): 7 | """ 8 | Enables SQLAlchemy integration feature for database connectivity 9 | Please note, that at the moment there can only be one central database that 10 | is used by boiler models, db cli and migrations. 11 | 12 | Technically you can use another sqlalchemy instance in your app's userland 13 | code, but you will have to manually bootstrap it and manage testing, 14 | migrations and models yourself. 15 | 16 | :param app: 17 | :param app_db: application-specific sqlalchemy instance 18 | :return: None 19 | """ 20 | db.init_app(app) 21 | -------------------------------------------------------------------------------- /boiler/feature/routing.py: -------------------------------------------------------------------------------- 1 | from werkzeug.utils import import_string 2 | from boiler.routes.regex import RegexConverter 3 | 4 | 5 | def routing_feature(app): 6 | """ 7 | Add routing feature 8 | Allows to define application routes un urls.py file and use lazy views. 9 | Additionally enables regular exceptions in route definitions 10 | """ 11 | # enable regex routes 12 | app.url_map.converters['regex'] = RegexConverter 13 | 14 | urls = app.name.rsplit('.', 1)[0] + '.urls.urls' 15 | 16 | # important issue ahead 17 | # see: https://github.com/projectshift/shift-boiler/issues/11 18 | try: 19 | urls = import_string(urls) 20 | except ImportError as e: 21 | err = 'Failed to import {}. If it exists, check that it does not ' 22 | err += 'import something non-existent itself! ' 23 | err += 'Try to manually import it to debug.' 24 | raise ImportError(err.format(urls)) 25 | 26 | # add routes now 27 | for route in urls.keys(): 28 | route_options = urls[route] 29 | route_options['rule'] = route 30 | app.add_url_rule(**route_options) 31 | 32 | 33 | -------------------------------------------------------------------------------- /boiler/jinja/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/jinja/__init__.py -------------------------------------------------------------------------------- /boiler/jinja/functions.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | from flask import has_request_context 3 | from flask import g 4 | 5 | 6 | def asset(url=None): 7 | """ 8 | Asset helper 9 | Generates path to a static asset based on configuration base path and 10 | support for versioning. Will easily allow you to move your assets away to 11 | a CDN without changing templates. Versioning allows you to cache your asset 12 | changes forever by the webserver. 13 | 14 | :param url: string - relative path to asset 15 | :return: string - full versioned url 16 | """ 17 | 18 | # fallback to url_for('static') if assets path not configured 19 | url = url.lstrip('/') 20 | assets_path = app.config.get('ASSETS_PATH') 21 | if not assets_path: 22 | url_for = app.jinja_env.globals.get('url_for') 23 | url = url_for('static', filename=url) 24 | else: 25 | assets_path = assets_path.rstrip('/') 26 | url = assets_path + '/' + url 27 | 28 | version = app.config.get('ASSETS_VERSION') 29 | if not version: 30 | return url 31 | 32 | sign = '?' 33 | if sign in url: 34 | sign = '&' 35 | 36 | pattern = '{url}{sign}v{version}' 37 | return pattern.format(url=url, sign=sign, version=version) 38 | 39 | 40 | def dev_proxy(): 41 | """ 42 | Is dev proxy? 43 | A boolean method to check if we are in development proxy mode. Dev proxy 44 | mode is detected by the presence of request header that you dev proxy 45 | server should append to request. 46 | :return: 47 | """ 48 | if not has_request_context(): 49 | return False 50 | 51 | return g.dev_proxy 52 | -------------------------------------------------------------------------------- /boiler/log/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/log/__init__.py -------------------------------------------------------------------------------- /boiler/log/datadog.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/log/datadog.py -------------------------------------------------------------------------------- /boiler/log/file.py: -------------------------------------------------------------------------------- 1 | import os, logging 2 | from logging.handlers import RotatingFileHandler 3 | 4 | 5 | def file_logger(app, level=None): 6 | """ 7 | Get file logger 8 | Returns configured fire logger ready to be attached to app 9 | 10 | :param app: application instance 11 | :param level: log this level 12 | :return: RotatingFileHandler 13 | """ 14 | path = os.path.join(os.getcwd(), 'var', 'logs', 'app.log') 15 | 16 | max_bytes = 1024 * 1024 * 2 17 | file_handler = RotatingFileHandler( 18 | filename=path, 19 | mode='a', 20 | maxBytes=max_bytes, 21 | backupCount=10 22 | ) 23 | 24 | if level is None: level = logging.INFO 25 | file_handler.setLevel(level) 26 | 27 | log_format = '%(asctime)s %(levelname)s: %(message)s' 28 | log_format += ' [in %(pathname)s:%(lineno)d]' 29 | file_handler.setFormatter(logging.Formatter(log_format)) 30 | 31 | return file_handler -------------------------------------------------------------------------------- /boiler/log/mail.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.handlers import SMTPHandler 3 | 4 | 5 | def mail_logger(app, level = None): 6 | """ 7 | Get mail logger 8 | Returns configured instance of mail logger ready to be attached to app. 9 | 10 | Important: app.config['DEBUG'] must be False! 11 | 12 | :param app: application instance 13 | :param level: mail errors of this level 14 | :return: SMTPHandler 15 | """ 16 | credentials = None 17 | if app.config['MAIL_USERNAME'] and app.config['MAIL_PASSWORD']: 18 | credentials = (app.config['MAIL_USERNAME'], app.config['MAIL_PASSWORD']) 19 | 20 | secure = None 21 | if app.config['MAIL_USE_TLS']: 22 | secure = tuple() 23 | 24 | # @todo: move to configuration 25 | config = dict( 26 | mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']), 27 | fromaddr=app.config['MAIL_DEFAULT_SENDER'], 28 | toaddrs=app.config['ADMINS'], 29 | credentials = credentials, 30 | subject='Application exception', 31 | secure = secure, 32 | timeout=1.0 33 | ) 34 | 35 | mail_handler = SMTPHandler(**config) 36 | 37 | if level is None: level = logging.ERROR 38 | mail_handler.setLevel(level) 39 | 40 | mail_log_format = ''' 41 | Message type: %(levelname)s 42 | Location: %(pathname)s:%(lineno)d 43 | Module: %(module)s 44 | Function: %(funcName)s 45 | Time: %(asctime)s 46 | 47 | Message: 48 | 49 | %(message)s 50 | ''' 51 | 52 | mail_handler.setFormatter(logging.Formatter(mail_log_format)) 53 | return mail_handler -------------------------------------------------------------------------------- /boiler/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/migrations/__init__.py -------------------------------------------------------------------------------- /boiler/migrations/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from alembic.config import Config as AlembicConfig 3 | 4 | 5 | class MigrationsConfig(AlembicConfig): 6 | def __init__(self, path, db_url, metadata, *args, **kwargs): 7 | self.dir=path 8 | self.url=db_url 9 | self.meta=metadata 10 | self.config = os.path.join(self.dir, 'alembic.ini') 11 | 12 | # bootstrap with ini if exists 13 | initialized = os.path.isfile(self.config) 14 | if initialized: 15 | args = list(args) 16 | args.insert(0, self.config) 17 | 18 | super().__init__(*args, **kwargs) 19 | self.set_main_option('sqlalchemy.url', db_url) 20 | self.set_main_option('script_location', path) 21 | self.config_file_name = self.config 22 | 23 | def get_template_directory(self): 24 | """ 25 | Get path to migrations templates 26 | This will get used when you run the db init command 27 | """ 28 | dir = os.path.join(os.path.dirname(__file__), 'templates') 29 | return dir 30 | 31 | -------------------------------------------------------------------------------- /boiler/migrations/templates/__init__.py: -------------------------------------------------------------------------------- 1 | from boiler.collections.paginated_collection import PaginatedCollection 2 | from boiler.collections.api_collection import ApiCollection -------------------------------------------------------------------------------- /boiler/migrations/templates/project/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /boiler/migrations/templates/project/alembic.ini.mako: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # the output encoding used when revision files 5 | # are written from script.py.mako 6 | output_encoding = utf-8 7 | 8 | # template used to generate migration files 9 | # file_template = %%(rev)s_%%(slug)s 10 | file_template = %%(year)d%%(month).2d%%(day).2d%%(hour).2d%%(minute).2d%%(second).2d-%%(rev)s 11 | 12 | # set to 'true' to run the environment during 13 | # the 'revision' command, regardless of autogenerate 14 | # revision_environment = false 15 | 16 | 17 | # Logging configuration 18 | [loggers] 19 | keys = root,sqlalchemy,alembic 20 | 21 | [handlers] 22 | keys = console 23 | 24 | [formatters] 25 | keys = generic 26 | 27 | [logger_root] 28 | level = WARN 29 | handlers = console 30 | qualname = 31 | 32 | [logger_sqlalchemy] 33 | level = WARN 34 | handlers = 35 | qualname = sqlalchemy.engine 36 | 37 | [logger_alembic] 38 | level = INFO 39 | handlers = 40 | qualname = alembic 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /boiler/migrations/templates/project/env.py: -------------------------------------------------------------------------------- 1 | from alembic import context 2 | from sqlalchemy import engine_from_config, pool 3 | from logging.config import fileConfig 4 | 5 | 6 | # ------------------------------------------------------------------------ 7 | 8 | # Load all your models in here for autogenerate support 9 | # from project.app1 import models 10 | # from project.app2 import models 11 | # etc... 12 | 13 | # ------------------------------------------------------------------------ 14 | 15 | 16 | # this is the Alembic Config object, which provides 17 | # access to the values within the .ini file in use. 18 | config = context.config 19 | 20 | # Interpret the config file for Python logging. 21 | # This line sets up loggers basically. 22 | fileConfig(config.config_file_name) 23 | 24 | # load all project models for autogenerate support 25 | target_metadata = config.meta 26 | 27 | 28 | def run_migrations_offline(): 29 | """Run migrations in 'offline' mode. 30 | 31 | This configures the context with just a URL 32 | and not an Engine, though an Engine is acceptable 33 | here as well. By skipping the Engine creation 34 | we don't even need a DBAPI to be available. 35 | 36 | Calls to context.execute() here emit the given string to the 37 | script output. 38 | 39 | """ 40 | url = config.get_main_option("sqlalchemy.url") 41 | context.configure(url=url) 42 | 43 | with context.begin_transaction(): 44 | context.run_migrations() 45 | 46 | 47 | def run_migrations_online(): 48 | """Run migrations in 'online' mode. 49 | 50 | In this scenario we need to create an Engine 51 | and associate a connection with the context. 52 | 53 | """ 54 | engine = engine_from_config( 55 | config.get_section(config.config_ini_section), 56 | prefix='sqlalchemy.', 57 | poolclass=pool.NullPool 58 | ) 59 | 60 | connection = engine.connect() 61 | context.configure( 62 | connection=connection, 63 | target_metadata=target_metadata 64 | ) 65 | 66 | try: 67 | with context.begin_transaction(): 68 | context.run_migrations() 69 | finally: 70 | connection.close() 71 | 72 | if context.is_offline_mode(): 73 | run_migrations_offline() 74 | else: 75 | run_migrations_online() 76 | 77 | -------------------------------------------------------------------------------- /boiler/migrations/templates/project/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /boiler/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectshift/shift-boiler/6bb38342b73077d179cead03cb43451a5c1f51b8/boiler/routes/__init__.py -------------------------------------------------------------------------------- /boiler/routes/lazy_views.py: -------------------------------------------------------------------------------- 1 | from werkzeug.utils import import_string, cached_property 2 | 3 | 4 | class LazyView: 5 | """ 6 | Lazy view 7 | Callable class that provides loading views on-demand as soon as they 8 | are hit. This reduces startup times and improves general performance. 9 | 10 | See flask docs for more: 11 | http://flask.pocoo.org/docs/0.10/patterns/lazyloading/ 12 | """ 13 | 14 | def __init__(self, import_name): 15 | self.import_name = import_name 16 | self.__module__,self.__name__ = import_name.rsplit('.', 1) 17 | 18 | def __call__(self, *args, **kwargs): 19 | """ Import and create instance of view """ 20 | 21 | # important issue ahead 22 | # @see: https://github.com/projectshift/shift-boiler/issues/11 23 | try: 24 | result = self.view(*args, **kwargs) 25 | return result 26 | except ImportError: 27 | err = 'Failed to import {}. If it exists, check that it does not ' 28 | err += 'import something non-existent itself! ' 29 | err += 'Try to manually import it to debug.' 30 | raise ImportError(err.format(self.import_name)) 31 | 32 | @cached_property 33 | def view(self): 34 | result = import_string(self.import_name) 35 | 36 | # do we have restfulness? 37 | try: 38 | from flask_restful import Resource 39 | from boiler.feature.api import api 40 | restful = True 41 | except ImportError: 42 | restful = False 43 | 44 | # is classy? 45 | if isinstance(result, type): 46 | 47 | # and also restful? 48 | is_restful = restful and Resource in result.__bases__ 49 | 50 | if is_restful: 51 | result = api.output(result) 52 | else: 53 | result = result.as_view(self.import_name) 54 | 55 | return result 56 | 57 | -------------------------------------------------------------------------------- /boiler/routes/regex.py: -------------------------------------------------------------------------------- 1 | from werkzeug.routing import BaseConverter 2 | 3 | 4 | class RegexConverter(BaseConverter): 5 | """ 6 | Regex converter 7 | Allows to use regular expressions in flask urls definitions. 8 | An example of route definition: '/-/ 9 | Will produce: user and slug variables. 10 | """ 11 | def __init__(self, url_map, *items): 12 | super(RegexConverter, self).__init__(url_map) 13 | self.regex = items[0] 14 | -------------------------------------------------------------------------------- /boiler/routes/route.py: -------------------------------------------------------------------------------- 1 | from boiler.routes.lazy_views import LazyView 2 | 3 | 4 | def route(view, endpoint=None, methods=None, defaults=None, **options): 5 | """ 6 | Route: a shorthand for route declaration 7 | Import and use it in your app.urls file by calling: 8 | url['/path/to/view'] = route('module.views.view', 'route_name') 9 | """ 10 | if not endpoint: 11 | endpoint = view 12 | if not methods: 13 | methods = ['GET'] 14 | return dict( 15 | view_func=LazyView(view), 16 | endpoint=endpoint, 17 | methods=methods, 18 | defaults=defaults, 19 | **options 20 | ) 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /boiler/templates/errors/400.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 400 Bad Request 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

400 Bad Request

12 | 13 | {% if not error.description %} 14 |

The browser (or proxy) sent a request that this server could not understand.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/401.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 401 Unauthorized 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

401 Unauthorized

12 | 13 | {% if not error.description %} 14 |

The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials (e.g. a bad password), or your client doesn't understand how to supply the credentials required.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/403.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 403 Forbidden 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

403 Forbidden

12 | 13 | {% if not error.description %} 14 |

You don't have the permission to access the requested resource. It is either read-protected or not readable by the server.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/404.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 404 Not Found 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

404 Not Found

12 | 13 | {% if not error.description %} 14 |

The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/405.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 405 Method Not Allowed 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

405 Method Not Allowed

12 | 13 | {% if not error.description %} 14 |

The method is not allowed for the requested URL.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/406.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 406 Not Acceptable 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

406 Not Acceptable

12 | 13 | {% if not error.description %} 14 |

The resource identified by the request is only capable of generating response entities which have content characteristics not acceptable according to the accept headers sent in the request.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/408.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 408 Request Timeout 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

408 Request Timeout

12 | 13 | {% if not error.description %} 14 |

The server closed the network connection because the browser didn't finish the request within the specified time.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/409.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 409 Conflict 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

409 Conflict

12 | 13 | {% if not error.description %} 14 |

A conflict happened while processing the request. The resource might have been modified while the request was being processed.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/410.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 410 Gone 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

410 Gone

12 | 13 | {% if not error.description %} 14 |

The requested URL is no longer available on this server and there is no forwarding address. If you followed a link from a foreign page, please contact the author of this page.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/411.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 411 Length Required 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

411 Length Required

12 | 13 | {% if not error.description %} 14 |

A request with this method requires a valid Content-Length header.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/412.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 412 Precondition Failed 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

412 Precondition Failed

12 | 13 | {% if not error.description %} 14 |

The precondition on the request for the URL failed positive evaluation.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/413.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 413 Request Entity Too Large 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

413 Request Entity Too Large

12 | 13 | {% if not error.description %} 14 |

The data value transmitted exceeds the capacity limit.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/414.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 414 Request URI Too Long 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

414 Request URI Too Long

12 | 13 | {% if not error.description %} 14 |

The length of the requested URL exceeds the capacity limit for this server. The request cannot be processed.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/415.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 415 Unsupported Media Type 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

415 Unsupported Media Type

12 | 13 | {% if not error.description %} 14 |

The server does not support the media type transmitted in the request.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/416.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 416 Requested Range Not Satisfiable 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

416 Requested Range Not Satisfiable

12 | 13 | {% if not error.description %} 14 |

The server cannot provide the requested range.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/417.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 417 Expectation Failed 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

417 Expectation Failed

12 | 13 | {% if not error.description %} 14 |

The server could not meet the requirements of the Expect header

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/418.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 418 I'm a teapot 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

418 I'm a teapot

12 | 13 | {% if not error.description %} 14 |

This server is a teapot, not a coffee machine

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/422.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 422 Unprocessable Entity 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

422 Unprocessable Entity

12 | 13 | {% if not error.description %} 14 |

The request was well-formed but was unable to be followed due to semantic errors.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/423.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 423 Locked 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

423 Locked

12 | 13 | {% if not error.description %} 14 |

The resource that is being accessed is locked.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/428.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 428 Precondition Required 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

428 Precondition Required

12 | 13 | {% if not error.description %} 14 |

This request is required to be conditional; try using "If-Match" or "If-Unmodified-Since".

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/429.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 429 Too Many Requests 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

429 Too Many Requests

12 | 13 | {% if not error.description %} 14 |

This user has exceeded an allotted request count. Try again later.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/431.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 431 Request Header Fields Too Large 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

431 Request Header Fields Too Large

12 | 13 | {% if not error.description %} 14 |

One or more header fields exceeds the maximum size.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/451.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 451 Unavailable For Legal Reasons 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

451 Unavailable For Legal Reasons

12 | 13 | {% if not error.description %} 14 |

Unavailable for legal reasons.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/500.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 500 Internal Server Error 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

500 Internal Server Error

12 | 13 | {% if not error.description %} 14 |

The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/501.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 501 Not Implemented 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

501 Not Implemented

12 | 13 | {% if not error.description %} 14 |

The server does not support the action requested by the browser.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/502.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 502 Bad Gateway 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

502 Bad Gateway

12 | 13 | {% if not error.description %} 14 |

The proxy server received an invalid response from an upstream server.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/503.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 503 Service Unavailable 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

503 Service Unavailable

12 | 13 | {% if not error.description %} 14 |

The server is temporarily unable to service your request due to maintenance downtime or capacity problems. Please try again later.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/504.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 504 Gateway Timeout 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

504 Gateway Timeout

12 | 13 | {% if not error.description %} 14 |

The connection to an upstream server timed out.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/505.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 505 HTTP Version Not Supported 6 | {% include 'errors/styles.j2' %} 7 | 8 | 9 | 10 | 11 |

505 HTTP Version Not Supported

12 | 13 | {% if not error.description %} 14 |

The server does not support the HTTP protocol version used in the request.

15 | {% else %} 16 |

{{error.description}}

17 | {% endif %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /boiler/templates/errors/styles.j2: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /boiler/templates/kernel_layout.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | {# styles #} 8 | 9 | 10 | 11 | 12 | {# scripts #} 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% block content %}{% endblock %} 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /boiler/templates/partials/flash-messages.j2: -------------------------------------------------------------------------------- 1 | {% with messages = get_flashed_messages(with_categories=true) %} 2 | {% if messages %} 3 | {% for level, message in messages %} 4 | 5 | {% if level and level != 'message' %} 6 |