├── .flake8 ├── .gitignore ├── .sample_flaskenv ├── LICENSE ├── README.md ├── app ├── __init__.py ├── database.py ├── models.py ├── permissions.py ├── routes.py ├── services │ ├── __init__.py │ └── account_management_services.py ├── static │ ├── js │ │ ├── login.js │ │ ├── register.js │ │ └── settings.js │ └── styles │ │ └── sakura.css ├── templates │ ├── 404.html │ ├── 500.html │ ├── admin.html │ ├── base.html │ ├── index.html │ ├── login.html │ ├── register.html │ └── settings.html ├── utils │ ├── __init__.py │ ├── custom_errors.py │ ├── error_utils.py │ ├── sanitization.py │ └── validators.py └── views │ ├── __init__.py │ ├── account_management_views.py │ ├── error_views.py │ └── static_views.py ├── config.py ├── flask_for_startups.py ├── migrations ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── 1_init.py ├── poetry.lock ├── pyproject.toml ├── requirements.txt ├── scripts ├── db_revision_autogen.sh └── db_revision_manual.sh └── tests ├── conftest.py └── test_account_management_views.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | .pytest_cache 3 | 4 | # Packages 5 | *.egg 6 | *.egg-info 7 | dist 8 | build 9 | eggs 10 | parts 11 | bin 12 | var 13 | sdist 14 | develop-eggs 15 | .installed.cfg 16 | lib 17 | lib64 18 | __pycache__ 19 | 20 | # Installer logs 21 | pip-log.txt 22 | 23 | # Unit test / coverage reports 24 | .coverage 25 | .tox 26 | nosetests.xml 27 | 28 | # Translations 29 | *.mo 30 | 31 | .venv 32 | venv 33 | logs 34 | migrations/schema.sql 35 | .flaskenv 36 | .vscode 37 | -------------------------------------------------------------------------------- /.sample_flaskenv: -------------------------------------------------------------------------------- 1 | SECRET_KEY=your_secret_key 2 | FLASK_APP=flask_for_startups.py 3 | FLASK_DEBUG=1 4 | FLASK_CONFIG=dev 5 | DEV_DATABASE_URI=postgresql://your_user:your_password@localhost/flask_for_startups 6 | TEST_DATABASE_URI=postgresql://your_user:your_password@localhost/test_flask_for_startups 7 | REMEMBER_COOKIE_HTTPONLY=True 8 | SESSION_COOKIE_SAMESITE=Lax 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nuvic 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flask_for_startups 2 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 3 | 4 | 5 | This flask boilerplate was written to help make it easy to iterate on your startup/indiehacker business, thereby increasing your chances of success. 6 | 7 | Interested in learning how this works? 8 | 9 | [![](https://www.flaskforstartups.com/imgs/get-the-book.png)](https://nuvic.gumroad.com/l/flaskforstartups) 10 | 11 | Want to show your support? [Get me a coffee ☕](https://www.buymeacoffee.com/nuvic) 12 | 13 | ## Acknowledgements 14 | 15 | - Alex Krupp's [django_for_startups repo](https://github.com/Alex3917/django_for_startups) and [article](https://alexkrupp.typepad.com/sensemaking/2021/06/django-for-startup-founders-a-better-software-architecture-for-saas-startups-and-consumer-apps.html) 16 | - Miguel Grinberg's [flask mega tutorial repo](https://github.com/miguelgrinberg/microblog). 17 | 18 | ## Why is this useful? 19 | 20 | When you're working on a project you're serious about, you want a set of conventions in place to let you develop fast and test different features. The main characteristics of this structure are: 21 | * **Predictability** 22 | * **Readability** 23 | * **Simplicity** 24 | * **Upgradability** 25 | 26 | For side projects especially, having this structure would be useful because it would let you easily pick up the project after some time. 27 | 28 | ## Features 29 | 30 | - Works with Python 3.9+ 31 | - [12-Factor](https://12factor.net/) based settings via [`.flaskenv` configuration handling](https://flask.palletsprojects.com/en/2.1.x/config/) 32 | - Login and registration via [flask-login](https://github.com/maxcountryman/flask-login) 33 | - [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy) Python SQL toolkit and ORM 34 | - DB Migration using [Alembic](https://github.com/sqlalchemy/alembic) 35 | - Role-based access control (RBAC) with User, UserRole, and Role models ready to go 36 | - [Pytest](https://github.com/pytest-dev/pytest/) setup with fixtures for app and models, and integration tests with high coverage 37 | - Validation using [pydantic](https://github.com/pydantic/pydantic) 38 | 39 | ### How is this different from other Flask tutorials? 40 | 41 | If you haven't read the above article, what's written here is a summary of the main points, and along with how it contrasts with the Flask structure from other popular tutorials. 42 | 43 | To make it simple to see, let's go through the `/register` route to see how a user would create an account. 44 | * user goes to `/register` 45 | * flask handles this request at `routes.py`: 46 | * `app.add_url_rule('/register', view_func=static_views.register)` 47 | * you can see that the route isn't using the usual decorator `@app.route` but instead, the route is connected with a `view_func` (aka controller) 48 | * `routes.py` actually only lists these `add_url_rule` functions connecting a url with a view_func 49 | * this makes it very easy for a developer to see exactly what route matches to which view function since it's all in one file. if the urls were split up, you would have to grep through your codebase to find the relevant url 50 | * the view function in file `static_views.py`, `register()` simply returns the template 51 | * user enters information on the register form (`register.html`), and submits their info 52 | * their user details are passed along to route `/api/register`: 53 | * `app.add_url_rule('/api/register', view_func=account_management_views.register_account, methods=['POST'])` 54 | * here the view function in file `account_management_views.py` looks like this: 55 | ```python 56 | def register_account(): 57 | unsafe_username = request.json.get("username") 58 | unsafe_email = request.json.get("email") 59 | unhashed_password = request.json.get("password") 60 | 61 | sanitized_username = sanitization.strip_xss(unsafe_username) 62 | sanitized_email = sanitization.strip_xss(unsafe_email) 63 | 64 | try: 65 | user_model = account_management_services.create_account( 66 | sanitized_username, sanitized_email, unhashed_password 67 | ) 68 | except ValidationError as e: 69 | return get_validation_error_response(validation_error=e, http_status_code=422) 70 | except custom_errors.EmailAddressAlreadyExistsError as e: 71 | return get_business_requirement_error_response( 72 | business_logic_error=e, http_status_code=409 73 | ) 74 | except custom_errors.InternalDbError as e: 75 | return get_db_error_response(db_error=e, http_status_code=500) 76 | 77 | login_user(user_model, remember=True) 78 | 79 | return {"message": "success"}, 201 80 | ``` 81 | * it shows linearly what functions are called for this endpoint (*readability* and *predictability*) 82 | * the user input is always sanitized first, with clear variable names of what's unsafe and what's sanitized 83 | * then the actual account creation occurs in a `service`, which is where your business logic happens 84 | * if the `account_management_services.create_account` function returns an exception, it's caught here, and an appropriate error response is returned back to the user 85 | * otherwise, the user is logged in 86 | * so how does the account creation service work? 87 | ```python 88 | def create_account(sanitized_username, sanitized_email): 89 | AccountValidator( 90 | username=sanitized_username, 91 | email=sanitized_email, 92 | password=unhashed_password 93 | ) 94 | 95 | if ( 96 | db.session.query(User.email).filter_by(email=sanitized_email).first() 97 | is not None 98 | ): 99 | raise custom_errors.EmailAddressAlreadyExistsError() 100 | 101 | hash = bcrypt.hashpw(unhashed_password.encode(), bcrypt.gensalt()) 102 | password_hash = hash.decode() 103 | 104 | account_model = Account() 105 | db.session.add(account_model) 106 | db.session.flush() 107 | 108 | user_model = User( 109 | username=sanitized_username, 110 | password_hash=password_hash, 111 | email=sanitized_email, 112 | account_id=account_model.account_id, 113 | ) 114 | 115 | db.session.add(user_model) 116 | db.session.commit() 117 | 118 | return user_model 119 | ``` 120 | * first, the user's info has to be validated through `AccountValidator` which checks for things like, does the email exist? 121 | * then it checks whether the email exists in the database, and if so, raise a custom error `EmailAddressAlreadyExists` 122 | * otherwise, it will add the user to the database and return the `user_model` 123 | * notice how the variable is called `user_model` instead of just `user`, making it clear that it's an ORM representation of the user 124 | * how do these custom errors work? 125 | * so if a user enters a email that already exists, it will raise this custom error from `custom_errors.py` 126 | ```python 127 | class EmailAddressAlreadyExistsError(Error): 128 | message = "There is already an account associated with this email address." 129 | internal_error_code = 40902 130 | ``` 131 | * the message is externally displayed to the user, while the `internal_error_code` is more for the frontend to use in debugging. it makes it easy for the frontend to see exactly what error happened and debug it (*readability*) 132 | ```python 133 | def get_business_requirement_error_response(business_logic_error, http_status_code): 134 | resp = { 135 | "errors": { 136 | "display_error": business_logic_error.message, 137 | "internal_error_code": business_logic_error.internal_error_code, 138 | } 139 | } 140 | return resp, http_status_code 141 | ``` 142 | * error messages are passed back to the frontend via a similar format as above: `display_error` and `internal_error_code`. the validation error message will be different in that it has field errors. (*simplicity*) 143 | * Testing 144 | * the tests are mostly integration tests using a test database 145 | * more work could be done here, but each endpoint should be tested for: permissions, validation errors, business requirement errors, and success conditions 146 | 147 | ## Setup Instructions 148 | 149 | Change `.sample_flaskenv` to `.flaskenv` 150 | 151 | ### Database setup 152 | 153 | Databases supported: 154 | - PostgreSQL 155 | - MySQL 156 | - SQLite 157 | 158 | However, I've only tested using PostgreSQL. 159 | 160 | Replace the `DEV_DATABASE_URI` with your database uri. If you're wishing to run the tests, update `TEST_DATABASE_URI`. 161 | 162 | ### Repo setup 163 | 164 | * `git clone git@github.com:nuvic/flask_for_startups.git` 165 | * `sudo apt-get install python3-dev` (needed to compile psycopg2, the python driver for PostgreSQL) 166 | * If using `poetry` for dependency management 167 | * `poetry install 168 | * Else use `pip` to install dependencies 169 | * `python3 -m venv venv` 170 | * activate virtual environment: `source venv/bin/activate` 171 | * install requirements: `pip install -r requirements.txt` 172 | * rename `.sample_flaskenv` to `.flaskenv` and update the relevant environment variables in `.flaskenv` 173 | * initialize the dev database: `alembic -c migrations/alembic.ini -x db=dev upgrade head` 174 | * run server: 175 | * with poetry: `poetry run flask run` 176 | * without poetry: `flask run` 177 | 178 | ### Updating db schema 179 | 180 | * if you make changes to models.py and want alembic to auto generate the db migration: `./scripts/db_revision_autogen.sh "your_change_here" 181 | * if you want to write your own changes: `./scripts/db_revision_manual.sh "your_change_here"` and find the new migration file in `migrations/versions` 182 | 183 | ### Run tests 184 | 185 | * if your test db needs to be migrated to latest schema: `alembic -c migrations/alembic.ini -x db=test upgrade head` 186 | * `python -m pytest tests` 187 | 188 | ### Dependency management 189 | 190 | Using [poetry](https://python-poetry.org/). 191 | 192 | Activate poetry shell and virtual environment: 193 | - `poetry shell` 194 | 195 | Check for outdated dependencies: 196 | - `poetry show --outdated` 197 | 198 | 199 | ## Other details 200 | 201 | * Sequential IDs vs UUIDs? 202 | * see [brandur's article](https://brandur.org/nanoglyphs/026-ids) for a good analysis of UUID vs sequence IDs 203 | * instead of UUID4, you can use a sequential UUID like a [tuid](https://github.com/tanglebones/pg_tuid) 204 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | import logging 3 | from logging.handlers import RotatingFileHandler 4 | import os 5 | 6 | # Core Flask imports 7 | from flask import Flask 8 | from flask_login import LoginManager 9 | 10 | # Third-party imports 11 | 12 | # App imports 13 | from app.database import DatabaseManager 14 | from config import config_manager 15 | 16 | 17 | # Load extensions 18 | login_manager = LoginManager() 19 | db_manager = DatabaseManager() 20 | 21 | 22 | def load_logs(app): 23 | if app.config["LOG_TO_STDOUT"]: 24 | stream_handler = logging.StreamHandler() 25 | stream_handler.setLevel(logging.INFO) 26 | app.logger.addHandler(stream_handler) 27 | else: 28 | if not os.path.exists("logs"): 29 | os.mkdir("logs") 30 | file_handler = RotatingFileHandler( 31 | "logs/app.log", maxBytes=10240, backupCount=10 32 | ) 33 | file_handler.setFormatter( 34 | logging.Formatter( 35 | "%(asctime)s %(levelname)s: %(message)s " "[in %(pathname)s:%(lineno)d]" 36 | ) 37 | ) 38 | file_handler.setLevel(logging.INFO) 39 | app.logger.addHandler(file_handler) 40 | 41 | app.logger.setLevel(logging.INFO) 42 | app.logger.info("app startup") 43 | return 44 | 45 | 46 | def create_app(config_name): 47 | app = Flask(__name__) 48 | app.config.from_object(config_manager[config_name]) 49 | 50 | config_manager[config_name].init_app(app) 51 | 52 | login_manager.login_view = "routes.login" 53 | login_manager.init_app(app) 54 | 55 | db_manager.init_app(app) 56 | 57 | from . import routes 58 | app.register_blueprint(routes.bp) 59 | 60 | if not app.debug and not app.testing: 61 | load_logs(app) 62 | 63 | return app 64 | -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | 5 | # Third-party imports 6 | from sqlalchemy import create_engine 7 | from sqlalchemy.orm import scoped_session, sessionmaker, declarative_base 8 | 9 | # App imports 10 | 11 | 12 | class DatabaseManager: 13 | def __init__(self, app=None): 14 | self.app = app 15 | self.session = None 16 | self.engine = None 17 | self.base = declarative_base() 18 | 19 | def init_app(self, app): 20 | self.create_engine(app.config["SQLALCHEMY_DATABASE_URI"]) 21 | self.create_scoped_session() 22 | self.base.query = self.session.query_property() 23 | 24 | def create_engine(self, sqlalchemy_database_uri): 25 | self.engine = create_engine(sqlalchemy_database_uri) 26 | 27 | def create_scoped_session(self): 28 | self.session = scoped_session( 29 | sessionmaker(autocommit=False, bind=self.engine) 30 | ) 31 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | from flask_login import UserMixin 5 | 6 | # Third-party imports 7 | from sqlalchemy import ( 8 | Integer, 9 | Column, 10 | Text, 11 | String, 12 | Boolean, 13 | DateTime, 14 | ForeignKey, 15 | func, 16 | ) 17 | from sqlalchemy.orm import relationship 18 | 19 | # App imports 20 | from app import db_manager 21 | 22 | # alias 23 | Base = db_manager.base 24 | 25 | 26 | class Account(Base): 27 | __tablename__ = "accounts" 28 | account_id = Column(Integer, primary_key=True) 29 | created_at = Column(DateTime, server_default=func.now()) 30 | users = relationship("User", back_populates="account") 31 | 32 | 33 | class Role(Base): 34 | __tablename__ = "roles" 35 | role_id = Column(Integer, primary_key=True) 36 | name = Column(Text, nullable=False) 37 | 38 | def __repr__(self): 39 | return f"" 40 | 41 | 42 | class UserRole(Base): 43 | __tablename__ = "users_x_roles" 44 | user_id = Column(Integer, ForeignKey("users.user_id"), primary_key=True) 45 | role_id = Column(Integer, ForeignKey("roles.role_id"), primary_key=True) 46 | assigned_at = Column(DateTime, nullable=False, server_default=func.now()) 47 | 48 | 49 | class User(UserMixin, Base): 50 | __tablename__ = "users" 51 | user_id = Column(Integer, primary_key=True) 52 | username = Column(Text) 53 | email = Column(String, nullable=False, unique=True) 54 | password_hash = Column(String(128), nullable=False) 55 | confirmed = Column(Boolean, nullable=False, server_default="false") 56 | created_at = Column(DateTime, nullable=False, server_default=func.now()) 57 | account_id = Column(Integer, ForeignKey("accounts.account_id"), nullable=False) 58 | account = relationship("Account", back_populates="users") 59 | roles = relationship("Role", secondary="users_x_roles") 60 | 61 | def get_id(self): 62 | return self.user_id 63 | 64 | def __repr__(self): 65 | return f"" 66 | -------------------------------------------------------------------------------- /app/permissions.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | from functools import wraps 3 | 4 | # Core Flask imports 5 | from flask_login import current_user 6 | 7 | # Third-party imports 8 | 9 | # App imports 10 | from .utils.error_utils import get_business_requirement_error_response 11 | from .utils.custom_errors import PermissionsDeniedError 12 | 13 | 14 | def roles_required(roles): 15 | def decorated_function(f): 16 | @wraps(f) 17 | def wrapper(*args, **kwargs): 18 | if set(roles).issubset({r.name for r in current_user.roles}): 19 | return f(*args, **kwargs) 20 | else: 21 | return get_business_requirement_error_response( 22 | PermissionsDeniedError, 403 23 | ) 24 | 25 | return wrapper 26 | 27 | return decorated_function 28 | -------------------------------------------------------------------------------- /app/routes.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | from flask import Blueprint 5 | 6 | # Third-party imports 7 | 8 | # App imports 9 | from app import db_manager 10 | from app import login_manager 11 | from .views import ( 12 | error_views, 13 | account_management_views, 14 | static_views, 15 | ) 16 | from .models import User 17 | 18 | bp = Blueprint('routes', __name__) 19 | 20 | # alias 21 | db = db_manager.session 22 | 23 | # Request management 24 | @bp.before_app_request 25 | def before_request(): 26 | db() 27 | 28 | @bp.teardown_app_request 29 | def shutdown_session(response_or_exc): 30 | db.remove() 31 | 32 | @login_manager.user_loader 33 | def load_user(user_id): 34 | """Load user by ID.""" 35 | if user_id and user_id != "None": 36 | return User.query.filter_by(user_id=user_id).first() 37 | 38 | # Error views 39 | bp.register_error_handler(404, error_views.not_found_error) 40 | 41 | bp.register_error_handler(500, error_views.internal_error) 42 | 43 | # Public views 44 | bp.add_url_rule("/", view_func=static_views.index) 45 | 46 | bp.add_url_rule("/register", view_func=static_views.register) 47 | 48 | bp.add_url_rule("/login", view_func=static_views.login) 49 | 50 | # Login required views 51 | bp.add_url_rule("/settings", view_func=static_views.settings) 52 | 53 | # Public API 54 | bp.add_url_rule( 55 | "/api/login", view_func=account_management_views.login_account, methods=["POST"] 56 | ) 57 | 58 | bp.add_url_rule("/logout", view_func=account_management_views.logout_account) 59 | 60 | bp.add_url_rule( 61 | "/api/register", 62 | view_func=account_management_views.register_account, 63 | methods=["POST"], 64 | ) 65 | 66 | # Login Required API 67 | bp.add_url_rule("/api/user", view_func=account_management_views.user) 68 | 69 | bp.add_url_rule( 70 | "/api/email", view_func=account_management_views.email, methods=["POST"] 71 | ) 72 | 73 | # Admin required 74 | bp.add_url_rule("/admin", view_func=static_views.admin) 75 | -------------------------------------------------------------------------------- /app/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuvic/flask_for_startups/3cc7ba46600d161cb883a32679c39bb01fd8c5c2/app/services/__init__.py -------------------------------------------------------------------------------- /app/services/account_management_services.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | 5 | # Third-party imports 6 | import bcrypt 7 | 8 | # App imports 9 | from app import db_manager as db 10 | from ..models import User, Account 11 | from ..utils import custom_errors 12 | from ..utils.validators import AccountValidator, EmailValidator 13 | 14 | 15 | def get_user_profile_from_user_model(user_model): 16 | user_model_dict = user_model.__dict__ 17 | 18 | allowlisted_keys = ["username", "email"] 19 | 20 | for key in list(user_model_dict.keys()): 21 | if key not in allowlisted_keys: 22 | user_model_dict.pop(key) 23 | 24 | return user_model_dict 25 | 26 | 27 | def update_email(current_user_model, sanitized_email): 28 | EmailValidator(email=sanitized_email) 29 | 30 | if ( 31 | db.session.query(User.email).filter_by(email=sanitized_email).first() 32 | is not None 33 | ): 34 | raise custom_errors.EmailAddressAlreadyExistsError() 35 | 36 | current_user_model.email = sanitized_email 37 | db.session.add(current_user_model) 38 | 39 | return 40 | 41 | 42 | def create_account(sanitized_username, sanitized_email, unhashed_password): 43 | AccountValidator( 44 | username=sanitized_username, 45 | email=sanitized_email, 46 | password=unhashed_password 47 | ) 48 | 49 | if ( 50 | db.session.query(User.email).filter_by(email=sanitized_email).first() 51 | is not None 52 | ): 53 | raise custom_errors.EmailAddressAlreadyExistsError() 54 | 55 | hash = bcrypt.hashpw(unhashed_password.encode(), bcrypt.gensalt()) 56 | password_hash = hash.decode() 57 | 58 | account_model = Account() 59 | db.session.add(account_model) 60 | db.session.flush() 61 | 62 | user_model = User( 63 | username=sanitized_username, 64 | password_hash=password_hash, 65 | email=sanitized_email, 66 | account_id=account_model.account_id, 67 | ) 68 | 69 | db.session.add(user_model) 70 | db.session.commit() 71 | 72 | return user_model 73 | 74 | 75 | def verify_login(sanitized_email, password): 76 | EmailValidator(email=sanitized_email) 77 | 78 | user_model = db.session.query(User).filter_by(email=sanitized_email).first() 79 | 80 | if not user_model: 81 | raise custom_errors.CouldNotVerifyLogin() 82 | 83 | if not bcrypt.checkpw(password.encode(), user_model.password_hash.encode()): 84 | raise custom_errors.CouldNotVerifyLogin() 85 | 86 | return user_model 87 | -------------------------------------------------------------------------------- /app/static/js/login.js: -------------------------------------------------------------------------------- 1 | const login_form = document.getElementById('login_form'); 2 | 3 | function login(e) { 4 | e.preventDefault(); 5 | let email = document.getElementById('email').value; 6 | let password = document.getElementById('password').value; 7 | fetch("/api/login", { 8 | method: "POST", 9 | headers: { 10 | "Content-Type": "application/json", 11 | }, 12 | credentials: "same-origin", 13 | body: JSON.stringify({ email: email, password: password }), 14 | }) 15 | .then((res) => res.json()) 16 | .then((data) => { 17 | console.log(data); 18 | }) 19 | .catch((err) => { 20 | console.log(err); 21 | }); 22 | }; 23 | 24 | login_form.addEventListener('submit', login); -------------------------------------------------------------------------------- /app/static/js/register.js: -------------------------------------------------------------------------------- 1 | const register_form = document.getElementById('register_form'); 2 | 3 | function register(e) { 4 | e.preventDefault(); 5 | let username = document.getElementById('username').value; 6 | let email = document.getElementById('email').value; 7 | let password = document.getElementById('password').value; 8 | 9 | fetch("/api/register", { 10 | method: "POST", 11 | headers: { 12 | "Content-Type": "application/json", 13 | }, 14 | credentials: "same-origin", 15 | body: JSON.stringify({ username: username, email: email, password: password }), 16 | }) 17 | .then((res) => res.json()) 18 | .then((data) => { 19 | console.log(data); 20 | register_form.reset(); 21 | }) 22 | .catch((err) => { 23 | console.log(err); 24 | }); 25 | }; 26 | 27 | register_form.addEventListener('submit', register); -------------------------------------------------------------------------------- /app/static/js/settings.js: -------------------------------------------------------------------------------- 1 | function getUserProfile() { 2 | let currentEmailEl = document.getElementById('current_email'); 3 | let currentUsernameEl = document.getElementById('current_username'); 4 | fetch("/api/user", { 5 | method: "GET", 6 | headers: { 7 | "Content-Type": "application/json", 8 | }, 9 | credentials: "same-origin" 10 | }) 11 | .then((res) => res.json()) 12 | .then((data) => { 13 | console.log(data); 14 | currentEmailEl.innerText = data.data.email; 15 | currentUsernameEl.innerText = data.data.username; 16 | }) 17 | .catch((err) => { 18 | console.log(err); 19 | }); 20 | } 21 | 22 | document.addEventListener("DOMContentLoaded", () => { getUserProfile() }); 23 | 24 | 25 | function updateEmail(e) { 26 | e.preventDefault(); 27 | let email = document.getElementById('new_email').value; 28 | let csrf = document.getElementsByName("csrf-token")[0].content; 29 | fetch("/api/email", { 30 | method: "POST", 31 | headers: { 32 | "Content-Type": "application/json", 33 | "X-CSRFToken": csrf, 34 | }, 35 | credentials: "same-origin", 36 | body: JSON.stringify({ email: email }), 37 | }) 38 | .then((res) => res.json()) 39 | .then((data) => { 40 | console.log(data); 41 | if (data.errors) { 42 | alert(data.errors.display_error); 43 | } 44 | }) 45 | .catch((err) => { 46 | console.log(err); 47 | }); 48 | }; 49 | 50 | const settings_form = document.getElementById('settings_form'); 51 | settings_form.addEventListener('submit', updateEmail); 52 | -------------------------------------------------------------------------------- /app/static/styles/sakura.css: -------------------------------------------------------------------------------- 1 | /* Sakura.css v1.3.1 2 | * ================ 3 | * Minimal css theme. 4 | * Project: https://github.com/oxalorg/sakura/ 5 | */ 6 | /* Body */ 7 | html { 8 | font-size: 62.5%; 9 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; } 10 | 11 | body { 12 | font-size: 1.8rem; 13 | line-height: 1.618; 14 | max-width: 38em; 15 | margin: auto; 16 | color: #4a4a4a; 17 | background-color: #f9f9f9; 18 | padding: 13px; } 19 | 20 | @media (max-width: 684px) { 21 | body { 22 | font-size: 1.53rem; } } 23 | 24 | @media (max-width: 382px) { 25 | body { 26 | font-size: 1.35rem; } } 27 | 28 | h1, h2, h3, h4, h5, h6 { 29 | line-height: 1.1; 30 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; 31 | font-weight: 700; 32 | margin-top: 3rem; 33 | margin-bottom: 1.5rem; 34 | overflow-wrap: break-word; 35 | word-wrap: break-word; 36 | -ms-word-break: break-all; 37 | word-break: break-word; } 38 | 39 | h1 { 40 | font-size: 2.35em; } 41 | 42 | h2 { 43 | font-size: 2.00em; } 44 | 45 | h3 { 46 | font-size: 1.75em; } 47 | 48 | h4 { 49 | font-size: 1.5em; } 50 | 51 | h5 { 52 | font-size: 1.25em; } 53 | 54 | h6 { 55 | font-size: 1em; } 56 | 57 | p { 58 | margin-top: 0px; 59 | margin-bottom: 2.5rem; } 60 | 61 | small, sub, sup { 62 | font-size: 75%; } 63 | 64 | hr { 65 | border-color: #1d7484; } 66 | 67 | a { 68 | text-decoration: none; 69 | color: #1d7484; } 70 | a:hover { 71 | color: #982c61; 72 | border-bottom: 2px solid #4a4a4a; } 73 | a:visited { 74 | color: #144f5a; } 75 | 76 | ul { 77 | padding-left: 1.4em; 78 | margin-top: 0px; 79 | margin-bottom: 2.5rem; } 80 | 81 | li { 82 | margin-bottom: 0.4em; } 83 | 84 | blockquote { 85 | margin-left: 0px; 86 | margin-right: 0px; 87 | padding-left: 1em; 88 | padding-top: 0.8em; 89 | padding-bottom: 0.8em; 90 | padding-right: 0.8em; 91 | border-left: 5px solid #1d7484; 92 | margin-bottom: 2.5rem; 93 | background-color: #f1f1f1; } 94 | 95 | blockquote p { 96 | margin-bottom: 0; } 97 | 98 | img, video { 99 | height: auto; 100 | max-width: 100%; 101 | margin-top: 0px; 102 | margin-bottom: 2.5rem; } 103 | 104 | /* Pre and Code */ 105 | pre { 106 | background-color: #f1f1f1; 107 | display: block; 108 | padding: 1em; 109 | overflow-x: auto; 110 | margin-top: 0px; 111 | margin-bottom: 2.5rem; } 112 | 113 | code { 114 | font-size: 0.9em; 115 | padding: 0 0.5em; 116 | background-color: #f1f1f1; 117 | white-space: pre-wrap; } 118 | 119 | pre > code { 120 | padding: 0; 121 | background-color: transparent; 122 | white-space: pre; } 123 | 124 | /* Tables */ 125 | table { 126 | text-align: justify; 127 | width: 100%; 128 | border-collapse: collapse; } 129 | 130 | td, th { 131 | padding: 0.5em; 132 | border-bottom: 1px solid #f1f1f1; } 133 | 134 | /* Buttons, forms and input */ 135 | input, textarea { 136 | border: 1px solid #4a4a4a; } 137 | input:focus, textarea:focus { 138 | border: 1px solid #1d7484; } 139 | 140 | textarea { 141 | width: 100%; } 142 | 143 | .button, button, input[type="submit"], input[type="reset"], input[type="button"] { 144 | display: inline-block; 145 | padding: 5px 10px; 146 | text-align: center; 147 | text-decoration: none; 148 | white-space: nowrap; 149 | background-color: #1d7484; 150 | color: #f9f9f9; 151 | border-radius: 1px; 152 | border: 1px solid #1d7484; 153 | cursor: pointer; 154 | box-sizing: border-box; } 155 | .button[disabled], button[disabled], input[type="submit"][disabled], input[type="reset"][disabled], input[type="button"][disabled] { 156 | cursor: default; 157 | opacity: .5; } 158 | .button:focus:enabled, .button:hover:enabled, button:focus:enabled, button:hover:enabled, input[type="submit"]:focus:enabled, input[type="submit"]:hover:enabled, input[type="reset"]:focus:enabled, input[type="reset"]:hover:enabled, input[type="button"]:focus:enabled, input[type="button"]:hover:enabled { 159 | background-color: #982c61; 160 | border-color: #982c61; 161 | color: #f9f9f9; 162 | outline: 0; } 163 | 164 | textarea, select, input { 165 | color: #4a4a4a; 166 | padding: 6px 10px; 167 | /* The 6px vertically centers text on FF, ignored by Webkit */ 168 | margin-bottom: 10px; 169 | background-color: #f1f1f1; 170 | border: 1px solid #f1f1f1; 171 | border-radius: 4px; 172 | box-shadow: none; 173 | box-sizing: border-box; } 174 | textarea:focus, select:focus, input:focus { 175 | border: 1px solid #1d7484; 176 | outline: 0; } 177 | 178 | input[type="checkbox"]:focus { 179 | outline: 1px dotted #1d7484; } 180 | 181 | label, legend, fieldset { 182 | display: block; 183 | margin-bottom: .5rem; 184 | font-weight: 600; } 185 | -------------------------------------------------------------------------------- /app/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Not Found

5 |

Back

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /app/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

An unexpected error has occurred

5 |

The administrator has been notified. Sorry for the inconvenience!

6 |

Back

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /app/templates/admin.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Admin

5 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Welcome to Flask for Startups 4 | 5 | 6 | 7 | {% block content %}{% endblock %} 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

flask_for_startups

5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /app/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Login

5 |
6 |
7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 |
15 | 16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /app/templates/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Register

5 |
6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 |
19 | 20 | 21 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /app/templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Settings

5 |

Update your email

6 |

Your info: 7 |
8 | 9 |
10 | 11 |

12 |
13 | 14 | 15 |

16 | 17 |
18 | 19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuvic/flask_for_startups/3cc7ba46600d161cb883a32679c39bb01fd8c5c2/app/utils/__init__.py -------------------------------------------------------------------------------- /app/utils/custom_errors.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | 5 | # Third-party imports 6 | 7 | # App imports 8 | 9 | 10 | class Error(Exception): 11 | def __init__(self, value=""): 12 | if not hasattr(self, "value"): 13 | self.value = value 14 | 15 | def __str__(self): 16 | return repr(self.value) 17 | 18 | 19 | class EmailAddressAlreadyExistsError(Error): 20 | message = "There is already an account associated with this email address." 21 | internal_error_code = 40902 22 | 23 | 24 | class InternalDbError(Error): 25 | message = "Sorry, we had a problem with that request. Please try again later or contact customer support." # noqa: E501 26 | internal_error_code = 50001 27 | 28 | 29 | class CouldNotVerifyLogin(Error): 30 | message = "You have entered an invalid email or password. Please try again." 31 | internal_error_code = 40101 32 | 33 | 34 | class PermissionsDeniedError(Error): 35 | message = "Sorry, you don't have the necessary permissions. Please contact your admin or customer support." # noqa: E501 36 | internal_error_code = 40301 37 | -------------------------------------------------------------------------------- /app/utils/error_utils.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | 5 | # Third-party imports 6 | 7 | # App imports 8 | 9 | 10 | def get_validation_error_response(validation_error, http_status_code, display_error=""): 11 | resp = { 12 | "errors": { 13 | "display_error": display_error, 14 | "field_errors": validation_error.errors() 15 | } 16 | } 17 | return resp, http_status_code 18 | 19 | 20 | def get_business_requirement_error_response(business_logic_error, http_status_code): 21 | resp = { 22 | "errors": { 23 | "display_error": business_logic_error.message, 24 | "internal_error_code": business_logic_error.internal_error_code, 25 | } 26 | } 27 | return resp, http_status_code 28 | 29 | 30 | def get_db_error_response(db_error, http_status_code): 31 | resp = { 32 | "errors": { 33 | "display_error": db_error.message, 34 | "internal_error_code": db_error.internal_error_code, 35 | } 36 | } 37 | return resp, http_status_code 38 | -------------------------------------------------------------------------------- /app/utils/sanitization.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | 5 | # Third-party imports 6 | import bleach 7 | 8 | # App imports 9 | 10 | 11 | def strip_xss(text): 12 | """Remove all markup from text.""" 13 | 14 | if not text: 15 | return 16 | 17 | allowed_tags = [] 18 | allowed_attributes = [] 19 | allowed_styles = [] 20 | 21 | text = bleach.clean( 22 | text, 23 | allowed_tags, 24 | allowed_attributes, 25 | allowed_styles, 26 | strip=True, 27 | strip_comments=True, 28 | ).strip() 29 | 30 | return text 31 | -------------------------------------------------------------------------------- /app/utils/validators.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | 5 | # Third-party imports 6 | from pydantic import BaseModel, validator, constr, EmailStr 7 | 8 | # App imports 9 | 10 | 11 | class AccountValidator(BaseModel): 12 | username: constr(min_length=1, max_length=15) = ... 13 | email: EmailStr = ... 14 | password: str = ... 15 | 16 | @validator('username') 17 | def username_valid(cls, v): 18 | if not v[0].isalpha(): 19 | raise ValueError('Username must start with a letter') 20 | if not v.isalnum(): 21 | raise ValueError('Username must contain only letters, numbers, and underscores') 22 | return v 23 | 24 | class EmailValidator(BaseModel): 25 | email: EmailStr 26 | -------------------------------------------------------------------------------- /app/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuvic/flask_for_startups/3cc7ba46600d161cb883a32679c39bb01fd8c5c2/app/views/__init__.py -------------------------------------------------------------------------------- /app/views/account_management_views.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | from flask import request, redirect, url_for 5 | 6 | # Third-party imports 7 | from pydantic import ValidationError 8 | from flask_login import login_user, logout_user, login_required, current_user 9 | 10 | # App imports 11 | from ..services import account_management_services 12 | from ..utils import custom_errors, sanitization 13 | from ..utils.error_utils import ( 14 | get_business_requirement_error_response, 15 | get_validation_error_response, 16 | get_db_error_response, 17 | ) 18 | 19 | 20 | def register_account(): 21 | unsafe_username = request.json.get("username") 22 | unsafe_email = request.json.get("email") 23 | unhashed_password = request.json.get("password") 24 | 25 | sanitized_username = sanitization.strip_xss(unsafe_username) 26 | sanitized_email = sanitization.strip_xss(unsafe_email) 27 | 28 | try: 29 | user_model = account_management_services.create_account( 30 | sanitized_username, sanitized_email, unhashed_password 31 | ) 32 | except ValidationError as e: 33 | return get_validation_error_response(validation_error=e, http_status_code=422) 34 | except custom_errors.EmailAddressAlreadyExistsError as e: 35 | return get_business_requirement_error_response( 36 | business_logic_error=e, http_status_code=409 37 | ) 38 | except custom_errors.InternalDbError as e: 39 | return get_db_error_response(db_error=e, http_status_code=500) 40 | 41 | login_user(user_model, remember=True) 42 | 43 | return {"message": "success"}, 201 44 | 45 | 46 | def login_account(): 47 | unsafe_email = request.json.get("email") 48 | password = request.json.get("password") 49 | 50 | sanitized_email = sanitization.strip_xss(unsafe_email) 51 | 52 | try: 53 | user_model = account_management_services.verify_login(sanitized_email, password) 54 | except ValidationError as e: 55 | return get_validation_error_response(validation_error=e, http_status_code=422) 56 | except custom_errors.CouldNotVerifyLogin as e: 57 | return get_business_requirement_error_response( 58 | business_logic_error=e, http_status_code=401 59 | ) 60 | 61 | login_user(user_model, remember=True) 62 | 63 | return {"message": "success"} 64 | 65 | 66 | def logout_account(): 67 | logout_user() 68 | return redirect(url_for("index")) 69 | 70 | 71 | @login_required 72 | def user(): 73 | user_profile_dict = account_management_services.get_user_profile_from_user_model( 74 | current_user 75 | ) 76 | return {"data": user_profile_dict} 77 | 78 | 79 | @login_required 80 | def email(): 81 | unsafe_email = request.json.get("email") 82 | 83 | sanitized_email = sanitization.strip_xss(unsafe_email) 84 | 85 | try: 86 | account_management_services.update_email(current_user, sanitized_email) 87 | except ValidationError as e: 88 | return get_validation_error_response(validation_error=e, http_status_code=422) 89 | except custom_errors.EmailAddressAlreadyExistsError as e: 90 | return get_business_requirement_error_response( 91 | business_logic_error=e, http_status_code=409 92 | ) 93 | except custom_errors.InternalDbError as e: 94 | return get_db_error_response(db_error=e, http_status_code=500) 95 | 96 | return {"message": "success"}, 201 97 | -------------------------------------------------------------------------------- /app/views/error_views.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | from flask import render_template 5 | 6 | # Third-party imports 7 | 8 | # App imports 9 | 10 | 11 | def not_found_error(error): 12 | return render_template("404.html"), 404 13 | 14 | 15 | def internal_error(error): 16 | return render_template("500.html"), 500 17 | -------------------------------------------------------------------------------- /app/views/static_views.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | from flask import render_template 5 | 6 | # Third-party imports 7 | from flask_login import login_required 8 | 9 | # App imports 10 | from ..permissions import roles_required 11 | 12 | 13 | def index(): 14 | return render_template("index.html") 15 | 16 | 17 | def register(): 18 | return render_template("register.html") 19 | 20 | 21 | def login(): 22 | return render_template("login.html") 23 | 24 | 25 | @login_required 26 | def settings(): 27 | return render_template("settings.html") 28 | 29 | 30 | @login_required 31 | @roles_required(["admin"]) 32 | def admin(): 33 | return render_template("admin.html") 34 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | basedir = os.path.abspath(os.path.dirname(__file__)) 5 | load_dotenv(os.path.join(basedir, ".flaskenv")) 6 | 7 | 8 | class Config(object): 9 | SECRET_KEY = os.environ.get("SECRET_KEY") 10 | SESSION_COOKIE_HTTPONLY = os.environ.get("SESSION_COOKIE_HTTPONLY") 11 | REMEMBER_COOKIE_HTTPONLY = os.environ.get("REMEMBER_COOKIE_HTTPONLY") 12 | SESSION_COOKIE_SAMESITE = os.environ.get("SESSION_COOKIE_SAMESITE") 13 | 14 | @staticmethod 15 | def init_app(app): 16 | pass 17 | 18 | 19 | class DevelopmentConfig(Config): 20 | SQLALCHEMY_DATABASE_URI = os.environ.get("DEV_DATABASE_URI") 21 | 22 | 23 | class TestingConfig(Config): 24 | TESTING = True 25 | SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URI") 26 | 27 | 28 | class ProductionConfig(Config): 29 | SQLALCHEMY_DATABASE_URI = os.environ.get("PROD_DATABASE_URI") 30 | 31 | 32 | config_manager = { 33 | "dev": DevelopmentConfig, 34 | "test": TestingConfig, 35 | "prod": ProductionConfig, 36 | } 37 | -------------------------------------------------------------------------------- /flask_for_startups.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | import os 3 | from dotenv import load_dotenv 4 | 5 | # Core Flask imports 6 | 7 | # Third-party imports 8 | 9 | # App imports 10 | from app import create_app, db_manager 11 | from app.models import Account, User, Role, UserRole 12 | 13 | 14 | dotenv_path = os.path.join(os.path.dirname(__file__), ".env") 15 | 16 | if os.path.exists(dotenv_path): 17 | load_dotenv(dotenv_path) 18 | 19 | 20 | app = create_app(os.getenv("FLASK_CONFIG") or "dev") 21 | 22 | 23 | @app.shell_context_processor 24 | def make_shell_context(): 25 | return dict(db=db_manager, User=User, Account=Account, Role=Role, UserRole=UserRole) 26 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | file_template = %%(year)d%%(month).2d%%(day).2d_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | # prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date 15 | # within the migration file as well as the filename. 16 | # string value is passed to dateutil.tz.gettz() 17 | # leave blank for localtime 18 | timezone = UTC 19 | 20 | # max length of characters to apply to the 21 | # "slug" field 22 | # truncate_slug_length = 40 23 | 24 | # set to 'true' to run the environment during 25 | # the 'revision' command, regardless of autogenerate 26 | # revision_environment = false 27 | 28 | # set to 'true' to allow .pyc and .pyo files without 29 | # a source .py file to be detected as revisions in the 30 | # versions/ directory 31 | # sourceless = false 32 | 33 | # version location specification; this defaults 34 | # to migrations/versions. When using multiple version 35 | # directories, initial revisions must be specified with --version-path 36 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 37 | 38 | # the output encoding used when revision files 39 | # are written from script.py.mako 40 | # output_encoding = utf-8 41 | 42 | sqlalchemy.url = driver://user:pass@localhost/dbname 43 | 44 | 45 | [post_write_hooks] 46 | # post_write_hooks defines scripts or Python functions that are run 47 | # on newly generated revision scripts. See the documentation for further 48 | # detail and examples 49 | 50 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 51 | # hooks = black 52 | # black.type = console_scripts 53 | # black.entrypoint = black 54 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 55 | 56 | # Logging configuration 57 | [loggers] 58 | keys = root,sqlalchemy,alembic 59 | 60 | [handlers] 61 | keys = console 62 | 63 | [formatters] 64 | keys = generic 65 | 66 | [logger_root] 67 | level = WARN 68 | handlers = console 69 | qualname = 70 | 71 | [logger_sqlalchemy] 72 | level = WARN 73 | handlers = 74 | qualname = sqlalchemy.engine 75 | 76 | [logger_alembic] 77 | level = INFO 78 | handlers = 79 | qualname = alembic 80 | 81 | [handler_console] 82 | class = StreamHandler 83 | args = (sys.stderr,) 84 | level = NOTSET 85 | formatter = generic 86 | 87 | [formatter_generic] 88 | format = %(levelname)-5.5s [%(name)s] %(message)s 89 | datefmt = %H:%M:%S 90 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | import os 3 | from pathlib import Path 4 | import sys 5 | from logging.config import fileConfig 6 | 7 | # Core Flask imports 8 | 9 | # Third-party imports 10 | from sqlalchemy import engine_from_config 11 | from sqlalchemy import pool 12 | from alembic import context 13 | from dotenv import dotenv_values 14 | 15 | # this is the Alembic Config object, which provides 16 | # access to the values within the .ini file in use. 17 | config = context.config 18 | 19 | # Interpret the config file for Python logging. 20 | # This line sets up loggers basically. 21 | fileConfig(config.config_file_name) 22 | 23 | 24 | # Get db uri depending on argument passed 25 | 26 | cmd_kwargs = context.get_x_argument(as_dictionary=True) 27 | if "db" not in cmd_kwargs: 28 | raise Exception( 29 | "We couldn't find `db` in the CLI arguments. " 30 | "Please verify `alembic` was run with `-x db=` " 31 | "(e.g. `alembic -x db=development upgrade head`)" 32 | ) 33 | 34 | db_env = cmd_kwargs["db"] 35 | 36 | if db_env not in ["dev", "test"]: 37 | raise Exception( 38 | "The `db` argument only accepts `dev` or `test`." 39 | "Please verify `alembic` was run with `-x db=` " 40 | "(e.g. `alembic -x db=development upgrade head`)" 41 | ) 42 | 43 | 44 | def get_dot_env(): 45 | cwd = Path(os.getcwd()) 46 | env_file = Path(cwd, ".flaskenv") 47 | values = dotenv_values(env_file) 48 | return values 49 | 50 | 51 | env_values = get_dot_env() 52 | 53 | if db_env == "dev": 54 | config.set_main_option("sqlalchemy.url", env_values["DEV_DATABASE_URI"]) 55 | elif db_env == "test": 56 | config.set_main_option("sqlalchemy.url", env_values["TEST_DATABASE_URI"]) 57 | 58 | 59 | # add your model's MetaData object here 60 | # for 'autogenerate' support 61 | # from myapp import mymodel 62 | # target_metadata = mymodel.Base.metadata 63 | 64 | # We are adding our current working directory to our path 65 | # so we can properly import our app's `models.Base` below 66 | cwd = os.getcwd() 67 | sys.path.append(cwd) 68 | 69 | # App imports 70 | from app.models import Base 71 | 72 | target_metadata = Base.metadata 73 | 74 | # other values from the config, defined by the needs of env.py, 75 | # can be acquired: 76 | # my_important_option = config.get_main_option("my_important_option") 77 | # ... etc. 78 | 79 | 80 | def run_migrations_offline(): 81 | """Run migrations in 'offline' mode. 82 | 83 | This configures the context with just a URL 84 | and not an Engine, though an Engine is acceptable 85 | here as well. By skipping the Engine creation 86 | we don't even need a DBAPI to be available. 87 | 88 | Calls to context.execute() here emit the given string to the 89 | script output. 90 | 91 | """ 92 | url = config.get_main_option("sqlalchemy.url") 93 | context.configure( 94 | url=url, 95 | target_metadata=target_metadata, 96 | literal_binds=True, 97 | dialect_opts={"paramstyle": "named"}, 98 | ) 99 | 100 | with context.begin_transaction(): 101 | context.run_migrations() 102 | 103 | 104 | def run_migrations_online(): 105 | """Run migrations in 'online' mode. 106 | 107 | In this scenario we need to create an Engine 108 | and associate a connection with the context. 109 | 110 | """ 111 | connectable = engine_from_config( 112 | config.get_section(config.config_ini_section), 113 | prefix="sqlalchemy.", 114 | poolclass=pool.NullPool, 115 | ) 116 | 117 | with connectable.connect() as connection: 118 | context.configure(connection=connection, target_metadata=target_metadata) 119 | 120 | with context.begin_transaction(): 121 | context.run_migrations() 122 | 123 | 124 | if context.is_offline_mode(): 125 | run_migrations_offline() 126 | else: 127 | run_migrations_online() 128 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/1_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 1_init 4 | Revises: 5 | Create Date: 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "1_init" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "accounts", 23 | sa.Column("account_id", sa.Integer(), nullable=False), 24 | sa.Column( 25 | "created_at", sa.DateTime(), server_default=sa.func.now(), nullable=True 26 | ), 27 | sa.PrimaryKeyConstraint("account_id"), 28 | ) 29 | op.create_table( 30 | "roles", 31 | sa.Column("role_id", sa.Integer(), nullable=False), 32 | sa.Column("name", sa.Text(), nullable=False), 33 | sa.PrimaryKeyConstraint("role_id"), 34 | ) 35 | op.create_table( 36 | "users", 37 | sa.Column("user_id", sa.Integer(), nullable=False), 38 | sa.Column("username", sa.Text(), nullable=True), 39 | sa.Column("email", sa.String(), nullable=False), 40 | sa.Column("password_hash", sa.String(length=128), nullable=False), 41 | sa.Column("confirmed", sa.Boolean(), server_default="false", nullable=False), 42 | sa.Column( 43 | "created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False 44 | ), 45 | sa.Column("account_id", sa.Integer(), nullable=False), 46 | sa.ForeignKeyConstraint( 47 | ["account_id"], 48 | ["accounts.account_id"], 49 | ), 50 | sa.PrimaryKeyConstraint("user_id"), 51 | sa.UniqueConstraint("email"), 52 | ) 53 | op.create_table( 54 | "users_x_roles", 55 | sa.Column("user_id", sa.Integer(), nullable=False), 56 | sa.Column("role_id", sa.Integer(), nullable=False), 57 | sa.Column( 58 | "assigned_at", 59 | sa.DateTime(), 60 | server_default=sa.func.now(), 61 | nullable=False, 62 | ), 63 | sa.ForeignKeyConstraint( 64 | ["role_id"], 65 | ["roles.role_id"], 66 | ), 67 | sa.ForeignKeyConstraint( 68 | ["user_id"], 69 | ["users.user_id"], 70 | ), 71 | sa.PrimaryKeyConstraint("user_id", "role_id"), 72 | ) 73 | # ### end Alembic commands ### 74 | 75 | 76 | def downgrade(): 77 | # ### commands auto generated by Alembic - please adjust! ### 78 | op.drop_table("users_x_roles") 79 | op.drop_table("users") 80 | op.drop_table("roles") 81 | op.drop_table("accounts") 82 | # ### end Alembic commands ### 83 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "alembic" 3 | version = "1.10.3" 4 | description = "A database migration tool for SQLAlchemy." 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.7" 8 | 9 | [package.dependencies] 10 | importlib-metadata = {version = "*", markers = "python_version < \"3.9\""} 11 | importlib-resources = {version = "*", markers = "python_version < \"3.9\""} 12 | Mako = "*" 13 | SQLAlchemy = ">=1.3.0" 14 | typing-extensions = ">=4" 15 | 16 | [package.extras] 17 | tz = ["python-dateutil"] 18 | 19 | [[package]] 20 | name = "bcrypt" 21 | version = "4.0.1" 22 | description = "Modern password hashing for your software and your servers" 23 | category = "main" 24 | optional = false 25 | python-versions = ">=3.6" 26 | 27 | [package.extras] 28 | tests = ["pytest (>=3.2.1,!=3.3.0)"] 29 | typecheck = ["mypy"] 30 | 31 | [[package]] 32 | name = "black" 33 | version = "22.12.0" 34 | description = "The uncompromising code formatter." 35 | category = "dev" 36 | optional = false 37 | python-versions = ">=3.7" 38 | 39 | [package.dependencies] 40 | click = ">=8.0.0" 41 | mypy-extensions = ">=0.4.3" 42 | pathspec = ">=0.9.0" 43 | platformdirs = ">=2" 44 | tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} 45 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 46 | 47 | [package.extras] 48 | colorama = ["colorama (>=0.4.3)"] 49 | d = ["aiohttp (>=3.7.4)"] 50 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 51 | uvloop = ["uvloop (>=0.15.2)"] 52 | 53 | [[package]] 54 | name = "bleach" 55 | version = "6.0.0" 56 | description = "An easy safelist-based HTML-sanitizing tool." 57 | category = "main" 58 | optional = false 59 | python-versions = ">=3.7" 60 | 61 | [package.dependencies] 62 | six = ">=1.9.0" 63 | webencodings = "*" 64 | 65 | [package.extras] 66 | css = ["tinycss2 (>=1.1.0,<1.2)"] 67 | 68 | [[package]] 69 | name = "click" 70 | version = "8.1.3" 71 | description = "Composable command line interface toolkit" 72 | category = "main" 73 | optional = false 74 | python-versions = ">=3.7" 75 | 76 | [package.dependencies] 77 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 78 | 79 | [[package]] 80 | name = "colorama" 81 | version = "0.4.6" 82 | description = "Cross-platform colored terminal text." 83 | category = "main" 84 | optional = false 85 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 86 | 87 | [[package]] 88 | name = "dnspython" 89 | version = "2.3.0" 90 | description = "DNS toolkit" 91 | category = "main" 92 | optional = false 93 | python-versions = ">=3.7,<4.0" 94 | 95 | [package.extras] 96 | curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] 97 | dnssec = ["cryptography (>=2.6,<40.0)"] 98 | doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.11.0)"] 99 | doq = ["aioquic (>=0.9.20)"] 100 | idna = ["idna (>=2.1,<4.0)"] 101 | trio = ["trio (>=0.14,<0.23)"] 102 | wmi = ["wmi (>=1.5.1,<2.0.0)"] 103 | 104 | [[package]] 105 | name = "email-validator" 106 | version = "1.3.1" 107 | description = "A robust email address syntax and deliverability validation library." 108 | category = "main" 109 | optional = false 110 | python-versions = ">=3.5" 111 | 112 | [package.dependencies] 113 | dnspython = ">=1.15.0" 114 | idna = ">=2.0.0" 115 | 116 | [[package]] 117 | name = "exceptiongroup" 118 | version = "1.1.1" 119 | description = "Backport of PEP 654 (exception groups)" 120 | category = "main" 121 | optional = false 122 | python-versions = ">=3.7" 123 | 124 | [package.extras] 125 | test = ["pytest (>=6)"] 126 | 127 | [[package]] 128 | name = "flake8" 129 | version = "4.0.1" 130 | description = "the modular source code checker: pep8 pyflakes and co" 131 | category = "dev" 132 | optional = false 133 | python-versions = ">=3.6" 134 | 135 | [package.dependencies] 136 | mccabe = ">=0.6.0,<0.7.0" 137 | pycodestyle = ">=2.8.0,<2.9.0" 138 | pyflakes = ">=2.4.0,<2.5.0" 139 | 140 | [[package]] 141 | name = "Flask" 142 | version = "2.2.3" 143 | description = "A simple framework for building complex web applications." 144 | category = "main" 145 | optional = false 146 | python-versions = ">=3.7" 147 | 148 | [package.dependencies] 149 | click = ">=8.0" 150 | importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} 151 | itsdangerous = ">=2.0" 152 | Jinja2 = ">=3.0" 153 | Werkzeug = ">=2.2.2" 154 | 155 | [package.extras] 156 | async = ["asgiref (>=3.2)"] 157 | dotenv = ["python-dotenv"] 158 | 159 | [[package]] 160 | name = "Flask-Login" 161 | version = "0.6.2" 162 | description = "User authentication and session management for Flask." 163 | category = "main" 164 | optional = false 165 | python-versions = ">=3.7" 166 | 167 | [package.dependencies] 168 | Flask = ">=1.0.4" 169 | Werkzeug = ">=1.0.1" 170 | 171 | [[package]] 172 | name = "greenlet" 173 | version = "2.0.2" 174 | description = "Lightweight in-process concurrent programming" 175 | category = "main" 176 | optional = false 177 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" 178 | 179 | [package.extras] 180 | docs = ["Sphinx", "docutils (<0.18)"] 181 | test = ["objgraph", "psutil"] 182 | 183 | [[package]] 184 | name = "idna" 185 | version = "3.4" 186 | description = "Internationalized Domain Names in Applications (IDNA)" 187 | category = "main" 188 | optional = false 189 | python-versions = ">=3.5" 190 | 191 | [[package]] 192 | name = "importlib-metadata" 193 | version = "6.3.0" 194 | description = "Read metadata from Python packages" 195 | category = "main" 196 | optional = false 197 | python-versions = ">=3.7" 198 | 199 | [package.dependencies] 200 | zipp = ">=0.5" 201 | 202 | [package.extras] 203 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 204 | perf = ["ipython"] 205 | testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] 206 | 207 | [[package]] 208 | name = "importlib-resources" 209 | version = "5.12.0" 210 | description = "Read resources from Python packages" 211 | category = "main" 212 | optional = false 213 | python-versions = ">=3.7" 214 | 215 | [package.dependencies] 216 | zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} 217 | 218 | [package.extras] 219 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 220 | testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] 221 | 222 | [[package]] 223 | name = "iniconfig" 224 | version = "2.0.0" 225 | description = "brain-dead simple config-ini parsing" 226 | category = "main" 227 | optional = false 228 | python-versions = ">=3.7" 229 | 230 | [[package]] 231 | name = "itsdangerous" 232 | version = "2.1.2" 233 | description = "Safely pass data to untrusted environments and back." 234 | category = "main" 235 | optional = false 236 | python-versions = ">=3.7" 237 | 238 | [[package]] 239 | name = "Jinja2" 240 | version = "3.1.2" 241 | description = "A very fast and expressive template engine." 242 | category = "main" 243 | optional = false 244 | python-versions = ">=3.7" 245 | 246 | [package.dependencies] 247 | MarkupSafe = ">=2.0" 248 | 249 | [package.extras] 250 | i18n = ["Babel (>=2.7)"] 251 | 252 | [[package]] 253 | name = "Mako" 254 | version = "1.2.4" 255 | description = "A super-fast templating language that borrows the best ideas from the existing templating languages." 256 | category = "main" 257 | optional = false 258 | python-versions = ">=3.7" 259 | 260 | [package.dependencies] 261 | MarkupSafe = ">=0.9.2" 262 | 263 | [package.extras] 264 | babel = ["Babel"] 265 | lingua = ["lingua"] 266 | testing = ["pytest"] 267 | 268 | [[package]] 269 | name = "MarkupSafe" 270 | version = "2.1.2" 271 | description = "Safely add untrusted strings to HTML/XML markup." 272 | category = "main" 273 | optional = false 274 | python-versions = ">=3.7" 275 | 276 | [[package]] 277 | name = "mccabe" 278 | version = "0.6.1" 279 | description = "McCabe checker, plugin for flake8" 280 | category = "dev" 281 | optional = false 282 | python-versions = "*" 283 | 284 | [[package]] 285 | name = "mypy-extensions" 286 | version = "1.0.0" 287 | description = "Type system extensions for programs checked with the mypy type checker." 288 | category = "dev" 289 | optional = false 290 | python-versions = ">=3.5" 291 | 292 | [[package]] 293 | name = "packaging" 294 | version = "23.0" 295 | description = "Core utilities for Python packages" 296 | category = "main" 297 | optional = false 298 | python-versions = ">=3.7" 299 | 300 | [[package]] 301 | name = "pathspec" 302 | version = "0.11.1" 303 | description = "Utility library for gitignore style pattern matching of file paths." 304 | category = "dev" 305 | optional = false 306 | python-versions = ">=3.7" 307 | 308 | [[package]] 309 | name = "platformdirs" 310 | version = "3.2.0" 311 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 312 | category = "dev" 313 | optional = false 314 | python-versions = ">=3.7" 315 | 316 | [package.extras] 317 | docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] 318 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] 319 | 320 | [[package]] 321 | name = "pluggy" 322 | version = "1.0.0" 323 | description = "plugin and hook calling mechanisms for python" 324 | category = "main" 325 | optional = false 326 | python-versions = ">=3.6" 327 | 328 | [package.extras] 329 | dev = ["pre-commit", "tox"] 330 | testing = ["pytest", "pytest-benchmark"] 331 | 332 | [[package]] 333 | name = "psycopg2" 334 | version = "2.9.6" 335 | description = "psycopg2 - Python-PostgreSQL Database Adapter" 336 | category = "main" 337 | optional = false 338 | python-versions = ">=3.6" 339 | 340 | [[package]] 341 | name = "pycodestyle" 342 | version = "2.8.0" 343 | description = "Python style guide checker" 344 | category = "dev" 345 | optional = false 346 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 347 | 348 | [[package]] 349 | name = "pydantic" 350 | version = "1.10.7" 351 | description = "Data validation and settings management using python type hints" 352 | category = "main" 353 | optional = false 354 | python-versions = ">=3.7" 355 | 356 | [package.dependencies] 357 | typing-extensions = ">=4.2.0" 358 | 359 | [package.extras] 360 | dotenv = ["python-dotenv (>=0.10.4)"] 361 | email = ["email-validator (>=1.0.3)"] 362 | 363 | [[package]] 364 | name = "pyflakes" 365 | version = "2.4.0" 366 | description = "passive checker of Python programs" 367 | category = "dev" 368 | optional = false 369 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 370 | 371 | [[package]] 372 | name = "pytest" 373 | version = "7.3.0" 374 | description = "pytest: simple powerful testing with Python" 375 | category = "main" 376 | optional = false 377 | python-versions = ">=3.7" 378 | 379 | [package.dependencies] 380 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 381 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 382 | iniconfig = "*" 383 | packaging = "*" 384 | pluggy = ">=0.12,<2.0" 385 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 386 | 387 | [package.extras] 388 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 389 | 390 | [[package]] 391 | name = "python-dotenv" 392 | version = "1.0.0" 393 | description = "Read key-value pairs from a .env file and set them as environment variables" 394 | category = "main" 395 | optional = false 396 | python-versions = ">=3.8" 397 | 398 | [package.extras] 399 | cli = ["click (>=5.0)"] 400 | 401 | [[package]] 402 | name = "six" 403 | version = "1.16.0" 404 | description = "Python 2 and 3 compatibility utilities" 405 | category = "main" 406 | optional = false 407 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 408 | 409 | [[package]] 410 | name = "SQLAlchemy" 411 | version = "2.0.9" 412 | description = "Database Abstraction Library" 413 | category = "main" 414 | optional = false 415 | python-versions = ">=3.7" 416 | 417 | [package.dependencies] 418 | greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} 419 | typing-extensions = ">=4.2.0" 420 | 421 | [package.extras] 422 | aiomysql = ["aiomysql", "greenlet (!=0.4.17)"] 423 | aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"] 424 | asyncio = ["greenlet (!=0.4.17)"] 425 | asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] 426 | mariadb_connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] 427 | mssql = ["pyodbc"] 428 | mssql_pymssql = ["pymssql"] 429 | mssql_pyodbc = ["pyodbc"] 430 | mypy = ["mypy (>=0.910)"] 431 | mysql = ["mysqlclient (>=1.4.0)"] 432 | mysql_connector = ["mysql-connector-python"] 433 | oracle = ["cx-oracle (>=7)"] 434 | oracle_oracledb = ["oracledb (>=1.0.1)"] 435 | postgresql = ["psycopg2 (>=2.7)"] 436 | postgresql_asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] 437 | postgresql_pg8000 = ["pg8000 (>=1.29.1)"] 438 | postgresql_psycopg = ["psycopg (>=3.0.7)"] 439 | postgresql_psycopg2binary = ["psycopg2-binary"] 440 | postgresql_psycopg2cffi = ["psycopg2cffi"] 441 | pymysql = ["pymysql"] 442 | sqlcipher = ["sqlcipher3-binary"] 443 | 444 | [[package]] 445 | name = "tomli" 446 | version = "2.0.1" 447 | description = "A lil' TOML parser" 448 | category = "main" 449 | optional = false 450 | python-versions = ">=3.7" 451 | 452 | [[package]] 453 | name = "typing-extensions" 454 | version = "4.5.0" 455 | description = "Backported and Experimental Type Hints for Python 3.7+" 456 | category = "main" 457 | optional = false 458 | python-versions = ">=3.7" 459 | 460 | [[package]] 461 | name = "webencodings" 462 | version = "0.5.1" 463 | description = "Character encoding aliases for legacy web content" 464 | category = "main" 465 | optional = false 466 | python-versions = "*" 467 | 468 | [[package]] 469 | name = "Werkzeug" 470 | version = "2.2.3" 471 | description = "The comprehensive WSGI web application library." 472 | category = "main" 473 | optional = false 474 | python-versions = ">=3.7" 475 | 476 | [package.dependencies] 477 | MarkupSafe = ">=2.1.1" 478 | 479 | [package.extras] 480 | watchdog = ["watchdog"] 481 | 482 | [[package]] 483 | name = "zipp" 484 | version = "3.15.0" 485 | description = "Backport of pathlib-compatible object wrapper for zip files" 486 | category = "main" 487 | optional = false 488 | python-versions = ">=3.7" 489 | 490 | [package.extras] 491 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 492 | testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] 493 | 494 | [metadata] 495 | lock-version = "1.1" 496 | python-versions = "^3.8" 497 | content-hash = "2029b399f2f17b960e093ec5f450a5a1ab0f988bc8a42d868d11c59c555ca3bc" 498 | 499 | [metadata.files] 500 | alembic = [ 501 | {file = "alembic-1.10.3-py3-none-any.whl", hash = "sha256:b2e0a6cfd3a8ce936a1168320bcbe94aefa3f4463cd773a968a55071beb3cd37"}, 502 | {file = "alembic-1.10.3.tar.gz", hash = "sha256:32a69b13a613aeb7e8093f242da60eff9daed13c0df02fff279c1b06c32965d2"}, 503 | ] 504 | bcrypt = [ 505 | {file = "bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f"}, 506 | {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0"}, 507 | {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410"}, 508 | {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344"}, 509 | {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a"}, 510 | {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3"}, 511 | {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2"}, 512 | {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535"}, 513 | {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e"}, 514 | {file = "bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab"}, 515 | {file = "bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9"}, 516 | {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c"}, 517 | {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b"}, 518 | {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d"}, 519 | {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d"}, 520 | {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215"}, 521 | {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c"}, 522 | {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda"}, 523 | {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665"}, 524 | {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71"}, 525 | {file = "bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd"}, 526 | ] 527 | black = [ 528 | {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, 529 | {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, 530 | {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, 531 | {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, 532 | {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, 533 | {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, 534 | {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, 535 | {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, 536 | {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, 537 | {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, 538 | {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, 539 | {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, 540 | ] 541 | bleach = [ 542 | {file = "bleach-6.0.0-py3-none-any.whl", hash = "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4"}, 543 | {file = "bleach-6.0.0.tar.gz", hash = "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414"}, 544 | ] 545 | click = [ 546 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 547 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 548 | ] 549 | colorama = [ 550 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 551 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 552 | ] 553 | dnspython = [ 554 | {file = "dnspython-2.3.0-py3-none-any.whl", hash = "sha256:89141536394f909066cabd112e3e1a37e4e654db00a25308b0f130bc3152eb46"}, 555 | {file = "dnspython-2.3.0.tar.gz", hash = "sha256:224e32b03eb46be70e12ef6d64e0be123a64e621ab4c0822ff6d450d52a540b9"}, 556 | ] 557 | email-validator = [ 558 | {file = "email_validator-1.3.1-py2.py3-none-any.whl", hash = "sha256:49a72f5fa6ed26be1c964f0567d931d10bf3fdeeacdf97bc26ef1cd2a44e0bda"}, 559 | {file = "email_validator-1.3.1.tar.gz", hash = "sha256:d178c5c6fa6c6824e9b04f199cf23e79ac15756786573c190d2ad13089411ad2"}, 560 | ] 561 | exceptiongroup = [ 562 | {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, 563 | {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, 564 | ] 565 | flake8 = [ 566 | {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, 567 | {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, 568 | ] 569 | Flask = [ 570 | {file = "Flask-2.2.3-py3-none-any.whl", hash = "sha256:c0bec9477df1cb867e5a67c9e1ab758de9cb4a3e52dd70681f59fa40a62b3f2d"}, 571 | {file = "Flask-2.2.3.tar.gz", hash = "sha256:7eb373984bf1c770023fce9db164ed0c3353cd0b53f130f4693da0ca756a2e6d"}, 572 | ] 573 | Flask-Login = [ 574 | {file = "Flask-Login-0.6.2.tar.gz", hash = "sha256:c0a7baa9fdc448cdd3dd6f0939df72eec5177b2f7abe6cb82fc934d29caac9c3"}, 575 | {file = "Flask_Login-0.6.2-py3-none-any.whl", hash = "sha256:1ef79843f5eddd0f143c2cd994c1b05ac83c0401dc6234c143495af9a939613f"}, 576 | ] 577 | greenlet = [ 578 | {file = "greenlet-2.0.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d"}, 579 | {file = "greenlet-2.0.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9"}, 580 | {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, 581 | {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, 582 | {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, 583 | {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, 584 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, 585 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, 586 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470"}, 587 | {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a"}, 588 | {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, 589 | {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, 590 | {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, 591 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, 592 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, 593 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, 594 | {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19"}, 595 | {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3"}, 596 | {file = "greenlet-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5"}, 597 | {file = "greenlet-2.0.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6"}, 598 | {file = "greenlet-2.0.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43"}, 599 | {file = "greenlet-2.0.2-cp35-cp35m-win32.whl", hash = "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a"}, 600 | {file = "greenlet-2.0.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394"}, 601 | {file = "greenlet-2.0.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0"}, 602 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3"}, 603 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db"}, 604 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099"}, 605 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75"}, 606 | {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf"}, 607 | {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292"}, 608 | {file = "greenlet-2.0.2-cp36-cp36m-win32.whl", hash = "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9"}, 609 | {file = "greenlet-2.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f"}, 610 | {file = "greenlet-2.0.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b"}, 611 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1"}, 612 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7"}, 613 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca"}, 614 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73"}, 615 | {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86"}, 616 | {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33"}, 617 | {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, 618 | {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, 619 | {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, 620 | {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, 621 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, 622 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, 623 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857"}, 624 | {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a"}, 625 | {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, 626 | {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, 627 | {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, 628 | {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, 629 | {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, 630 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, 631 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b"}, 632 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b"}, 633 | {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8"}, 634 | {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9"}, 635 | {file = "greenlet-2.0.2-cp39-cp39-win32.whl", hash = "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5"}, 636 | {file = "greenlet-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564"}, 637 | {file = "greenlet-2.0.2.tar.gz", hash = "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0"}, 638 | ] 639 | idna = [ 640 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 641 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 642 | ] 643 | importlib-metadata = [ 644 | {file = "importlib_metadata-6.3.0-py3-none-any.whl", hash = "sha256:8f8bd2af397cf33bd344d35cfe7f489219b7d14fc79a3f854b75b8417e9226b0"}, 645 | {file = "importlib_metadata-6.3.0.tar.gz", hash = "sha256:23c2bcae4762dfb0bbe072d358faec24957901d75b6c4ab11172c0c982532402"}, 646 | ] 647 | importlib-resources = [ 648 | {file = "importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"}, 649 | {file = "importlib_resources-5.12.0.tar.gz", hash = "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6"}, 650 | ] 651 | iniconfig = [ 652 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 653 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 654 | ] 655 | itsdangerous = [ 656 | {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, 657 | {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, 658 | ] 659 | Jinja2 = [ 660 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 661 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 662 | ] 663 | Mako = [ 664 | {file = "Mako-1.2.4-py3-none-any.whl", hash = "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818"}, 665 | {file = "Mako-1.2.4.tar.gz", hash = "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34"}, 666 | ] 667 | MarkupSafe = [ 668 | {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, 669 | {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, 670 | {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"}, 671 | {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"}, 672 | {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"}, 673 | {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"}, 674 | {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"}, 675 | {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"}, 676 | {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"}, 677 | {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"}, 678 | {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"}, 679 | {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"}, 680 | {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"}, 681 | {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"}, 682 | {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"}, 683 | {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"}, 684 | {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"}, 685 | {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"}, 686 | {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"}, 687 | {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"}, 688 | {file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"}, 689 | {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"}, 690 | {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"}, 691 | {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"}, 692 | {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"}, 693 | {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"}, 694 | {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"}, 695 | {file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"}, 696 | {file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"}, 697 | {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"}, 698 | {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"}, 699 | {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"}, 700 | {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"}, 701 | {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"}, 702 | {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"}, 703 | {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"}, 704 | {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"}, 705 | {file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"}, 706 | {file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"}, 707 | {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"}, 708 | {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"}, 709 | {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"}, 710 | {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"}, 711 | {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"}, 712 | {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"}, 713 | {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"}, 714 | {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"}, 715 | {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"}, 716 | {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"}, 717 | {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, 718 | ] 719 | mccabe = [ 720 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 721 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 722 | ] 723 | mypy-extensions = [ 724 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 725 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 726 | ] 727 | packaging = [ 728 | {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, 729 | {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, 730 | ] 731 | pathspec = [ 732 | {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, 733 | {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, 734 | ] 735 | platformdirs = [ 736 | {file = "platformdirs-3.2.0-py3-none-any.whl", hash = "sha256:ebe11c0d7a805086e99506aa331612429a72ca7cd52a1f0d277dc4adc20cb10e"}, 737 | {file = "platformdirs-3.2.0.tar.gz", hash = "sha256:d5b638ca397f25f979350ff789db335903d7ea010ab28903f57b27e1b16c2b08"}, 738 | ] 739 | pluggy = [ 740 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 741 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 742 | ] 743 | psycopg2 = [ 744 | {file = "psycopg2-2.9.6-cp310-cp310-win32.whl", hash = "sha256:f7a7a5ee78ba7dc74265ba69e010ae89dae635eea0e97b055fb641a01a31d2b1"}, 745 | {file = "psycopg2-2.9.6-cp310-cp310-win_amd64.whl", hash = "sha256:f75001a1cbbe523e00b0ef896a5a1ada2da93ccd752b7636db5a99bc57c44494"}, 746 | {file = "psycopg2-2.9.6-cp311-cp311-win32.whl", hash = "sha256:53f4ad0a3988f983e9b49a5d9765d663bbe84f508ed655affdb810af9d0972ad"}, 747 | {file = "psycopg2-2.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:b81fcb9ecfc584f661b71c889edeae70bae30d3ef74fa0ca388ecda50b1222b7"}, 748 | {file = "psycopg2-2.9.6-cp36-cp36m-win32.whl", hash = "sha256:11aca705ec888e4f4cea97289a0bf0f22a067a32614f6ef64fcf7b8bfbc53744"}, 749 | {file = "psycopg2-2.9.6-cp36-cp36m-win_amd64.whl", hash = "sha256:36c941a767341d11549c0fbdbb2bf5be2eda4caf87f65dfcd7d146828bd27f39"}, 750 | {file = "psycopg2-2.9.6-cp37-cp37m-win32.whl", hash = "sha256:869776630c04f335d4124f120b7fb377fe44b0a7645ab3c34b4ba42516951889"}, 751 | {file = "psycopg2-2.9.6-cp37-cp37m-win_amd64.whl", hash = "sha256:a8ad4a47f42aa6aec8d061fdae21eaed8d864d4bb0f0cade5ad32ca16fcd6258"}, 752 | {file = "psycopg2-2.9.6-cp38-cp38-win32.whl", hash = "sha256:2362ee4d07ac85ff0ad93e22c693d0f37ff63e28f0615a16b6635a645f4b9214"}, 753 | {file = "psycopg2-2.9.6-cp38-cp38-win_amd64.whl", hash = "sha256:d24ead3716a7d093b90b27b3d73459fe8cd90fd7065cf43b3c40966221d8c394"}, 754 | {file = "psycopg2-2.9.6-cp39-cp39-win32.whl", hash = "sha256:1861a53a6a0fd248e42ea37c957d36950da00266378746588eab4f4b5649e95f"}, 755 | {file = "psycopg2-2.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:ded2faa2e6dfb430af7713d87ab4abbfc764d8d7fb73eafe96a24155f906ebf5"}, 756 | {file = "psycopg2-2.9.6.tar.gz", hash = "sha256:f15158418fd826831b28585e2ab48ed8df2d0d98f502a2b4fe619e7d5ca29011"}, 757 | ] 758 | pycodestyle = [ 759 | {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, 760 | {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, 761 | ] 762 | pydantic = [ 763 | {file = "pydantic-1.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d"}, 764 | {file = "pydantic-1.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e"}, 765 | {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:516f1ed9bc2406a0467dd777afc636c7091d71f214d5e413d64fef45174cfc7a"}, 766 | {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae150a63564929c675d7f2303008d88426a0add46efd76c3fc797cd71cb1b46f"}, 767 | {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ecbbc51391248116c0a055899e6c3e7ffbb11fb5e2a4cd6f2d0b93272118a209"}, 768 | {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4a2b50e2b03d5776e7f21af73e2070e1b5c0d0df255a827e7c632962f8315af"}, 769 | {file = "pydantic-1.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:a7cd2251439988b413cb0a985c4ed82b6c6aac382dbaff53ae03c4b23a70e80a"}, 770 | {file = "pydantic-1.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68792151e174a4aa9e9fc1b4e653e65a354a2fa0fed169f7b3d09902ad2cb6f1"}, 771 | {file = "pydantic-1.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe2507b8ef209da71b6fb5f4e597b50c5a34b78d7e857c4f8f3115effaef5fe"}, 772 | {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a86d8c8db68086f1e30a530f7d5f83eb0685e632e411dbbcf2d5c0150e8dcd"}, 773 | {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75ae19d2a3dbb146b6f324031c24f8a3f52ff5d6a9f22f0683694b3afcb16fb"}, 774 | {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:464855a7ff7f2cc2cf537ecc421291b9132aa9c79aef44e917ad711b4a93163b"}, 775 | {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:193924c563fae6ddcb71d3f06fa153866423ac1b793a47936656e806b64e24ca"}, 776 | {file = "pydantic-1.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:b4a849d10f211389502059c33332e91327bc154acc1845f375a99eca3afa802d"}, 777 | {file = "pydantic-1.10.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cc1dde4e50a5fc1336ee0581c1612215bc64ed6d28d2c7c6f25d2fe3e7c3e918"}, 778 | {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0cfe895a504c060e5d36b287ee696e2fdad02d89e0d895f83037245218a87fe"}, 779 | {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:670bb4683ad1e48b0ecb06f0cfe2178dcf74ff27921cdf1606e527d2617a81ee"}, 780 | {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:950ce33857841f9a337ce07ddf46bc84e1c4946d2a3bba18f8280297157a3fd1"}, 781 | {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c15582f9055fbc1bfe50266a19771bbbef33dd28c45e78afbe1996fd70966c2a"}, 782 | {file = "pydantic-1.10.7-cp37-cp37m-win_amd64.whl", hash = "sha256:82dffb306dd20bd5268fd6379bc4bfe75242a9c2b79fec58e1041fbbdb1f7914"}, 783 | {file = "pydantic-1.10.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c7f51861d73e8b9ddcb9916ae7ac39fb52761d9ea0df41128e81e2ba42886cd"}, 784 | {file = "pydantic-1.10.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6434b49c0b03a51021ade5c4daa7d70c98f7a79e95b551201fff682fc1661245"}, 785 | {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d34ab766fa056df49013bb6e79921a0265204c071984e75a09cbceacbbdd5d"}, 786 | {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:701daea9ffe9d26f97b52f1d157e0d4121644f0fcf80b443248434958fd03dc3"}, 787 | {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf135c46099ff3f919d2150a948ce94b9ce545598ef2c6c7bf55dca98a304b52"}, 788 | {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0f85904f73161817b80781cc150f8b906d521fa11e3cdabae19a581c3606209"}, 789 | {file = "pydantic-1.10.7-cp38-cp38-win_amd64.whl", hash = "sha256:9f6f0fd68d73257ad6685419478c5aece46432f4bdd8d32c7345f1986496171e"}, 790 | {file = "pydantic-1.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c230c0d8a322276d6e7b88c3f7ce885f9ed16e0910354510e0bae84d54991143"}, 791 | {file = "pydantic-1.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:976cae77ba6a49d80f461fd8bba183ff7ba79f44aa5cfa82f1346b5626542f8e"}, 792 | {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d45fc99d64af9aaf7e308054a0067fdcd87ffe974f2442312372dfa66e1001d"}, 793 | {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2a5ebb48958754d386195fe9e9c5106f11275867051bf017a8059410e9abf1f"}, 794 | {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:abfb7d4a7cd5cc4e1d1887c43503a7c5dd608eadf8bc615413fc498d3e4645cd"}, 795 | {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:80b1fab4deb08a8292d15e43a6edccdffa5377a36a4597bb545b93e79c5ff0a5"}, 796 | {file = "pydantic-1.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:d71e69699498b020ea198468e2480a2f1e7433e32a3a99760058c6520e2bea7e"}, 797 | {file = "pydantic-1.10.7-py3-none-any.whl", hash = "sha256:0cd181f1d0b1d00e2b705f1bf1ac7799a2d938cce3376b8007df62b29be3c2c6"}, 798 | {file = "pydantic-1.10.7.tar.gz", hash = "sha256:cfc83c0678b6ba51b0532bea66860617c4cd4251ecf76e9846fa5a9f3454e97e"}, 799 | ] 800 | pyflakes = [ 801 | {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, 802 | {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, 803 | ] 804 | pytest = [ 805 | {file = "pytest-7.3.0-py3-none-any.whl", hash = "sha256:933051fa1bfbd38a21e73c3960cebdad4cf59483ddba7696c48509727e17f201"}, 806 | {file = "pytest-7.3.0.tar.gz", hash = "sha256:58ecc27ebf0ea643ebfdf7fb1249335da761a00c9f955bcd922349bcb68ee57d"}, 807 | ] 808 | python-dotenv = [ 809 | {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, 810 | {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, 811 | ] 812 | six = [ 813 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 814 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 815 | ] 816 | SQLAlchemy = [ 817 | {file = "SQLAlchemy-2.0.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:734805708632e3965c2c40081f9a59263c29ffa27cba9b02d4d92dfd57ba869f"}, 818 | {file = "SQLAlchemy-2.0.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8d3ece5960b3e821e43a4927cc851b6e84a431976d3ffe02aadb96519044807e"}, 819 | {file = "SQLAlchemy-2.0.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d118e233f416d713aac715e2c1101e17f91e696ff315fc9efbc75b70d11e740"}, 820 | {file = "SQLAlchemy-2.0.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f005245e1cb9b8ca53df73ee85e029ac43155e062405015e49ec6187a2e3fb44"}, 821 | {file = "SQLAlchemy-2.0.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:34eb96c1de91d8f31e988302243357bef3f7785e1b728c7d4b98bd0c117dafeb"}, 822 | {file = "SQLAlchemy-2.0.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7e472e9627882f2d75b87ff91c5a2bc45b31a226efc7cc0a054a94fffef85862"}, 823 | {file = "SQLAlchemy-2.0.9-cp310-cp310-win32.whl", hash = "sha256:0a865b5ec4ba24f57c33b633b728e43fde77b968911a6046443f581b25d29dd9"}, 824 | {file = "SQLAlchemy-2.0.9-cp310-cp310-win_amd64.whl", hash = "sha256:6e84ab63d25d8564d7a8c05dc080659931a459ee27f6ed1cf4c91f292d184038"}, 825 | {file = "SQLAlchemy-2.0.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db4bd1c4792da753f914ff0b688086b9a8fd78bb9bc5ae8b6d2e65f176b81eb9"}, 826 | {file = "SQLAlchemy-2.0.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ad5363a1c65fde7b7466769d4261126d07d872fc2e816487ae6cec93da604b6b"}, 827 | {file = "SQLAlchemy-2.0.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebc4eeb1737a5a9bdb0c24f4c982319fa6edd23cdee27180978c29cbb026f2bd"}, 828 | {file = "SQLAlchemy-2.0.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbda1da8d541904ba262825a833c9f619e93cb3fd1156be0a5e43cd54d588dcd"}, 829 | {file = "SQLAlchemy-2.0.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d5327f54a9c39e7871fc532639616f3777304364a0bb9b89d6033ad34ef6c5f8"}, 830 | {file = "SQLAlchemy-2.0.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ac6a0311fb21a99855953f84c43fcff4bdca27a2ffcc4f4d806b26b54b5cddc9"}, 831 | {file = "SQLAlchemy-2.0.9-cp311-cp311-win32.whl", hash = "sha256:d209594e68bec103ad5243ecac1b40bf5770c9ebf482df7abf175748a34f4853"}, 832 | {file = "SQLAlchemy-2.0.9-cp311-cp311-win_amd64.whl", hash = "sha256:865392a50a721445156809c1a6d6ab6437be70c1c2599f591a8849ed95d3c693"}, 833 | {file = "SQLAlchemy-2.0.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0b49f1f71d7a44329a43d3edd38cc5ee4c058dfef4487498393d16172007954b"}, 834 | {file = "SQLAlchemy-2.0.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a019f723b6c1e6b3781be00fb9e0844bc6156f9951c836ff60787cc3938d76"}, 835 | {file = "SQLAlchemy-2.0.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9838bd247ee42eb74193d865e48dd62eb50e45e3fdceb0fdef3351133ee53dcf"}, 836 | {file = "SQLAlchemy-2.0.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:78612edf4ba50d407d0eb3a64e9ec76e6efc2b5d9a5c63415d53e540266a230a"}, 837 | {file = "SQLAlchemy-2.0.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:f61ab84956dc628c8dfe9d105b6aec38afb96adae3e5e7da6085b583ff6ea789"}, 838 | {file = "SQLAlchemy-2.0.9-cp37-cp37m-win32.whl", hash = "sha256:07950fc82f844a2de67ddb4e535f29b65652b4d95e8b847823ce66a6d540a41d"}, 839 | {file = "SQLAlchemy-2.0.9-cp37-cp37m-win_amd64.whl", hash = "sha256:e62c4e762d6fd2901692a093f208a6a6575b930e9458ad58c2a7f080dd6132da"}, 840 | {file = "SQLAlchemy-2.0.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b3e5864eba71a3718236a120547e52c8da2ccb57cc96cecd0480106a0c799c92"}, 841 | {file = "SQLAlchemy-2.0.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d06e119cf79a3d80ab069f064a07152eb9ba541d084bdaee728d8a6f03fd03d"}, 842 | {file = "SQLAlchemy-2.0.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee2946042cc7851842d7a086a92b9b7b494cbe8c3e7e4627e27bc912d3a7655e"}, 843 | {file = "SQLAlchemy-2.0.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13f984a190d249769a050634b248aef8991acc035e849d02b634ea006c028fa8"}, 844 | {file = "SQLAlchemy-2.0.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e4780be0f19e5894c17f75fc8de2fe1ae233ab37827125239ceb593c6f6bd1e2"}, 845 | {file = "SQLAlchemy-2.0.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:68ed381bc340b4a3d373dbfec1a8b971f6350139590c4ca3cb722fdb50035777"}, 846 | {file = "SQLAlchemy-2.0.9-cp38-cp38-win32.whl", hash = "sha256:aa5c270ece17c0c0e0a38f2530c16b20ea05d8b794e46c79171a86b93b758891"}, 847 | {file = "SQLAlchemy-2.0.9-cp38-cp38-win_amd64.whl", hash = "sha256:1b69666e25cc03c602d9d3d460e1281810109e6546739187044fc256c67941ef"}, 848 | {file = "SQLAlchemy-2.0.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6e27189ff9aebfb2c02fd252c629ea58657e7a5ff1a321b7fc9c2bf6dc0b5f3"}, 849 | {file = "SQLAlchemy-2.0.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8239ce63a90007bce479adf5460d48c1adae4b933d8e39a4eafecfc084e503c"}, 850 | {file = "SQLAlchemy-2.0.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f759eccb66e6d495fb622eb7f4ac146ae674d829942ec18b7f5a35ddf029597"}, 851 | {file = "SQLAlchemy-2.0.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246712af9fc761d6c13f4f065470982e175d902e77aa4218c9cb9fc9ff565a0c"}, 852 | {file = "SQLAlchemy-2.0.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6b72dccc5864ea95c93e0a9c4e397708917fb450f96737b4a8395d009f90b868"}, 853 | {file = "SQLAlchemy-2.0.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:93c78d42c14aa9a9e0866eacd5b48df40a50d0e2790ee377af7910d224afddcf"}, 854 | {file = "SQLAlchemy-2.0.9-cp39-cp39-win32.whl", hash = "sha256:f49c5d3c070a72ecb96df703966c9678dda0d4cb2e2736f88d15f5e1203b4159"}, 855 | {file = "SQLAlchemy-2.0.9-cp39-cp39-win_amd64.whl", hash = "sha256:4c3020afb144572c7bfcba9d7cce57ad42bff6e6115dffcfe2d4ae6d444a214f"}, 856 | {file = "SQLAlchemy-2.0.9-py3-none-any.whl", hash = "sha256:e730603cae5747bc6d6dece98b45a57d647ed553c8d5ecef602697b1c1501cf2"}, 857 | {file = "SQLAlchemy-2.0.9.tar.gz", hash = "sha256:95719215e3ec7337b9f57c3c2eda0e6a7619be194a5166c07c1e599f6afc20fa"}, 858 | ] 859 | tomli = [ 860 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 861 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 862 | ] 863 | typing-extensions = [ 864 | {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, 865 | {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, 866 | ] 867 | webencodings = [ 868 | {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, 869 | {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, 870 | ] 871 | Werkzeug = [ 872 | {file = "Werkzeug-2.2.3-py3-none-any.whl", hash = "sha256:56433961bc1f12533306c624f3be5e744389ac61d722175d543e1751285da612"}, 873 | {file = "Werkzeug-2.2.3.tar.gz", hash = "sha256:2e1ccc9417d4da358b9de6f174e3ac094391ea1d4fbef2d667865d819dfd0afe"}, 874 | ] 875 | zipp = [ 876 | {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, 877 | {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, 878 | ] 879 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "flask-for-startups" 3 | version = "0.1.0" 4 | description = "This flask boilerplate was written to help make it easy to iterate on your startup/indiehacker business, thereby increasing your chances of success." 5 | authors = ["Nuvic"] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.8" 11 | Flask-Login = "^0.6.2" 12 | Flask = "^2.2.3" 13 | bleach = "^6.0.0" 14 | SQLAlchemy = "^2.0.9" 15 | alembic = "^1.10.3" 16 | pytest = "^7.3.0" 17 | python-dotenv = "^1.0.0" 18 | bcrypt = "^4.0.1" 19 | psycopg2 = "^2.9.6" 20 | pydantic = "^1.10.7" 21 | email-validator = "^1.3.1" 22 | 23 | 24 | [tool.poetry.group.dev.dependencies] 25 | black = "^22.1.0" 26 | flake8 = "^4.0.1" 27 | 28 | [build-system] 29 | requires = ["poetry-core"] 30 | build-backend = "poetry.core.masonry.api" 31 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.7.6; python_version >= "3.6" 2 | atomicwrites==1.4.0; python_version >= "3.6" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.4.0" 3 | attrs==21.4.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 4 | bcrypt==3.2.0; python_version >= "3.6" 5 | bleach==4.1.0; python_version >= "3.6" 6 | cffi==1.15.0; python_version >= "3.6" 7 | click==8.0.4; python_version >= "3.6" 8 | colorama==0.4.4; python_version >= "3.6" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.5.0" or python_version >= "3.6" and python_full_version < "3.0.0" and platform_system == "Windows" or platform_system == "Windows" and python_version >= "3.6" and python_full_version >= "3.5.0" 9 | flask-login==0.5.0 10 | flask==2.0.3; python_version >= "3.6" 11 | greenlet==1.1.2; python_version >= "3" and python_full_version < "3.0.0" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") or python_version >= "3" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and python_full_version >= "3.5.0" or python_version >= "3" and python_full_version < "3.0.0" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") or python_version >= "3" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") and python_full_version >= "3.5.0" 12 | importlib-metadata==4.11.2; python_version < "3.9" and python_version >= "3.7" 13 | importlib-resources==5.4.0; python_version < "3.9" and python_version >= "3.6" 14 | iniconfig==1.1.1; python_version >= "3.6" 15 | itsdangerous==2.1.0; python_version >= "3.7" 16 | jinja2==3.0.3; python_version >= "3.6" 17 | mako==1.1.6; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" 18 | markupsafe==2.1.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7" or python_version >= "3.7" 19 | marshmallow==3.14.1; python_version >= "3.6" 20 | packaging==21.3; python_version >= "3.6" 21 | pluggy==1.0.0; python_version >= "3.6" 22 | psycopg2==2.9.3; python_version >= "3.6" 23 | py==1.11.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 24 | pycparser==2.21; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" 25 | pyparsing==3.0.7; python_version >= "3.6" 26 | pytest==7.0.1; python_version >= "3.6" 27 | python-dotenv==0.19.2; python_version >= "3.5" 28 | six==1.16.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.6" 29 | sqlalchemy==1.4.31; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0") 30 | tomli==2.0.1; python_version >= "3.7" 31 | webencodings==0.5.1; python_version >= "3.6" 32 | werkzeug==2.0.3; python_version >= "3.6" 33 | zipp==3.7.0; python_version < "3.9" and python_version >= "3.7" 34 | -------------------------------------------------------------------------------- /scripts/db_revision_autogen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Use: ./scripts/db_revision_autogen.sh "description_for_this_db_revision" 4 | alembic -c migrations/alembic.ini -x db=dev revision --autogenerate -m $1 --rev-id=$(date '+%Y%m%d' -u)_$1 -------------------------------------------------------------------------------- /scripts/db_revision_manual.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Use: ./scripts/db_revision_manual.sh "description_for_this_db_revision" 4 | alembic -c migrations/alembic.ini revision -m $1 --rev-id=$(date '+%Y%m%d')_$1 -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | import pytest 3 | 4 | # Core Flask imports 5 | 6 | # Third-party imports 7 | from sqlalchemy.orm import sessionmaker, scoped_session 8 | from sqlalchemy import event 9 | from sqlalchemy import create_engine 10 | import bcrypt 11 | 12 | # App imports 13 | from app import create_app, db_manager 14 | from app.models import Base, User, Account 15 | 16 | 17 | @pytest.fixture(scope="session") 18 | def app(request): 19 | app = create_app(config_name="test") 20 | 21 | # Get clean test database: delete data from all tables in the test db 22 | for table in reversed(Base.metadata.sorted_tables): 23 | session = db_manager.session() 24 | session.execute(table.delete()) 25 | session.commit() 26 | 27 | # Establish an application context before running the tests. 28 | ctx = app.app_context() 29 | ctx.push() 30 | 31 | def teardown(): 32 | ctx.pop() 33 | 34 | request.addfinalizer(teardown) 35 | return app 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def client(app): 40 | """A test client for the app.""" 41 | return app.test_client() 42 | 43 | 44 | @pytest.fixture(scope="session") 45 | def _connection(app): 46 | engine = create_engine(app.config["SQLALCHEMY_DATABASE_URI"]) 47 | connection = engine.connect() 48 | yield connection 49 | connection.close() 50 | 51 | 52 | # Session = scoped_session(sessionmaker()) 53 | @pytest.fixture(scope="session") 54 | def _scoped_session(app): 55 | Session = scoped_session(sessionmaker()) 56 | return Session 57 | 58 | 59 | @pytest.fixture(autouse=True) 60 | def db(_connection, _scoped_session, request): 61 | # Bind app's db session to the test session 62 | transaction = _connection.begin() 63 | Session = _scoped_session 64 | session = Session(bind=_connection) 65 | session.begin_nested() 66 | 67 | @event.listens_for(session, "after_transaction_end") 68 | def restart_savepoint(db_session, transaction): 69 | """Support tests with rollbacks. 70 | 71 | if the database supports SAVEPOINT (SQLite needs special 72 | config for this to work), starting a savepoint 73 | will allow tests to also use rollback within tests 74 | 75 | Reference: https://docs.sqlalchemy.org/en/14/orm/session_transaction.html#session-begin-nested # noqa: E501 76 | """ 77 | if transaction.nested and not transaction._parent.nested: 78 | # ensure that state is expired the way session.commit() at 79 | # the top level normally does 80 | session.expire_all() 81 | session.begin_nested() 82 | 83 | """ 84 | Important. This step binds the app's db session to the test session 85 | to allow each individual test to be wrapped in a transaction 86 | and rollback to a clean state after each test 87 | """ 88 | db_manager.session = session 89 | 90 | def teardown(): 91 | Session.remove() 92 | transaction.rollback() 93 | 94 | request.addfinalizer(teardown) 95 | 96 | yield db_manager 97 | 98 | 99 | @pytest.fixture() 100 | def user_details(): 101 | class UserDetails(object): 102 | test_username = "test_user" 103 | test_email = "test@email.com" 104 | test_password = "my_secure_password" 105 | 106 | return UserDetails 107 | 108 | 109 | @pytest.fixture() 110 | def existing_user(db, user_details): 111 | account_model = Account() 112 | db.session.add(account_model) 113 | db.session.flush() 114 | 115 | hash = bcrypt.hashpw(user_details.test_password.encode(), bcrypt.gensalt()) 116 | password_hash = hash.decode() 117 | 118 | user_model = User( 119 | username=user_details.test_username, 120 | password_hash=password_hash, 121 | email=user_details.test_email, 122 | account_id=account_model.account_id, 123 | ) 124 | db.session.add(user_model) 125 | db.session.commit() 126 | return user_model 127 | -------------------------------------------------------------------------------- /tests/test_account_management_views.py: -------------------------------------------------------------------------------- 1 | # Standard Library imports 2 | 3 | # Core Flask imports 4 | 5 | # Third-party imports 6 | 7 | # App imports 8 | from app.models import User 9 | from app.utils.custom_errors import CouldNotVerifyLogin 10 | 11 | 12 | def test_index(client): 13 | response = client.get("/") 14 | assert response.status_code == 200 15 | 16 | 17 | def test_register_view(client): 18 | response = client.get("/register") 19 | 20 | assert response.status_code == 200 21 | 22 | 23 | def test_register_service(client, db, user_details): 24 | response = client.post( 25 | "/api/register", 26 | json={ 27 | "username": user_details.test_username, 28 | "email": user_details.test_email, 29 | "password": user_details.test_password, 30 | }, 31 | ) 32 | 33 | # test successful account registration 34 | assert response.status_code == 201 35 | 36 | # test that user was inserted into database 37 | user_model = db.session.query(User).filter_by(email=user_details.test_email).first() 38 | assert user_model is not None 39 | assert user_model.username == user_details.test_username 40 | assert user_model.email == user_details.test_email 41 | 42 | 43 | def test_register_service_reject_duplicates(client, db, user_details): 44 | response = client.post( 45 | "/api/register", 46 | json={ 47 | "username": user_details.test_username, 48 | "email": user_details.test_email, 49 | "password": user_details.test_password, 50 | }, 51 | ) 52 | 53 | assert response.status_code == 201 54 | 55 | # test that duplicate email registrations are not allowed 56 | response_duplicate = client.post( 57 | "/api/register", 58 | json={ 59 | "username": user_details.test_username, 60 | "email": user_details.test_email, 61 | "password": user_details.test_password, 62 | }, 63 | ) 64 | 65 | assert response_duplicate.status_code == 409 66 | 67 | user_model_list = ( 68 | db.session.query(User).filter_by(email=user_details.test_email).all() 69 | ) 70 | 71 | # test that only one user exists with the registered email 72 | assert len(user_model_list) == 1 73 | 74 | 75 | def test_register_service_requires_email(client, db, user_details): 76 | response = client.post( 77 | "/api/register", json={"username": user_details.test_username} 78 | ) 79 | 80 | assert response.status_code == 422 81 | 82 | email_error_response = response.json["errors"]["field_errors"]["email"][0] 83 | assert email_error_response == "Field may not be null." 84 | 85 | password_error_response = response.json["errors"]["field_errors"]["password"][0] 86 | assert password_error_response == "Field may not be null." 87 | 88 | 89 | def test_login_success(client, existing_user, user_details): 90 | login_view_response = client.get("/login") 91 | 92 | assert login_view_response.status_code == 200 93 | 94 | response = client.post( 95 | "/api/login", 96 | json={"email": existing_user.email, "password": user_details.test_password}, 97 | ) 98 | 99 | assert response.status_code == 200 100 | assert response.json["message"] == "success" 101 | 102 | 103 | def test_login_fail(client, existing_user): 104 | response = client.post( 105 | "/api/login", 106 | json={"email": existing_user.email, "password": "fake password"}, 107 | ) 108 | 109 | response_display_error = response.json["errors"]["display_error"] 110 | 111 | assert response.status_code == 401 112 | assert response_display_error == CouldNotVerifyLogin.message 113 | 114 | 115 | def test_logout(client, existing_user, user_details): 116 | client.post( 117 | "/api/login", 118 | json={"email": existing_user.email, "password": user_details.test_password}, 119 | ) 120 | 121 | response = client.get("/logout", follow_redirects=True) 122 | 123 | # check that the path changed 124 | assert response.request.path == "/" 125 | --------------------------------------------------------------------------------