diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da93d34c5a..1e2913dcc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,21 +9,21 @@ on: jobs: lint: - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 strategy: matrix: - python-version: [3.7] + python-version: [3.8] steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev + sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-dev.txt @@ -33,18 +33,22 @@ jobs: run: flake8 flask_appbuilder - name: mypy run: mypy flask_appbuilder + - name: black + run: black --check tests + - name: flake8 + run: flake8 tests test-postgres: - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9.7] + python-version: ["3.7", "3.8", "3.9"] env: SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://pguser:pguserpassword@127.0.0.1:15432/app services: postgres: - image: postgres:10-alpine + image: postgres:14-alpine env: POSTGRES_USER: pguser POSTGRES_PASSWORD: pguserpassword @@ -52,31 +56,43 @@ jobs: ports: - 15432:5432 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Run openldap + run: | + docker run -d \ + -v '${{ github.workspace }}/docker/openldap/ldifs:/ldifs' \ + -v '${{ github.workspace }}/docker/openldap/schemas/memberof.ldif:/opt/bitnami/openldap/etc/schema/memberof.ldif' \ + -e LDAP_URI=ldap://openldap:1389 \ + -e LDAP_BASE=dc=example,dc=org \ + -e LDAP_ADMIN_USERNAME=admin \ + -e LDAP_ADMIN_PASSWORD=admin_password \ + -e LDAP_EXTRA_SCHEMAS=cosine,inetorgperson,nis,memberof \ + -p 1389:1389 \ + bitnami/openldap:2.6.4 - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev + sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-dev.txt pip install -r requirements-extra.txt - name: Run tests run: | - nosetests --stop -v --with-coverage --cover-package=flask_appbuilder flask_appbuilder.tests --ignore-files="test_mongoengine\.py" + nosetests --stop -v --with-coverage --cover-package=flask_appbuilder tests --ignore-files="test_mongoengine\.py" - name: Upload code coverage run: | bash <(curl -s https://codecov.io/bash) -cF python test-mysql: - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 strategy: matrix: - python-version: [3.7] + python-version: [3.8] env: SQLALCHEMY_DATABASE_URI: | mysql+mysqldb://mysqluser:mysqluserpassword@127.0.0.1:13306/app?charset=utf8mb4&binary_prefix=true @@ -91,31 +107,43 @@ jobs: ports: - 13306:3306 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Run openldap + run: | + docker run -d \ + -v '${{ github.workspace }}/docker/openldap/ldifs:/ldifs' \ + -v '${{ github.workspace }}/docker/openldap/schemas/memberof.ldif:/opt/bitnami/openldap/etc/schema/memberof.ldif' \ + -e LDAP_URI=ldap://openldap:1389 \ + -e LDAP_BASE=dc=example,dc=org \ + -e LDAP_ADMIN_USERNAME=admin \ + -e LDAP_ADMIN_PASSWORD=admin_password \ + -e LDAP_EXTRA_SCHEMAS=cosine,inetorgperson,nis,memberof \ + -p 1389:1389 \ + bitnami/openldap:2.6.4 - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev + sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-dev.txt pip install -r requirements-extra.txt - name: Run tests run: | - nosetests --stop -v --with-coverage --cover-package=flask_appbuilder flask_appbuilder.tests --ignore-files="test_mongoengine\.py" + nosetests --stop -v --with-coverage --cover-package=flask_appbuilder tests --ignore-files="test_mongoengine\.py" - name: Upload code coverage run: | bash <(curl -s https://codecov.io/bash) -cF python test-mssql: - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 strategy: matrix: - python-version: [3.7] + python-version: [3.8] env: SQLALCHEMY_DATABASE_URI: | mssql+pyodbc://sa:Password_123@localhost:11433/master?driver=FreeTDS @@ -128,15 +156,27 @@ jobs: ports: - 11433:1433 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Run openldap + run: | + docker run -d \ + -v '${{ github.workspace }}/docker/openldap/ldifs:/ldifs' \ + -v '${{ github.workspace }}/docker/openldap/schemas/memberof.ldif:/opt/bitnami/openldap/etc/schema/memberof.ldif' \ + -e LDAP_URI=ldap://openldap:1389 \ + -e LDAP_BASE=dc=example,dc=org \ + -e LDAP_ADMIN_USERNAME=admin \ + -e LDAP_ADMIN_PASSWORD=admin_password \ + -e LDAP_EXTRA_SCHEMAS=cosine,inetorgperson,nis,memberof \ + -p 1389:1389 \ + bitnami/openldap:2.6.4 - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev freetds-bin unixodbc-dev tdsodbc + sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev freetds-bin unixodbc-dev tdsodbc pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-dev.txt @@ -144,38 +184,51 @@ jobs: sudo cp .github/workflows/odbcinst.ini /etc/odbcinst.ini - name: Run tests run: | - nosetests --stop -v --with-coverage --cover-package=flask_appbuilder flask_appbuilder.tests --ignore-files="test_mongoengine\.py" + nosetests --stop -v --with-coverage --cover-package=flask_appbuilder tests --ignore-files="test_mongoengine\.py" - name: Upload code coverage run: | bash <(curl -s https://codecov.io/bash) -cF python test-mongodb: - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 strategy: matrix: - python-version: [3.6, 3.7] + python-version: [3.7] services: - mssql: + mongo: image: mongo:4.4.1-bionic ports: - 27017:27017 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Run openldap + run: | + docker run -d \ + -v '${{ github.workspace }}/docker/openldap/ldifs:/ldifs' \ + -v '${{ github.workspace }}/docker/openldap/schemas/memberof.ldif:/opt/bitnami/openldap/etc/schema/memberof.ldif' \ + -e LDAP_URI=ldap://openldap:1389 \ + -e LDAP_BASE=dc=example,dc=org \ + -e LDAP_ADMIN_USERNAME=admin \ + -e LDAP_ADMIN_PASSWORD=admin_password \ + -e LDAP_EXTRA_SCHEMAS=cosine,inetorgperson,nis,memberof \ + -p 1389:1389 \ + bitnami/openldap:2.6.4 - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev + sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-dev.txt pip install -r requirements-extra.txt + pip install -r requirements-mongodb.txt - name: Run tests run: | - nosetests --stop -v --with-coverage --cover-package=flask_appbuilder flask_appbuilder/tests/test_mongoengine.py + nosetests --stop -v --with-coverage --cover-package=flask_appbuilder tests/test_mongoengine.py - name: Upload code coverage run: | bash <(curl -s https://codecov.io/bash) -cF python diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000000..39084e0fc5 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,50 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + paths: + - 'flask_appbuilder/**' + pull_request: + # The branches below must be a subset of the branches above + branches: [ "master" ] + paths: + - 'flask_appbuilder/**' + schedule: + - cron: '0 4 * * *' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-22.04 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a8c73791df..db595d49d5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,180 @@ Flask-AppBuilder ChangeLog ========================== +Improvements and Bug fixes on 4.3.7 +----------------------------------- + +- fix: fix: swagger missing nonce (#2116) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.3.6 +----------------------------------- + +- fix: increase email field length (#2102) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.3.5 +----------------------------------- + +- fix: release tests exclusion (#2093) [Daniel Vaz Gaspar] +- fix: make deletion in quicktemplates example work again (#2088) [Fabian Halkivaha] +- fix: MVC form action, broken reset my password (#2091) [Daniel Vaz Gaspar] +- chore: dont add 'tests' package to wheel (#2087) [cwegener] +- chore(deps): bump pygments from 2.13.0 to 2.15.0 (#2089) [dependabot[bot]] + +Improvements and Bug fixes on 4.3.4 +----------------------------------- + +- fix: select filters spacing, theme and operation select (#2079) [Daniel Vaz Gaspar] +- refactor: Refactored logging functions to consistently use lazy interpolation (#2071) [Bruce] +- feat: add optional flask-talisman and use csp nonce on scripts (#2075) [Daniel Vaz Gaspar] +- chore: improve tests and test data load (#2072) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.3.3 +----------------------------------- + +- fix: marshmallow enum by value keep compatibility (#2067) [Daniel Vaz Gaspar] +- fix: marshmallow new min version to 3.18 (#2066) [Daniel Vaz Gaspar] +- fix: select2-ajax-widget (#2052) [Nadir Can Kavkas] +- chore: remove marshmallow-enum dependency (#2064) [Daniel Vaz Gaspar] +- fix: Double escaping for next param in login with oauth (#2053) [Aleksandr Musorin] +- chore: remove RemovedInMarshmallow4 warnings (#2024) [Sebastian Liebscher] +- docs: Update docs/security.rst with Windows LDAP working Example (#2026) [verschlimmbesserer] +- fix(translations): better translation of the pt_BR language (#2061) [Lucas Gonzalez de Queiroz] +- fix: broken link to config.py template (#2056) [Alex Gordienko] +- fix: user registration menu name (#2051) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.3.2 +----------------------------------- + +- fix: CRUD MVC log message (#2045) [Daniel Vaz Gaspar] +- fix: deprecated method for getting value on select2 (#2039) [Viacheslav] +- chore: bump Flask and werkzeug (#2034) [Daniel Vaz Gaspar] +- ci: improve codeql configuration (#2032) [Daniel Vaz Gaspar] +- ci: add codeQL analysis (#2031) [Daniel Vaz Gaspar] +- fix: cli create app ask for initial secret key (#2029) [Daniel Vaz Gaspar] +- fix: using base_filters with FilterEqualFunction not working for relation fields (#2011) [ThomasP0815] +- ci: bump ubuntu version, remove mockldap (#2013) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.3.1 +----------------------------------- + +- fix(mvc): operation filters with new select2 (#2005) [Daniel Vaz Gaspar] +- fix(translations): misspell in ru translations (#2002) [Stepan] + +Improvements and Bug fixes on 4.3.0 +----------------------------------- + +- fix: disable rate limit by default (#1999) [Daniel Vaz Gaspar] +- fix: auth rate limit docs and default rate (#1997) [Daniel Vaz Gaspar] +- feat: Add rate limiter (#1976) [bolkedebruin] +- docs: Updated LDAP Documentation (#1988) [Alissa Gerhard] +- fix: Save next URL on failed login attempt (#1936) [Dosenpfand] +- fix: select2 theme use bootstrap (#1995) [Daniel Vaz Gaspar] +- fix: CI broken by pyodbc vs unixodbc (#1996) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.2.1 +----------------------------------- + +- ci: fix pyodbc install failure (#1992) [Daniel Vaz Gaspar] +- fix: Remove unused parameter from QuerySelectMultipleField instantiation (#1991) [Dosenpfand] +- fix: Make sure user input is not treated as safe in the oauth view (#1978) [Glenn Schuurman] +- fix: don't use root logger on safe decorator (#1990) [Igor Khrol] +- chore: upgrade Font Awesome to version 6 (#1979) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.2.0 +----------------------------------- + +- feat: add opt-in outer default load option to model REST API (#1971) [Daniel Vaz Gaspar] +- chore: Add more type annotation to REST API module (#1969) [Daniel Vaz Gaspar] +- fix: upgrade Select2 to 4.0.13 (#1968) [Nicola Gramola] +- fix: REST API one-to-one relationship (#1965) [Daniel Vaz Gaspar] +- fix(api): _info HTTP 500 when exists a defined invalid search field (#1963) [Daniel Vaz Gaspar] +- chore: Use implicit default loading rather than explicit joined eager loading (#1961) [John Bodley] +- chore: Increase upper-bound on apispec (#1903) [Tomáš Drtina] +- fix: replace deprecated attachment_filename (#1956) [Steve Embling] + +Improvements and Bug fixes on 4.1.6 +----------------------------------- + +- feat: add utility method on SM for fetching all roles and perms for a user (#1950) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.1.5 +----------------------------------- + +- fix: HTML label IDs for db and ldap login (#1935) [Dosenpfand] +- fix: OAuth state parameter (#1932) [Daniel Vaz Gaspar] +- docs: Fix a few typos (#1929) [Tim Gates] +- chore: Update compiled german translation, delete backup file (#1928) [Dosenpfand] +- fix: addon managers import (#1920) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.1.4 +----------------------------------- + +- chore: Redirect to prev url on login for AuthRemoteUserView (#1901) [Alexander Ryndin] +- chore: Bump upper bounds on wtforms and flask-wtf (#1904) [Tomáš Drtina] +- fix(mvc): related model view setting default related field value (#1898) [Daniel Vaz Gaspar] +- fix: DateTimePicker rendering in forms (#1698) [Federico Padua] +- test(fab_cli): tag tests that need internet so they can be skipped (#1880) [jnahmias] +- fix: fix a wrong 'next' URL in javascript (#1897) [Sansarun Sukawongviwat] +- chore: allow authlib > 1 updated docs (#1891) [Daniel Vaz Gaspar] +- docs: fix oauth example config (#1890) [Daniel Vaz Gaspar] +- docs: fix oauth example config (#1889) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.1.3 +----------------------------------- + +- fix: user stats view search (#1887) [Daniel Vaz Gaspar] +- fix: Do not render hidden form fields twice (#1848) [Dosenpfand] +- chore: Bump requirements pillow version, remove PIL from doc (#1873) [Dosenpfand] +- fix: custom menu option (#1884) [Daniel Vaz Gaspar] +- fix: FAB_INDEX_VIEW type check (#1883) [Daniel Vaz Gaspar] +- fix(api): register responses with apispec using components.response() (#1881) [jnahmias] +- docs: add responsible disclosure text to security (#1882) [Daniel Vaz Gaspar] +- chore: Improve german translation (#1872) [Dosenpfand] +- fix: populating permission and vm instead of just setting the id (#1874) [Zef Lin] + +Improvements and Bug fixes on 4.1.2 +----------------------------------- + +- fix: remove sqlite dbs from examples (#1853) [Daniel Vaz Gaspar] +- fix(MVC): discard excluded filters from query (#1862) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.1.1 +----------------------------------- + +- fix: custom security class import, bad cast (#1851) [Daniel Vaz Gaspar] +- fix: Set certificates before reconnecting to LDAP (#1846) [Sebastian Bernauer] + +Improvements and Bug fixes on 4.1.0 +----------------------------------- + +- docs: add FAB_ADD_SECURITY_API config option (#1840) [Daniel Vaz Gaspar] +- feat: add keycloak auth provider options (#1832) [nilivingston] +- docs: add Azure OAUTH example (#1837) [Mathew Wicks] +- fix: security api (#1831) [Daniel Vaz Gaspar] +- fix: dependency constraints, bump flask-login, flask-wtf (#1838) [Daniel Vaz Gaspar] +- fix: noop user update on Auth db, use set user model (#1834) [Daniel Vaz Gaspar] +- chore: bump postgres to 14 (#1833) [Daniel Vaz Gaspar] +- chore: Update and fix german translation (#1827) [Dosenpfand] +- chore: Enhance is_safe_redirect_url (#1826) [Geido] +- feat: Add CRUD apis for role, permission, user (#1801) [Mayur] +- docs: updated brackets in OAuth Authentication (#1798) [David Berg] +- chore: add Slovenian language (#1828) [dkrat7] +- fix: doc requirements (#1820) [Daniel Vaz Gaspar] + +Improvements and Bug fixes on 4.0.0 +----------------------------------- + +- chore: major bumps Flask, Click, PyJWT and flask-jwt-extended (#1817) [Daniel Vaz Gaspar] + [Breaking changes] + +Improvements and Bug fixes on 3.4.5 +----------------------------------- + +- test: Add test for `export-roles --indent`'s argument “duck casting” to int (#1811) [Étienne Boisseau-Sierra] +- fix: next url on login (OAuth, OID, DB) (#1804) [Daniel Vaz Gaspar] +- docs: Update doc i18 to flask_babel (#1792) [Federico Padua] +- feat(cli): allow `export-roles` to be beautified (#1724) [Étienne Boisseau-Sierra] + Improvements and Bug fixes on 3.4.4 ----------------------------------- diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index bb5fdaccc6..170d7b5069 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -28,9 +28,14 @@ can run a subset of tests targeting only Postgres. $ docker-compose up -d - 2 - Run Postgres tests +.. code-block:: bash + + $ nosetests -v + +You can also use tox + .. code-block:: bash $ tox -e postgres @@ -60,24 +65,16 @@ Using Postgres $ export SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://pguser:pguserpassword@0.0.0.0/app -3 - Run the fixtures +4 - To run a single test .. code-block:: bash - $ nosetests -v flask_appbuilder.tests.test_0_fixture - - -4 - Run a single test - -.. code-block:: bash - - $ nosetests -v flask_appbuilder.tests.test_api:APITestCase.test_get_item_dotted_mo_notation - + $ nosetests -v tests.test_api:APITestCase.test_get_item_dotted_mo_notation .. note:: - If your using SQLite3, the location of the db is: ./flask_appbuilder/tests/app.db - You can safely delete it, if you need to delete the fixtures for example. + If your using SQLite3, the location of the db is: ./tests/app.db + You can safely delete it, if you need to delete test data for example. Responsible disclosure of Security Vulnerabilities diff --git a/MANIFEST.in b/MANIFEST.in index 698aa97abd..64e7e951a8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,4 +5,3 @@ prune *.pyc recursive-include flask_appbuilder/static * recursive-include flask_appbuilder/templates * recursive-include flask_appbuilder/translations * -recursive-include flask_appbuilder/tests * diff --git a/README.rst b/README.rst index 7527083749..50dcc4bf95 100644 --- a/README.rst +++ b/README.rst @@ -40,52 +40,6 @@ Change Log `Versions `_ for further detail on what changed. -BREAKING CHANGE on 3.0.0 (OAuth) - -Major version 3, changed it's **OAuth** dependency from flask-oauth to authlib, due to this OAuth configuration -changed: - -Before: - -.. code-block:: - - OAUTH_PROVIDERS = [ - {'name':'google', 'icon':'fa-google', 'token_key':'access_token', - 'remote_app': { - 'consumer_key':'GOOGLE KEY', - 'consumer_secret':'GOOGLE SECRET', - 'base_url':'https://www.googleapis.com/oauth2/v2/', - 'request_token_params':{ - 'scope': 'email profile' - }, - 'request_token_url':None, - 'access_token_url':'https://accounts.google.com/o/oauth2/token', - 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} - } - ] - -Now: - -.. code-block:: - - OAUTH_PROVIDERS = [ - {'name':'google', 'icon':'fa-google', 'token_key':'access_token', - 'remote_app': { - 'client_id':'GOOGLE KEY', - 'client_secret':'GOOGLE SECRET', - 'api_base_url':'https://www.googleapis.com/oauth2/v2/', - 'client_kwargs':{ - 'scope': 'email profile' - }, - 'request_token_url':None, - 'access_token_url':'https://accounts.google.com/o/oauth2/token', - 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} - } - ] - -Also make sure you change your dependency for flask-oauth to `authlib `_ - - Fixes, Bugs and contributions ----------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index bba4582687..900058a4ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.7" services: postgres: container_name: fab-postgres - image: postgres:10 + image: postgres:14 restart: unless-stopped env_file: .env command: postgres -c 'max_connections=500' @@ -20,3 +20,18 @@ services: MONGO_INITDB_DATABASE: app ports: - 27017:27017 + ldap: + container_name: fab-ldap + image: bitnami/openldap:2.6.4 + environment: + LDAP_URI: ldap://openldap:1389 + LDAP_BASE: dc=example,dc=org + LDAP_ADMIN_USERNAME: admin + LDAP_ADMIN_PASSWORD: admin_password + LDAP_CUSTOM_LDIF_DIR: /ldifs + LDAP_EXTRA_SCHEMAS: cosine,inetorgperson,nis,memberof + volumes: + - './docker/openldap/ldifs:/ldifs' + - './docker/openldap/schemas/memberof.ldif:/opt/bitnami/openldap/etc/schema/memberof.ldif' + ports: + - 1389:1389 diff --git a/docker/openldap/ldifs/users.ldif b/docker/openldap/ldifs/users.ldif new file mode 100644 index 0000000000..e23dad564b --- /dev/null +++ b/docker/openldap/ldifs/users.ldif @@ -0,0 +1,68 @@ +# extended LDIF +# +# LDAPv3 +# base with scope subtree +# filter: (objectclass=*) +# requesting: ALL +# + +# example.org +dn: dc=example,dc=org +objectClass: dcObject +objectClass: organization +dc: example +o: example + +# users, example.org +dn: ou=users,dc=example,dc=org +objectClass: organizationalUnit +ou: users + +# users, example.org +dn: ou=groups,dc=example,dc=org +objectClass: organizationalUnit +ou: groups + +# alice, users, example.org +dn: cn=alice,ou=users,dc=example,dc=org +cn: alice +sn: Doe +givenName: Alice +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: shadowAccount +userPassword: alice_password +uid: alice +uidNumber: 1000 +gidNumber: 1000 +mail: alice@example.org +homeDirectory: /home/alice + +# natalie, users, example.org +dn: cn=natalie,ou=users,dc=example,dc=org +cn: natalie +sn: Smith +givenName: Natalie +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: shadowAccount +userPassword: natalie_password +uid: natalie +uidNumber: 1001 +gidNumber: 1001 +mail: natalie@example.org +homeDirectory: /home/natalie + +# readers, users, example.org +dn: cn=readers,ou=groups,dc=example,dc=org +cn: readers +objectClass: groupOfNames +objectClass: top +member: cn=alice,ou=users,dc=example,dc=org +member: cn=natalie,ou=users,dc=example,dc=org + +dn: cn=staff,ou=groups,dc=example,dc=org +cn: staff +objectClass: groupOfNames +objectClass: top +member: cn=alice,ou=users,dc=example,dc=org diff --git a/docker/openldap/schemas/memberof.ldif b/docker/openldap/schemas/memberof.ldif new file mode 100644 index 0000000000..f3274008b4 --- /dev/null +++ b/docker/openldap/schemas/memberof.ldif @@ -0,0 +1,19 @@ +dn: cn=module,cn=config +cn: module +objectClass: olcModuleList +olcModulePath: /opt/bitnami/openldap/lib/openldap +olcModuleLoad: memberof.so +olcModuleLoad: refint.so + +dn: olcOverlay=memberof,olcDatabase={2}mdb,cn=config +objectClass: olcMemberOf +objectClass: olcOverlayConfig +olcOverlay: memberof + +dn: olcOverlay=refint,olcDatabase={2}mdb,cn=config +objectClass: olcConfig +objectClass: olcOverlayConfig +objectClass: olcRefintConfig +objectClass: top +olcOverlay: refint +olcRefintAttribute: memberof member manager owner \ No newline at end of file diff --git a/docs/advanced.rst b/docs/advanced.rst index a2b6aa1b52..642a315a9a 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -134,7 +134,7 @@ for example a confirmation field:: class ContactModelView(ModelView): datamodel = SQLAInterface(Contact) add_form_extra_fields = { - 'extra': TextField(gettext('Extra Field'), + 'extra': StringField(gettext('Extra Field'), description=gettext('Extra Field description'), widget=BS3TextFieldWidget()) } @@ -158,7 +158,7 @@ Next override your field using your new widget:: class ExampleView(ModelView): datamodel = SQLAInterface(ExampleModel) edit_form_extra_fields = { - 'field2': TextField('field2', widget=BS3TextFieldROWidget()) + 'field2': StringField('field2', widget=BS3TextFieldROWidget()) } Readonly select fields are a special case, but it's solved in a simpler way:: @@ -177,7 +177,7 @@ Readonly select fields are a special case, but it's solved in a simpler way:: edit_form_extra_fields = { 'department': QuerySelectField( 'Department', - query_factory=department_query, + query_func=department_query, widget=Select2Widget(extra_classes="readonly") ) } diff --git a/docs/breaking.rst b/docs/breaking.rst new file mode 100644 index 0000000000..f20e662630 --- /dev/null +++ b/docs/breaking.rst @@ -0,0 +1,86 @@ +BREAKING CHANGES +================ + +Version 4.0.0 +------------- + +- Drops python 3.6 support +- Removed config key `AUTH_STRICT_RESPONSE_CODES`, it's always strict now. +- Removes `Flask-OpenID` dependency (you can install it has an extra dependency `pip install flask-appbuilder[openid]`) +- Major version bumps on following packages + +**Flask from 1.X to 2.X** + +Breaking changes: https://flask.palletsprojects.com/en/2.0.x/changes/#version-2-0-0 + +**flask-jwt-extended 3.X to 4.X:** + +Breaking changes: https://flask-jwt-extended.readthedocs.io/en/stable/v4_upgrade_guide/ + +**Jinja2 2.X to 3.X** + +Breaking changes: https://jinja.palletsprojects.com/en/3.0.x/changes/#version-3-0-0 + +**Werkzeug 1.X to 2.X** + +https://werkzeug.palletsprojects.com/en/2.0.x/changes/#version-2-0-0 + +The following packages are probably not impactful to you: + +**pyJWT 1.X to 2.X:** + +Breaking changes: https://pyjwt.readthedocs.io/en/stable/changelog.html#v2-0-0 + +**Click 7.X to 8.X:** + +Breaking changes: https://click.palletsprojects.com/en/8.0.x/changes/#version-8-0-0 + +**itsdangerous 1.X to 2.X** + +Breaking changes: https://github.com/pallets/itsdangerous/blob/main/CHANGES.rst#version-200 + +Version 3.0.0 (OAuth) +--------------------- + +Major version 3, changed it's **OAuth** dependency from flask-oauth to authlib, due to this OAuth configuration +changed: + +Before: + +.. code-block:: + + OAUTH_PROVIDERS = [ + {'name':'google', 'icon':'fa-google', 'token_key':'access_token', + 'remote_app': { + 'consumer_key':'GOOGLE KEY', + 'consumer_secret':'GOOGLE SECRET', + 'base_url':'https://www.googleapis.com/oauth2/v2/', + 'request_token_params':{ + 'scope': 'email profile' + }, + 'request_token_url':None, + 'access_token_url':'https://accounts.google.com/o/oauth2/token', + 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} + } + ] + +Now: + +.. code-block:: + + OAUTH_PROVIDERS = [ + {'name':'google', 'icon':'fa-google', 'token_key':'access_token', + 'remote_app': { + 'client_id':'GOOGLE KEY', + 'client_secret':'GOOGLE SECRET', + 'api_base_url':'https://www.googleapis.com/oauth2/v2/', + 'client_kwargs':{ + 'scope': 'email profile' + }, + 'request_token_url':None, + 'access_token_url':'https://accounts.google.com/o/oauth2/token', + 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} + } + ] + +Also make sure you change your dependency for flask-oauth to `authlib `_ diff --git a/docs/config.rst b/docs/config.rst index ca109de25d..faa060b3ab 100755 --- a/docs/config.rst +++ b/docs/config.rst @@ -13,6 +13,11 @@ Use config.py to configure the following parameters. By default it will use SQLL +========================================+============================================+===========+ | SQLALCHEMY_DATABASE_URI | DB connection string (flask-sqlalchemy) | Cond. | +----------------------------------------+--------------------------------------------+-----------+ +| SECRET_KEY | Flask secret key used for securely signing | | +| | the session cookie Set the secret_key on | | +| | the application to something unique and | | +| | secret. | Yes | ++----------------------------------------+--------------------------------------------+-----------+ | MONGODB_SETTINGS | DB connection string (flask-mongoengine) | Cond. | +----------------------------------------+--------------------------------------------+-----------+ | AUTH_TYPE = 0 | 1 | 2 | 3 | 4 | This is the authentication type | Yes | @@ -56,6 +61,12 @@ Use config.py to configure the following parameters. By default it will use SQLL | | AUTH_TYPE = 2 | | | | | | | | AUTH_LDAP_SERVER = "ldap://ldapserver.new" | | +| | | | +| | For using LDAP over TLS, set the protocol | | +| | scheme to "ldaps" and set | | +| | "AUTH_LDAP_USE_TLS = False" | | ++----------------------------------------+--------------------------------------------+-----------+ +| AUTH_LDAP_USE_TLS | Require the use of STARTTLS | | +----------------------------------------+--------------------------------------------+-----------+ | AUTH_LDAP_BIND_USER | Define the DN for the user that will be | No | | | used for the initial LDAP BIND. | | @@ -72,10 +83,11 @@ Use config.py to configure the following parameters. By default it will use SQLL | | (Bool) | | +----------------------------------------+--------------------------------------------+-----------+ | AUTH_LDAP_TLS_CACERTDIR | CA Certificate directory to check peer | No | -| | certificate | | +| | certificate. Certificate files must be | | +| | PEM-encoded | | +----------------------------------------+--------------------------------------------+-----------+ | AUTH_LDAP_TLS_CACERTFILE | CA Certificate file to check peer | No | -| | certificate | | +| | certificate. File must be PEM-encoded | | +----------------------------------------+--------------------------------------------+-----------+ | AUTH_LDAP_TLS_CERTFILE | Certificate file for client auth | No | | | use with AUTH_LDAP_TLS_KEYFILE | | @@ -202,11 +214,6 @@ Use config.py to configure the following parameters. By default it will use SQLL | AUTH_ROLE_PUBLIC | Special Role that holds the public | No | | | permissions, no authentication needed. | | +----------------------------------------+--------------------------------------------+-----------+ -| AUTH_STRICT_RESPONSE_CODES | When True, protected endpoints will return | No | -| | HTTP 403 instead of 401. This option will | | -| | be removed and default to True on the next | | -| | major release. defaults to False | | -+----------------------------------------+--------------------------------------------+-----------+ | AUTH_API_LOGIN_ALLOW_MULTIPLE_PROVIDERS| Allow REST API login with alternative auth | No | | True|False | providers (default False) | | +----------------------------------------+--------------------------------------------+-----------+ @@ -262,6 +269,13 @@ Use config.py to configure the following parameters. By default it will use SQLL | FAB_SECURITY_MANAGER_CLASS | Declare a new custom SecurityManager | | | | class | No | +----------------------------------------+--------------------------------------------+-----------+ +| FAB_ADD_SECURITY_API | [Beta] Adds a CRUD REST API for users, | | +| | roles, permissions, view_menus. | No | +| | Further details on /swagger/v1 | | +| | All endpoints are under /api/v1/sercurity/ | | +| | [Note]: This feature is still in beta | | +| | breaking changes are likely to occur | | ++----------------------------------------+--------------------------------------------+-----------+ | FAB_ADD_SECURITY_VIEWS | Enables or disables registering all | | | | security views (boolean default:True) | No | +----------------------------------------+--------------------------------------------+-----------+ @@ -307,6 +321,15 @@ Use config.py to configure the following parameters. By default it will use SQLL | | Default is False. | | +----------------------------------------+--------------------------------------------+-----------+ +Note +---- + +Make sure you set your own `SECRET_KEY` to something unique and secret. This secret key is used by Flask for +securely signing the session cookie and can be used for any other security related needs by extensions or your application. +It should be a long random bytes or str. For example, copy the output of this to your config:: + + $ python -c 'import secrets; print(secrets.token_hex())' + '192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf' Using config.py --------------- @@ -320,7 +343,7 @@ Next you only have to import them to the Flask app object, like this app = Flask(__name__) app.config.from_object('config') -Take a look at the skeleton `config.py `_ +Take a look at the skeleton `config.py `_ .. _jmespath-examples: @@ -342,4 +365,4 @@ causes users 1 and 2 to be registered with role ``Admin`` and rest with the role JMESPath expression allow more groups to be evaluated: ``email == 'user1@domain.com' && 'Admin' || (email == 'user2@domain.com' && 'Op' || 'Viewer')`` -For more example, see `specification `_. \ No newline at end of file +For more example, see `specification `_. diff --git a/docs/i18n.rst b/docs/i18n.rst index 25207f0765..c60fece17d 100644 --- a/docs/i18n.rst +++ b/docs/i18n.rst @@ -4,7 +4,7 @@ i18n Translations Introduction ------------ -F.A.B. has support for 14 languages (planning for some more): +F.A.B. has support for 15 languages (planning for some more): - Chinese - Dutch - English @@ -15,6 +15,7 @@ F.A.B. has support for 14 languages (planning for some more): - Portuguese - Portuguese Brazil - Russian + - Slovenian - Spanish - Greek - Korean diff --git a/docs/index.rst b/docs/index.rst index 453c397485..4b2c6699fe 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -70,7 +70,7 @@ Contents: diagrams api versionmigration - + breaking Indices and tables ================== diff --git a/docs/installation.rst b/docs/installation.rst index 19935f2439..5c486e4fee 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -114,7 +114,7 @@ Installation Requirements pip installs all the requirements for you. -Flask App Builder dependes on +Flask App Builder depends on - flask : The web framework, this is what we're extending. - flask-sqlalchemy : DB access (see SQLAlchemy). @@ -123,14 +123,9 @@ Flask App Builder dependes on - flask-wtform : Web forms. - flask-Babel : For internationalization. -If you plan to use Image processing or upload, you will need to install PIL:: - - pip install pillow - -or:: - - pip install PIL +If you plan to use Image processing or upload, you will need to install Pillow:: + pip install Pillow Python 2 and 3 Compatibility ---------------------------- diff --git a/docs/rest_api.rst b/docs/rest_api.rst index d89e3d5248..2887b3a4ba 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -1485,20 +1485,22 @@ Enum Fields ``ModelRestApi`` offers support for **Enum** fields, you have to declare them on a specific way:: - class GenderEnum(enum.Enum): - male = 'Male' - female = 'Female' + class BookType(enum.Enum): + HARDCOVER = 1 + PAPERBACK = 2 + EBOOK = 3 - class Contact(Model): + class Book(Model): id = Column(Integer, primary_key=True) - name = Column(String(150), unique=True, nullable=False) - address = Column(String(564)) - birthday = Column(Date, nullable=True) - personal_phone = Column(String(20)) - personal_celphone = Column(String(20)) - contact_group_id = Column(Integer, ForeignKey('contact_group.id'), nullable=False) - contact_group = relationship("ContactGroup") - gender = Column(Enum(GenderEnum), nullable=False, info={"enum_class": GenderEnum}) + title = Column(String(150), unique=True, nullable=False) + type = Column(Enum(BookType), nullable=False) + +Default marshmallow behaviour for enums is to return the value of the enum, in this case an integer. +If you want to return the string representation of the enum you can set this behaviour at the Model definition:: + + class Book(Model): + id = Column(Integer, primary_key=True) + title = Column(String(150), unique=True, nullable=False) + type = Column(Enum(BookType), nullable=False, info={"marshmallow_enum": {"by_value": False}}) -Notice the ``info={"enum_class": GenderEnum}`` diff --git a/docs/security.rst b/docs/security.rst index 54cae1bb02..c5ef982dc7 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -1,6 +1,12 @@ Security ======== +Responsible disclosure +---------------------- + +We want to keep Flask-AppBuilder safe for everyone. If you've discovered a security vulnerability +please report to danielvazgaspar@gmail.com. + Supported Authentication Types ------------------------------ @@ -134,6 +140,12 @@ You can limit the LDAP search scope by configuring:: You can give FlaskAppBuilder roles based on LDAP roles (note, this requires AUTH_LDAP_SEARCH to be set):: # a mapping from LDAP DN to a list of FAB roles + AUTH_ROLES_MAPPING = { + "CN=fab_users,OU=groups,DC=example,DC=com": ["User"], + "CN=fab_admins,OU=groups,DC=example,DC=com": ["Admin"], + } + + # a mapping from OpenLDAP DN to a list of FAB roles AUTH_ROLES_MAPPING = { "cn=fab_users,ou=groups,dc=example,dc=com": ["User"], "cn=fab_admins,ou=groups,dc=example,dc=com": ["Admin"], @@ -148,6 +160,21 @@ You can give FlaskAppBuilder roles based on LDAP roles (note, this requires AUTH # force users to re-auth after 30min of inactivity (to keep roles in sync) PERMANENT_SESSION_LIFETIME = 1800 +TLS +~~~ + +For STARTTLS, configure an `ldap://` server and set `AUTH_LDAP_USE_TLS` to `True`:: + + AUTH_LDAP_SERVER = "ldap://ldap.example.com" + AUTH_LDAP_USE_TLS = True + +For LDAP over TLS (ldaps), configure the server with the `ldaps://` scheme and set `AUTH_LDAP_USE_TLS` to `False`:: + + AUTH_LDAP_SERVER = "ldaps://ldap.example.com" + AUTH_LDAP_USE_TLS = False + +Additional LDAP/TLS Options, including CA certificate settings and client authentication, can be found in the :doc:`config`. + Authentication: OAuth --------------------- @@ -165,74 +192,132 @@ Specify a list of OAUTH_PROVIDERS in **config.py** that you want to allow for yo # the list of providers which the user can choose from OAUTH_PROVIDERS = [ - {'name':'twitter', 'icon':'fa-twitter', - 'token_key':'oauth_token', - 'remote_app': { - 'client_id':'TWITTER_KEY', - 'client_secret':'TWITTER_SECRET', - 'api_base_url':'https://api.twitter.com/1.1/', - 'request_token_url':'https://api.twitter.com/oauth/request_token', - 'access_token_url':'https://api.twitter.com/oauth/access_token', - 'authorize_url':'https://api.twitter.com/oauth/authenticate'} + { + "name": "twitter", + "icon": "fa-twitter", + "token_key": "oauth_token", + "remote_app": { + "client_id": "TWITTER_KEY", + "client_secret": "TWITTER_SECRET", + "api_base_url": "https://api.twitter.com/1.1/", + "request_token_url": "https://api.twitter.com/oauth/request_token", + "access_token_url": "https://api.twitter.com/oauth/access_token", + "authorize_url": "https://api.twitter.com/oauth/authenticate", + }, }, - {'name':'google', 'icon':'fa-google', - 'token_key':'access_token', - 'remote_app': { - 'client_id':'GOOGLE_KEY', - 'client_secret':'GOOGLE_SECRET', - 'api_base_url':'https://www.googleapis.com/oauth2/v2/', - 'client_kwargs':{ - 'scope': 'email profile' - }, - 'request_token_url':None, - 'access_token_url':'https://accounts.google.com/o/oauth2/token', - 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} + { + "name": "google", + "icon": "fa-google", + "token_key": "access_token", + "remote_app": { + "client_id": "GOOGLE_KEY", + "client_secret": "GOOGLE_SECRET", + "api_base_url": "https://www.googleapis.com/oauth2/v2/", + "client_kwargs": {"scope": "email profile"}, + "request_token_url": None, + "access_token_url": "https://accounts.google.com/o/oauth2/token", + "authorize_url": "https://accounts.google.com/o/oauth2/auth", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", + }, + }, + { + "name": "openshift", + "icon": "fa-circle-o", + "token_key": "access_token", + "remote_app": { + "client_id": "system:serviceaccount:mynamespace:mysa", + "client_secret": "", + "api_base_url": "https://openshift.default.svc.cluster.local:443", + "client_kwargs": {"scope": "user:info"}, + "redirect_uri": "https://myapp-mynamespace.apps.", + "access_token_url": "https://oauth-openshift.apps./oauth/token", + "authorize_url": "https://oauth-openshift.apps./oauth/authorize", + "token_endpoint_auth_method": "client_secret_post", + }, }, - {'name':'openshift', 'icon':'fa-circle-o', - 'token_key':'access_token', - 'remote_app': { - 'client_id':'system:serviceaccount:mynamespace:mysa', - 'client_secret':'', - 'api_base_url':'https://openshift.default.svc.cluster.local:443', - 'client_kwargs':{ - 'scope': 'user:info' + { + "name": "okta", + "icon": "fa-circle-o", + "token_key": "access_token", + "remote_app": { + "client_id": "OKTA_KEY", + "client_secret": "OKTA_SECRET", + "api_base_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/", + "client_kwargs": {"scope": "openid profile email groups"}, + "access_token_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/token", + "authorize_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/authorize", + "server_metadata_url": f"https://OKTA_DOMAIN.okta.com/.well-known/openid-configuration", + }, + }, + { + "name": "aws_cognito", + "icon": "fa-amazon", + "token_key": "access_token", + "remote_app": { + "client_id": "COGNITO_CLIENT_ID", + "client_secret": "COGNITO_CLIENT_SECRET", + "api_base_url": "https://COGNITO_APP.auth.REGION.amazoncognito.com/", + "client_kwargs": {"scope": "openid email aws.cognito.signin.user.admin"}, + "access_token_url": "https://COGNITO_APP.auth.REGION.amazoncognito.com/token", + "authorize_url": "https://COGNITO_APP.auth.REGION.amazoncognito.com/authorize", + }, + }, + { + "name": "keycloak", + "icon": "fa-key", + "token_key": "access_token", + "remote_app": { + "client_id": "KEYCLOAK_CLIENT_ID", + "client_secret": "KEYCLOAK_CLIENT_SECRET", + "api_base_url": "https://KEYCLOAK_DOMAIN/realms/master/protocol/openid-connect", + "client_kwargs": { + "scope": "email profile" }, - 'redirect_uri':'https://myapp-mynamespace.apps.', - 'access_token_url':'https://oauth-openshift.apps./oauth/token', - 'authorize_url':'https://oauth-openshift.apps./oauth/authorize', - 'token_endpoint_auth_method':'client_secret_post'} + "access_token_url": "KEYCLOAK_DOMAIN/realms/master/protocol/openid-connect/token", + "authorize_url": "KEYCLOAK_DOMAIN/realms/master/protocol/openid-connect/auth", + "request_token_url": None, + }, }, - {'name': 'okta', 'icon': 'fa-circle-o', - 'token_key': 'access_token', - 'remote_app': { - 'client_id': 'OKTA_KEY', - 'client_secret': 'OKTA_SECRET', - 'api_base_url': 'https://OKTA_DOMAIN.okta.com/oauth2/v1/', - 'client_kwargs': { - 'scope': 'openid profile email groups' + { + "name": "keycloak_before_17", + "icon": "fa-key", + "token_key": "access_token", + "remote_app": { + "client_id": "KEYCLOAK_CLIENT_ID", + "client_secret": "KEYCLOAK_CLIENT_SECRET", + "api_base_url": "https://KEYCLOAK_DOMAIN/auth/realms/master/protocol/openid-connect", + "client_kwargs": { + "scope": "email profile" }, - 'access_token_url': 'https://OKTA_DOMAIN.okta.com/oauth2/v1/token', - 'authorize_url': 'https://OKTA_DOMAIN.okta.com/oauth2/v1/authorize', + "access_token_url": "KEYCLOAK_DOMAIN/auth/realms/master/protocol/openid-connect/token", + "authorize_url": "KEYCLOAK_DOMAIN/auth/realms/master/protocol/openid-connect/auth", + "request_token_url": None, + }, }, - {'name': 'aws_cognito', 'icon': 'fa-amazon', - 'token_key': 'access_token', - 'remote_app': { - 'client_id': 'COGNITO_CLIENT_ID', - 'client_secret': 'COGNITO_CLIENT_SECRET', - 'api_base_url': 'https://COGNITO_APP.auth.REGION.amazoncognito.com/', - 'client_kwargs': { - 'scope': 'openid email aws.cognito.signin.user.admin' + { + "name": "azure", + "icon": "fa-windows", + "token_key": "access_token", + "remote_app": { + "client_id": "AZURE_APPLICATION_ID", + "client_secret": "AZURE_SECRET", + "api_base_url": "https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2", + "client_kwargs": { + "scope": "User.read name preferred_username email profile upn", + "resource": "AZURE_APPLICATION_ID", }, - 'access_token_url': 'https://COGNITO_APP.auth.REGION.amazoncognito.com/token', - 'authorize_url': 'https://COGNITO_APP.auth.REGION.amazoncognito.com/authorize', - } + "request_token_url": None, + "access_token_url": "https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/token", + "authorize_url": "https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/authorize", + }, + }, ] This needs a small explanation, you basically have five special keys: :name: the name of the provider: you can choose whatever you want, but FAB has builtin logic in `BaseSecurityManager.get_oauth_user_info()` for: - 'azure', 'github', 'google', 'linkedin', 'okta', 'openshift', 'twitter' + 'azure', 'github', 'google', 'keycloak', 'keycloak_before_17', 'linkedin', 'okta', 'openshift', 'twitter' :icon: the font-awesome icon for this provider @@ -310,6 +395,16 @@ Therefore, you can send tweets, post on the users Facebook, retrieve the user's Take a look at the `example `_ to get an idea of a simple use for this. +Authentication: Rate limiting +----------------------------- + +To prevent brute-forcing of credentials, you can apply rate limits to AuthViews in 4.2.0, so that +only 10 POST requests can be made every 20 seconds. This can be enabled by setting +``AUTH_RATE_LIMITED`` and ``RATELIMIT_ENABLED`` to ``True``. +The rate can be changed by adjusting ``AUTH_RATE_LIMIT`` to, for example, ``1 per 10 seconds``. Take a look +at the `documentation `_ of Flask-Limiter for more options and +examples. + Role based ---------- @@ -843,3 +938,23 @@ Some images: .. image:: ./images/security.png :width: 100% + +Optional dependency Flask-Talisman +================================== + +All javascript code and inline scripts can have a nonce attribute provided by Flask-Talisman. +This package will not initialize Flask-Talisman for you, but will use `csp_nonce()` on Jinja2 if it exists. +To initialize Flask-Talisman, you can do the following: + +.. code-block:: python + + from flask import Flask + from flask_appbuilder import AppBuilder + from flask_talisman import Talisman + + app = Flask(__name__) + app.config.from_object('config') + db = SQLA(app) + appbuilder = AppBuilder(app, db.session) + + Talisman(app) diff --git a/examples/crud_rest_api/app/api.py b/examples/crud_rest_api/app/api.py index be167bfa2c..7eea760656 100644 --- a/examples/crud_rest_api/app/api.py +++ b/examples/crud_rest_api/app/api.py @@ -5,22 +5,11 @@ from sqlalchemy import or_ from . import appbuilder, db -from .models import Contact, ContactGroup, Gender, ModelOMParent +from .models import Contact, ContactGroup, ModelOMParent from marshmallow import fields, Schema -def fill_gender(): - try: - db.session.add(Gender(name="Male")) - db.session.add(Gender(name="Female")) - db.session.add(Gender(name="Nonbinary")) - db.session.commit() - except Exception: - db.session.rollback() - - db.create_all() -fill_gender() class GreetingsResponseSchema(Schema): @@ -32,14 +21,10 @@ class GreetingApi(BaseApi): openapi_spec_component_schemas = (GreetingsResponseSchema,) openapi_spec_methods = { - "greeting": { - "get": { - "description": "Override description", - } - } + "greeting": {"get": {"description": "Override description"}} } - @expose('/') + @expose("/") def greeting(self): """Send a greeting --- @@ -65,10 +50,7 @@ class CustomFilter(BaseFilter): def apply(self, query, value): return query.filter( - or_( - Contact.name.like(value + "%"), - Contact.address.like(value + "%"), - ) + or_(Contact.name.like(value + "%"), Contact.address.like(value + "%")) ) @@ -79,11 +61,7 @@ class ContactModelApi(ModelRestApi): search_filters = {"name": [CustomFilter]} openapi_spec_methods = { - "get_list": { - "get": { - "description": "Get all contacts, filter and pagination", - } - } + "get_list": {"get": {"description": "Get all contacts, filter and pagination"}} } diff --git a/examples/crud_rest_api/app/models.py b/examples/crud_rest_api/app/models.py index 774ddf03d8..c2e3af0151 100644 --- a/examples/crud_rest_api/app/models.py +++ b/examples/crud_rest_api/app/models.py @@ -1,7 +1,8 @@ import datetime +import enum from flask_appbuilder import Model -from sqlalchemy import Column, Date, ForeignKey, Integer, String +from sqlalchemy import Column, Date, ForeignKey, Integer, String, Enum from sqlalchemy.orm import relationship, backref mindate = datetime.date(datetime.MINYEAR, 1, 1) @@ -15,12 +16,9 @@ def __repr__(self): return self.name -class Gender(Model): - id = Column(Integer, primary_key=True) - name = Column(String(50), unique=True, nullable=False) - - def __repr__(self): - return self.name +class Gender(enum.Enum): + Female = 1 + Male = 2 class Contact(Model): @@ -32,8 +30,7 @@ class Contact(Model): personal_celphone = Column(String(20)) contact_group_id = Column(Integer, ForeignKey("contact_group.id"), nullable=False) contact_group = relationship("ContactGroup") - gender_id = Column(Integer, ForeignKey("gender.id"), nullable=False) - gender = relationship("Gender") + gender = Column(Enum(Gender), info={"marshmallow_by_value": False}) def __repr__(self): return self.name diff --git a/examples/crud_rest_api/config.py b/examples/crud_rest_api/config.py index f73635595a..7a5da5c67e 100644 --- a/examples/crud_rest_api/config.py +++ b/examples/crud_rest_api/config.py @@ -2,6 +2,7 @@ basedir = os.path.abspath(os.path.dirname(__file__)) +FAB_ADD_SECURITY_API = True CSRF_ENABLED = True SECRET_KEY = "\2\1thisismyscretkey\1\2\e\y\y\h" @@ -68,11 +69,10 @@ # APP_THEME = "solar.css" # APP_THEME = "superhero.css" -#FAB_ROLES = { +# FAB_ROLES = { # "ReadOnly": [ # [".*", "can_list"], # [".*", "can_show"], # [".*", "menu_access"] # ] -#} - +# } diff --git a/examples/crud_rest_api/testdata.py b/examples/crud_rest_api/testdata.py index e04e5ad34c..aca4055d3d 100644 --- a/examples/crud_rest_api/testdata.py +++ b/examples/crud_rest_api/testdata.py @@ -18,7 +18,6 @@ def get_random_name(names_list, size=1): try: db.session.query(Contact).delete() - db.session.query(Gender).delete() db.session.query(ContactGroup).delete() db.session.commit() except Exception: @@ -38,17 +37,6 @@ def get_random_name(names_list, size=1): log.error("Creating Groups: %s", e) db.session.rollback() -try: - genders = list() - genders.append(Gender(name="Male")) - genders.append(Gender(name="Female")) - db.session.add(genders[0]) - db.session.add(genders[1]) - db.session.commit() -except Exception as e: - log.error("Creating Genders: %s", e) - db.session.rollback() - model_oo_parents = list() for i in range(20): @@ -81,7 +69,7 @@ def get_random_name(names_list, size=1): c.personal_phone = random.randrange(1111111, 9999999) c.personal_celphone = random.randrange(1111111, 9999999) c.contact_group = groups[random.randrange(0, 3)] - c.gender = genders[random.randrange(0, 2)] + c.gender = random.choice(list(Gender)) year = random.choice(range(1900, 2012)) month = random.choice(range(1, 12)) day = random.choice(range(1, 28)) diff --git a/examples/employees/app/views.py b/examples/employees/app/views.py index 6a71cf9505..420272d9b4 100644 --- a/examples/employees/app/views.py +++ b/examples/employees/app/views.py @@ -1,7 +1,7 @@ from flask_appbuilder import ModelView +from flask_appbuilder.fields import QuerySelectField from flask_appbuilder.fieldwidgets import Select2Widget from flask_appbuilder.models.sqla.interface import SQLAInterface -from wtforms.ext.sqlalchemy.fields import QuerySelectField from . import appbuilder, db from .models import Benefit, Department, Employee, EmployeeHistory, Function @@ -24,7 +24,7 @@ class EmployeeView(ModelView): edit_form_extra_fields = { "department": QuerySelectField( "Department", - query_factory=department_query, + query_func=department_query, widget=Select2Widget(extra_classes="readonly"), ) } diff --git a/examples/extendsecurity/testdata.py b/examples/extendsecurity/testdata.py index e0cfd24848..6d2c7518a4 100644 --- a/examples/extendsecurity/testdata.py +++ b/examples/extendsecurity/testdata.py @@ -2,8 +2,8 @@ import logging import random -from .app import appbuilder, db, create_app -from .app.models import ContactGroup, Gender, Contact, Company +from app import appbuilder, db, create_app +from app.models import ContactGroup, Gender, Contact, Company log = logging.getLogger(__name__) diff --git a/examples/factoryapp/README.rst b/examples/factoryapp/README.rst index 57af1a2e99..1bbfa600ed 100644 --- a/examples/factoryapp/README.rst +++ b/examples/factoryapp/README.rst @@ -9,7 +9,7 @@ Create an Admin user and insert test data:: Run it:: - $ export FLASK_APP="app:create_app('config')" + $ export FLASK_APP="app.app:create_app('config')" $ flask fab create-admin $ flask run diff --git a/examples/factoryapp/app/__init__.py b/examples/factoryapp/app/__init__.py index 8eba7004c6..e69de29bb2 100644 --- a/examples/factoryapp/app/__init__.py +++ b/examples/factoryapp/app/__init__.py @@ -1,24 +0,0 @@ -import logging - -from flask import Flask -from flask_appbuilder import AppBuilder, SQLA - -logging.basicConfig(format="%(asctime)s:%(levelname)s:%(name)s:%(message)s") -logging.getLogger().setLevel(logging.DEBUG) - -db = SQLA() -appbuilder = AppBuilder() - - -def create_app(config): - app = Flask(__name__) - with app.app_context(): - app.config.from_object(config) - db.init_app(app) - appbuilder.init_app(app, db.session) - from . import views # noqa - - db.create_all() - appbuilder.post_init() - views.fill_gender() - return app diff --git a/examples/factoryapp/app/app.py b/examples/factoryapp/app/app.py new file mode 100644 index 0000000000..a573509e22 --- /dev/null +++ b/examples/factoryapp/app/app.py @@ -0,0 +1,36 @@ +import logging + +from flask import Flask +from flask_appbuilder import AppBuilder, SQLA + +logging.basicConfig(format="%(asctime)s:%(levelname)s:%(name)s:%(message)s") +logging.getLogger().setLevel(logging.DEBUG) + + +def create_app(config): + app = Flask(__name__) + db = SQLA() + appbuilder = AppBuilder() + with app.app_context(): + app.config.from_object(config) + db.init_app(app) + appbuilder.init_app(app, db.session) + + from .views import ContactModelView, GroupModelView, fill_gender + + appbuilder.add_view( + ContactModelView, + "List Contacts", + icon="fa-envelope", + category="Contacts", + category_icon="fa-envelope", + ) + + appbuilder.add_view( + GroupModelView, "List Groups", icon="fa-folder-open-o", category="Contacts" + ) + + db.create_all() + appbuilder.post_init() + fill_gender() + return app diff --git a/examples/factoryapp/app/views.py b/examples/factoryapp/app/views.py index eb5751eac3..69e49f5a22 100644 --- a/examples/factoryapp/app/views.py +++ b/examples/factoryapp/app/views.py @@ -1,17 +1,17 @@ -from flask_appbuilder import ModelView +from flask import current_app +from flask_appbuilder import ModelView, IndexView from flask_appbuilder.models.sqla.interface import SQLAInterface -from . import appbuilder, db from .models import Contact, ContactGroup, Gender def fill_gender(): try: - db.session.add(Gender(name="Male")) - db.session.add(Gender(name="Female")) - db.session.commit() + current_app.appbuilder.session.add(Gender(name="Male")) + current_app.appbuilder.session.add(Gender(name="Female")) + current_app.appbuilder.session.commit() except Exception: - db.session.rollback() + current_app.appbuilder.session.rollback() class ContactModelView(ModelView): @@ -71,20 +71,10 @@ class ContactModelView(ModelView): ] -appbuilder.add_view( - ContactModelView, - "List Contacts", - icon="fa-envelope", - category="Contacts", - category_icon="fa-envelope", -) - - class GroupModelView(ModelView): datamodel = SQLAInterface(ContactGroup) related_views = [ContactModelView] -appbuilder.add_view( - GroupModelView, "List Groups", icon="fa-folder-open-o", category="Contacts" -) +class MyIndexView(IndexView): + index_template = "my_index.html" diff --git a/examples/factoryapp/config.py b/examples/factoryapp/config.py index 12f98beadc..ee6285554c 100644 --- a/examples/factoryapp/config.py +++ b/examples/factoryapp/config.py @@ -18,6 +18,8 @@ # SQLALCHEMY_DATABASE_URI = 'postgresql://scott:tiger@localhost:5432/myapp' # SQLALCHEMY_ECHO = True +FAB_INDEX_VIEW = "app.views.MyIndexView" + BABEL_DEFAULT_LOCALE = "en" BABEL_DEFAULT_FOLDER = "translations" LANGUAGES = { diff --git a/examples/factoryapp/testdata.py b/examples/factoryapp/testdata.py index 7538e732ac..2bdc202a6c 100644 --- a/examples/factoryapp/testdata.py +++ b/examples/factoryapp/testdata.py @@ -1,7 +1,8 @@ from datetime import datetime import random -from app import create_app, db +from flask import current_app +from app.app import create_app from app.models import Contact, ContactGroup, Gender app = create_app("config") @@ -17,19 +18,19 @@ def get_random_name(names_list, size=1): try: - db.session.add(ContactGroup(name="Friends")) - db.session.add(ContactGroup(name="Family")) - db.session.add(ContactGroup(name="Work")) - db.session.commit() + current_app.appbuilder.session.add(ContactGroup(name="Friends")) + current_app.appbuilder.session.add(ContactGroup(name="Family")) + current_app.appbuilder.session.add(ContactGroup(name="Work")) + current_app.appbuilder.session.commit() except Exception: - db.session.rollback() + current_app.appbuilder.session.rollback() try: - db.session.add(Gender(name="Male")) - db.session.add(Gender(name="Female")) - db.session.commit() + current_app.appbuilder.session.add(Gender(name="Male")) + current_app.appbuilder.session.add(Gender(name="Female")) + current_app.appbuilder.session.commit() except Exception: - db.session.rollback() + current_app.appbuilder.session.rollback() f = open("NAMES.DIC", "rb") names_list = [x.strip() for x in f.readlines()] @@ -50,9 +51,9 @@ def get_random_name(names_list, size=1): month = random.choice(range(1, 12)) day = random.choice(range(1, 28)) c.birthday = datetime(year, month, day) - db.session.add(c) + current_app.appbuilder.session.add(c) try: - db.session.commit() + current_app.appbuilder.session.commit() print("inserted {0}".format(c)) except Exception: - db.session.rollback() + current_app.appbuilder.session.rollback() diff --git a/examples/issue_789/test.db b/examples/issue_789/test.db deleted file mode 100644 index 252b14aa73..0000000000 Binary files a/examples/issue_789/test.db and /dev/null differ diff --git a/examples/oauth/app/security.py b/examples/oauth/app/security.py index ed1077f3f5..3c69f44df8 100644 --- a/examples/oauth/app/security.py +++ b/examples/oauth/app/security.py @@ -1,15 +1,14 @@ -from flask import redirect, session +from flask import session from flask_appbuilder import expose from flask_appbuilder.security.views import AuthOAuthView from flask_appbuilder.security.sqla.manager import SecurityManager class MyAuthOAuthView(AuthOAuthView): - @expose("/logout/") def logout(self): """Delete access token before logging out.""" - session.pop('oauth_token', None) + session.pop("oauth_token", None) return super().logout() diff --git a/examples/oauth/app/views.py b/examples/oauth/app/views.py index 0b7f79fe43..6d92b0cea4 100644 --- a/examples/oauth/app/views.py +++ b/examples/oauth/app/views.py @@ -20,8 +20,9 @@ def form_get(self, form): def form_post(self, form): remote_app = self.appbuilder.sm.oauth_remotes["twitter"] resp = remote_app.post( - "statuses/update.json", data={"status": form.message.data}, - token=remote_app.token + "statuses/update.json", + data={"status": form.message.data}, + token=remote_app.token, ) if resp.status_code != 200: flash("An error occurred", "danger") diff --git a/examples/oauth/config.py b/examples/oauth/config.py index 20d093c6ab..427107382c 100644 --- a/examples/oauth/config.py +++ b/examples/oauth/config.py @@ -1,12 +1,6 @@ import os from flask import session -from flask_appbuilder.security.manager import ( - AUTH_OID, - AUTH_REMOTE_USER, - AUTH_DB, - AUTH_LDAP, - AUTH_OAUTH, -) +from flask_appbuilder.security.manager import AUTH_OAUTH basedir = os.path.abspath(os.path.dirname(__file__)) @@ -51,7 +45,9 @@ "request_token_url": "https://api.twitter.com/oauth/request_token", "access_token_url": "https://api.twitter.com/oauth/access_token", "authorize_url": "https://api.twitter.com/oauth/authenticate", - "fetch_token": lambda: session.get("oauth_token"), # DON'T DO THIS IN PRODUCTION + "fetch_token": lambda: session.get( + "oauth_token" + ), # DON'T DO THIS IN PRODUCTION }, }, { @@ -66,6 +62,7 @@ "request_token_url": None, "access_token_url": "https://accounts.google.com/o/oauth2/token", "authorize_url": "https://accounts.google.com/o/oauth2/auth", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", }, }, { @@ -81,8 +78,12 @@ "resource": os.environ.get("AZURE_APPLICATION_ID"), }, "request_token_url": None, - "access_token_url": "https://login.microsoftonline.com/{AZURE_TENANT_ID}/oauth2/token", - "authorize_url": "https://login.microsoftonline.com/{AZURE_TENANT_ID}/oauth2/authorize", + "access_token_url": f"https://login.microsoftonline.com/" + f"{os.environ.get('AZURE_APPLICATION_ID')}/" + "oauth2/token", + "authorize_url": f"https://login.microsoftonline.com/" + f"{os.environ.get('AZURE_APPLICATION_ID')}/" + f"oauth2/authorize", }, }, { @@ -92,16 +93,48 @@ "remote_app": { "client_id": os.environ.get("OKTA_KEY"), "client_secret": os.environ.get("OKTA_SECRET"), - "api_base_url": "https://{}.okta.com/oauth2/v1/".format( - os.environ.get("OKTA_DOMAIN") - ), + "api_base_url": f"https://{os.environ.get('OKTA_DOMAIN')}.okta.com/oauth2/v1/", "client_kwargs": {"scope": "openid profile email groups"}, - "access_token_url": "https://{}.okta.com/oauth2/v1/token".format( - os.environ.get("OKTA_DOMAIN") - ), - "authorize_url": "https://{}.okta.com/oauth2/v1/authorize".format( - os.environ.get("OKTA_DOMAIN") - ), + "access_token_url": f"https://{os.environ.get('OKTA_DOMAIN')}.okta.com/" + f"oauth2/v1/token", + "authorize_url": f"https://{os.environ.get('OKTA_DOMAIN')}.okta.com/" + f"oauth2/v1/authorize", + "server_metadata_url": f"https://{os.environ.get('OKTA_DOMAIN')}.okta.com/" + f".well-known/openid-configuration", + }, + }, + { + "name": "keycloak", + "icon": "fa-key", + "token_key": "access_token", + "remote_app": { + "client_id": os.environ.get("KEYCLOAK_CLIENT_ID"), + "client_secret": os.environ.get("KEYCLOAK_CLIENT_SECRET"), + "api_base_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"realms/master/protocol/openid-connect", + "client_kwargs": {"scope": "email profile"}, + "access_token_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"realms/master/protocol/openid-connect/token", + "authorize_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"realms/master/protocol/openid-connect/auth", + "request_token_url": None, + }, + }, + { + "name": "keycloak_before_17", + "icon": "fa-key", + "token_key": "access_token", + "remote_app": { + "client_id": os.environ.get("KEYCLOAK_CLIENT_ID"), + "client_secret": os.environ.get("KEYCLOAK_CLIENT_SECRET"), + "api_base_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"auth/realms/master/protocol/openid-connect", + "client_kwargs": {"scope": "email profile"}, + "access_token_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"auth/realms/master/protocol/openid-connect/token", + "authorize_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"auth/realms/master/protocol/openid-connect/auth", + "request_token_url": None, }, }, ] @@ -119,7 +152,7 @@ AUTH_USER_REGISTRATION_ROLE = "Admin" # Self registration role based on user info -AUTH_USER_REGISTRATION_ROLE_JMESPATH = "contains(['alice@example.com', 'celine@example.com'], email) && 'Admin' || 'Public'" +# AUTH_USER_REGISTRATION_ROLE_JMESPATH = "contains(['alice@example.com', 'celine@example.com'], email) && 'Admin' || 'Public'" # Replace users database roles each login with those received from OAUTH/LDAP AUTH_ROLES_SYNC_AT_LOGIN = True @@ -127,8 +160,8 @@ # A mapping from LDAP/OAUTH group names to FAB roles AUTH_ROLES_MAPPING = { # For OAUTH - "USER_GROUP_NAME": ["User"], - "ADMIN_GROUP_NAME": ["Admin"], + # "USER_GROUP_NAME": ["User"], + # "ADMIN_GROUP_NAME": ["Admin"], # For LDAP # "cn=User,ou=groups,dc=example,dc=com": ["User"], # "cn=Admin,ou=groups,dc=example,dc=com": ["Admin"], diff --git a/examples/quickcharts/app/data.py b/examples/quickcharts/app/data.py index 4dff27aeda..e4d363a721 100644 --- a/examples/quickcharts/app/data.py +++ b/examples/quickcharts/app/data.py @@ -26,7 +26,7 @@ def fill_data(): db.session.add(c) db.session.commit() except Exception as e: - log.error("Update ViewMenu error: {0}".format(str(e))) + log.error("Update ViewMenu error: %s", e) db.session.rollback() for political in politicals: c = PoliticalType(name=political) @@ -34,7 +34,7 @@ def fill_data(): db.session.add(c) db.session.commit() except Exception as e: - log.error("Update ViewMenu error: {0}".format(str(e))) + log.error("Update ViewMenu error: %s", e) db.session.rollback() try: for x in range(1, 20): @@ -51,5 +51,5 @@ def fill_data(): db.session.add(cs) db.session.commit() except Exception as e: - log.error("Update ViewMenu error: {0}".format(str(e))) + log.error("Update ViewMenu error: %s", e) db.session.rollback() diff --git a/examples/quickcharts2/app/views.py b/examples/quickcharts2/app/views.py index 0c98514c30..29a171e195 100644 --- a/examples/quickcharts2/app/views.py +++ b/examples/quickcharts2/app/views.py @@ -36,7 +36,7 @@ def fill_data(): db.session.add(c) db.session.commit() except Exception as e: - log.error("Update ViewMenu error: {0}".format(str(e))) + log.error("Update ViewMenu error: %s", e) db.session.rollback() for political in politicals: c = PoliticalType(name=political) @@ -44,7 +44,7 @@ def fill_data(): db.session.add(c) db.session.commit() except Exception as e: - log.error("Update ViewMenu error: {0}".format(str(e))) + log.error("Update ViewMenu error: %s", e) db.session.rollback() try: for x in range(1, 20): @@ -61,7 +61,7 @@ def fill_data(): db.session.add(cs) db.session.commit() except Exception as e: - log.error("Update ViewMenu error: {0}".format(str(e))) + log.error("Update ViewMenu error: %s", e) db.session.rollback() diff --git a/examples/quickhowto/config.py b/examples/quickhowto/config.py index ff08a39ce1..c813f2e723 100644 --- a/examples/quickhowto/config.py +++ b/examples/quickhowto/config.py @@ -48,6 +48,7 @@ def custom_password_validator(password: str) -> None: if len(password) < 8: raise PasswordComplexityValidationError("Must have at least 8 characters") + # FAB_PASSWORD_COMPLEXITY_VALIDATOR = custom_password_validator FAB_PASSWORD_COMPLEXITY_ENABLED = True @@ -81,4 +82,3 @@ def custom_password_validator(password: str) -> None: # APP_THEME = "sandstone.css" # APP_THEME = "solar.css" # APP_THEME = "superhero.css" - diff --git a/examples/quickhowto3/extra.db b/examples/quickhowto3/extra.db deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/quicktemplates/app/templates/mybase.html b/examples/quicktemplates/app/templates/mybase.html index 9939c708ce..438556cbb8 100644 --- a/examples/quicktemplates/app/templates/mybase.html +++ b/examples/quicktemplates/app/templates/mybase.html @@ -8,6 +8,8 @@ {% endblock %} {% block body %} + {% include 'appbuilder/general/confirm.html' %} + {% include 'appbuilder/general/alert.html' %} {% set languages = appbuilder.languages %} {% set menu = appbuilder.menu %} diff --git a/examples/related_fields/app/views.py b/examples/related_fields/app/views.py index 5dc18fe169..ba717f6c42 100644 --- a/examples/related_fields/app/views.py +++ b/examples/related_fields/app/views.py @@ -111,7 +111,7 @@ class ContactModelView(ModelView): col_name="contact_sub_group", widget=Select2SlaveAJAXWidget( master_id="contact_group", - endpoint="/contactmodelview/api/column/add/contact_sub_group?_flt_0_contact_group_id={{ID}}", + endpoint="/contactmodelview/api/column/add/contact_sub_group?_flt_0_contact_group={{ID}}", ), ), "contact_group2": AJAXSelectField( @@ -130,7 +130,7 @@ class ContactModelView(ModelView): col_name="contact_sub_group2", widget=Select2SlaveAJAXWidget( master_id="contact_group2", - endpoint="/contactmodelview/api/column/add/contact_sub_group2?_flt_0_contact_group2_id={{ID}}", + endpoint="/contactmodelview/api/column/add/contact_sub_group2?_flt_0_contact_group2={{ID}}", ), ), } diff --git a/examples/user_registration/app/views.py b/examples/user_registration/app/views.py index a08eea4d2d..6de3f4182f 100644 --- a/examples/user_registration/app/views.py +++ b/examples/user_registration/app/views.py @@ -147,5 +147,5 @@ class ContactTimeChartView(GroupByChartView): category="Contacts", ) -log.info("F.A.B. Version: {0}".format(appbuilder.version)) -log.info("User extension class {0}".format(UserExtensionMixin.__subclasses__()[0])) +log.info("F.A.B. Version: %s", appbuilder.version) +log.info("User extension class %s", UserExtensionMixin.__subclasses__()[0]) diff --git a/flask_appbuilder/__init__.py b/flask_appbuilder/__init__.py index 51fca9d2aa..1eebd688cf 100644 --- a/flask_appbuilder/__init__.py +++ b/flask_appbuilder/__init__.py @@ -1,5 +1,5 @@ __author__ = "Daniel Vaz Gaspar" -__version__ = "3.4.4" +__version__ = "4.3.7" from .actions import action # noqa: F401 from .api import ModelRestApi # noqa: F401 diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 54e0d1d662..23ee150d40 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -3,15 +3,29 @@ import logging import re import traceback -from typing import Callable, Dict, List, Optional, Set, Tuple, Type +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Set, + Tuple, + Type, + TYPE_CHECKING, + Union, +) import urllib.parse from apispec import APISpec, yaml_utils from apispec.exceptions import DuplicateComponentNameError from flask import Blueprint, current_app, jsonify, make_response, request, Response +from flask_appbuilder.models.sqla import Model +from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import lazy_gettext as _ import jsonschema from marshmallow import Schema, ValidationError +from marshmallow.fields import Field from marshmallow_sqlalchemy.fields import Related, RelatedList import prison from sqlalchemy.exc import IntegrityError @@ -21,6 +35,7 @@ from .convert import Model2SchemaConverter from .schemas import get_info_schema, get_item_schema, get_list_schema from .._compat import as_unicode +from ..baseviews import AbstractViewApi from ..const import ( API_ADD_COLUMNS_RES_KEY, API_ADD_COLUMNS_RIS_KEY, @@ -59,14 +74,24 @@ ) from ..exceptions import FABException, InvalidOrderByColumnFABException from ..hooks import get_before_request_hooks, wrap_route_handler_with_hooks +from ..models.filters import Filters from ..security.decorators import permission_name, protect +from ..utils.limit import Limit + +if TYPE_CHECKING: + from flask_appbuilder import AppBuilder + log = logging.getLogger(__name__) -def get_error_msg(): +ModelKeyType = Union[str, int] +QueryRelatedFieldsFilters = Dict[str, List[List[Any]]] + + +def get_error_msg() -> str: """ - (inspired on Superset code) + (inspired on Superset code) :return: (str) """ if current_app.config.get("FAB_API_SHOW_STACKTRACE"): @@ -74,59 +99,61 @@ def get_error_msg(): return "Fatal error" -def safe(f): +def safe(f: Callable[..., Any]) -> Callable[..., Any]: """ A decorator that catches uncaught exceptions and return the response in JSON format (inspired on Superset code) """ - def wraps(self, *args, **kwargs): + def wraps(self: "BaseApi", *args: Any, **kwargs: Any) -> Response: try: return f(self, *args, **kwargs) except BadRequest as e: return self.response_400(message=str(e)) except Exception as e: - logging.exception(e) + log.exception(e) return self.response_500(message=get_error_msg()) return functools.update_wrapper(wraps, f) -def rison(schema=None): +def rison( + schema: Optional[Dict[str, Any]] = None +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """ - Use this decorator to parse URI *Rison* arguments to - a python data structure, your method gets the data - structure on kwargs['rison']. Response is HTTP 400 - if *Rison* is not correct:: + Use this decorator to parse URI *Rison* arguments to + a python data structure, your method gets the data + structure on kwargs['rison']. Response is HTTP 400 + if *Rison* is not correct:: - class ExampleApi(BaseApi): - @expose('/risonjson') - @rison() - def rison_json(self, **kwargs): - return self.response(200, result=kwargs['rison']) + class ExampleApi(BaseApi): + @expose('/risonjson') + @rison() + def rison_json(self, **kwargs): + return self.response(200, result=kwargs['rison']) - You can additionally pass a JSON schema to - validate Rison arguments:: + You can additionally pass a JSON schema to + validate Rison arguments:: - schema = { - "type": "object", - "properties": { - "arg1": { - "type": "integer" - } + schema = { + "type": "object", + "properties": { + "arg1": { + "type": "integer" } } + } - class ExampleApi(BaseApi): - @expose('/risonjson') - @rison(schema) - def rison_json(self, **kwargs): - return self.response(200, result=kwargs['rison']) + class ExampleApi(BaseApi): + @expose('/risonjson') + @rison(schema) + def rison_json(self, **kwargs): + return self.response(200, result=kwargs['rison']) """ - def _rison(f): - def wraps(self, *args, **kwargs): + def _rison(f: Callable[..., Any]) -> Callable[..., Any]: + def wraps(self: "BaseApi", *args: Any, **kwargs: Any) -> Response: value = request.args.get(API_URI_RIS_KEY, None) kwargs["rison"] = dict() if value: @@ -159,26 +186,26 @@ def wraps(self, *args, **kwargs): return _rison -def expose(url="/", methods=("GET",)): +def expose(url: str = "/", methods: Tuple[str] = ("GET",)) -> Callable[..., Any]: """ - Use this decorator to expose API endpoints on your API classes. + Use this decorator to expose API endpoints on your API classes. - :param url: - Relative URL for the endpoint - :param methods: - Allowed HTTP methods. By default only GET is allowed. + :param url: + Relative URL for the endpoint + :param methods: + Allowed HTTP methods. By default only GET is allowed. """ - def wrap(f): + def wrap(f: Callable[..., Any]) -> Callable[..., Any]: if not hasattr(f, "_urls"): - f._urls = [] - f._urls.append((url, methods)) + f._urls = [] # type: ignore + f._urls.append((url, methods)) # type: ignore return f return wrap -def merge_response_func(func, key): +def merge_response_func(func: Callable[..., Any], key: str) -> Callable[..., Any]: """ Use this decorator to set a new merging response function to HTTP endpoints @@ -194,105 +221,103 @@ def merge_some_function(self, response, rison_args): :return: None """ - def wrap(f): + def wrap(f: Callable[..., Any]) -> Callable[..., Any]: if not hasattr(f, "_response_key_func_mappings"): - f._response_key_func_mappings = dict() - f._response_key_func_mappings[key] = func + f._response_key_func_mappings = {} # type: ignore + f._response_key_func_mappings[key] = func # type: ignore return f return wrap -class BaseApi(object): +class BaseApi(AbstractViewApi): """ - All apis inherit from this class. - it's constructor will register your exposed urls on flask - as a Blueprint. + All apis inherit from this class. + it's constructor will register your exposed urls on flask + as a Blueprint. - This class does not expose any urls, - but provides a common base for all APIS. + This class does not expose any urls, + but provides a common base for all APIS. """ - appbuilder = None - blueprint = None endpoint: Optional[str] = None version: Optional[str] = "v1" """ - Define the Api version for this resource/class + Define the Api version for this resource/class """ route_base: Optional[str] = None """ - Define the route base where all methods will suffix from + Define the route base where all methods will suffix from """ resource_name: Optional[str] = None """ - Defines a custom resource name, overrides the inferred from Class name - makes no sense to use it with route base + Defines a custom resource name, overrides the inferred from Class name + makes no sense to use it with route base """ base_permissions: Optional[List[str]] = None """ - A list of allowed base permissions:: + A list of allowed base permissions:: - class ExampleApi(BaseApi): - base_permissions = ['can_get'] + class ExampleApi(BaseApi): + base_permissions = ['can_get'] """ class_permission_name: Optional[str] = None """ - Override class permission name default fallback to self.__class__.__name__ + Override class permission name default fallback to self.__class__.__name__ """ previous_class_permission_name: Optional[str] = None """ - If set security converge will replace all permissions tuples - with this name by the class_permission_name or self.__class__.__name__ + If set security converge will replace all permissions tuples + with this name by the class_permission_name or self.__class__.__name__ """ method_permission_name: Optional[Dict[str, str]] = None """ - Override method permission names, example:: + Override method permission names, example:: - method_permissions_name = { - 'get_list': 'read', - 'get': 'read', - 'put': 'write', - 'post': 'write', - 'delete': 'write' - } + method_permissions_name = { + 'get_list': 'read', + 'get': 'read', + 'put': 'write', + 'post': 'write', + 'delete': 'write' + } """ previous_method_permission_name: Optional[Dict[str, str]] = None """ - Use same structure as method_permission_name. If set security converge - will replace all method permissions by the new ones + Use same structure as method_permission_name. If set security converge + will replace all method permissions by the new ones """ allow_browser_login = False """ - Will allow flask-login cookie authorization on the API - default is False. + Will allow flask-login cookie authorization on the API + default is False. """ csrf_exempt = True """ - If using flask-wtf CSRFProtect exempt the API from check + If using flask-wtf CSRFProtect exempt the API from check """ - apispec_parameter_schemas: Optional[Dict[str, Dict]] = None + apispec_parameter_schemas: Optional[Dict[str, Dict[str, Any]]] = None """ - Set your custom Rison parameter schemas here so that - they get registered on the OpenApi spec:: + Set your custom Rison parameter schemas here so that + they get registered on the OpenApi spec:: - custom_parameter = { - "type": "object" - "properties": { - "name": { - "type": "string" - } + custom_parameter = { + "type": "object" + "properties": { + "name": { + "type": "string" } } + } - class CustomApi(BaseApi): - apispec_parameter_schemas = { - "custom_parameter": custom_parameter - } + class CustomApi(BaseApi): + apispec_parameter_schemas = { + "custom_parameter": custom_parameter + } """ - _apispec_parameter_schemas = None + _apispec_parameter_schemas: Optional[Dict[str, Dict[str, Any]]] = None openapi_spec_component_schemas: Tuple[Type[Schema], ...] = tuple() """ @@ -379,67 +404,82 @@ class Schema1(Schema): }, } """ - Override custom OpenApi responses + Override custom OpenApi responses """ - exclude_route_methods = set() + exclude_route_methods: Set[str] = set() """ - Does not register routes for a set of builtin ModelRestApi functions. - example:: + Does not register routes for a set of builtin ModelRestApi functions. + example:: - class ContactModelView(ModelRestApi): - datamodel = SQLAInterface(Contact) - exclude_route_methods = {"info", "get_list", "get"} + class ContactModelView(ModelRestApi): + datamodel = SQLAInterface(Contact) + exclude_route_methods = {"info", "get_list", "get"} - The previous examples will only register the `put`, `post` and `delete` routes + The previous examples will only register the `put`, `post` and `delete` routes """ - include_route_methods: Set[str] = None + include_route_methods: Optional[Set[str]] = None """ - If defined will assume a white list setup, where all endpoints are excluded - except those define on this attribute - example:: + If defined will assume a white list setup, where all endpoints are excluded + except those define on this attribute + example:: - class ContactModelView(ModelRestApi): - datamodel = SQLAInterface(Contact) - include_route_methods = {"list"} + class ContactModelView(ModelRestApi): + datamodel = SQLAInterface(Contact) + include_route_methods = {"list"} - The previous example will exclude all endpoints except the `list` endpoint + The previous example will exclude all endpoints except the `list` endpoint """ - openapi_spec_methods: Dict = {} + openapi_spec_methods: Dict[str, Any] = {} """ - Merge OpenAPI spec defined on the method's doc. - For example to merge/override `get_list`:: + Merge OpenAPI spec defined on the method's doc. + For example to merge/override `get_list`:: - class GreetingApi(BaseApi): - resource_name = "greeting" - openapi_spec_methods = { - "greeting": { - "get": { - "description": "Override description", - } + class GreetingApi(BaseApi): + resource_name = "greeting" + openapi_spec_methods = { + "greeting": { + "get": { + "description": "Override description", } } + } """ openapi_spec_tag: Optional[str] = None """ - By default all endpoints will be tagged (grouped) to their class name. - Use this attribute to override the tag name + By default all endpoints will be tagged (grouped) to their class name. + Use this attribute to override the tag name + """ + + limits: Optional[List[Limit]] = None + """ + List of limits for this api. + + Use it like this if you want to restrict the rate of requests to a view:: + + class MyView(ModelView): + limits = [Limit("2 per 5 second")] + + or use the decorator @limit. """ def __init__(self) -> None: """ - Initialization of base permissions - based on exposed methods and actions + Initialization of base permissions + based on exposed methods and actions - Initialization of extra args + Initialization of extra args """ + self.appbuilder = None + self.blueprint = None + # Init OpenAPI - self._response_key_func_mappings = dict() - self.apispec_parameter_schemas = self.apispec_parameter_schemas or dict() - self._apispec_parameter_schemas = self._apispec_parameter_schemas or dict() + self._response_key_func_mappings: Dict[str, Any] = {} + self.apispec_parameter_schemas = self.apispec_parameter_schemas or {} + self._apispec_parameter_schemas = self._apispec_parameter_schemas or {} self._apispec_parameter_schemas.update(self.apispec_parameter_schemas) if self.openapi_spec_component_schemas is None: self.openapi_spec_component_schemas = () @@ -463,7 +503,13 @@ def __init__(self) -> None: if self.base_permissions is None: self.base_permissions = set() is_add_base_permissions = True + + if self.limits is None: + self.limits = [] + for attr_name in dir(self): + if hasattr(getattr(self, attr_name), "_limit"): + self.limits.append(getattr(getattr(self, attr_name), "_limit")) # If include_route_methods is not None white list if ( self.include_route_methods is not None @@ -483,7 +529,12 @@ def __init__(self) -> None: self.base_permissions.add(PERMISSION_PREFIX + _permission_name) self.base_permissions = list(self.base_permissions) - def create_blueprint(self, appbuilder, endpoint=None, static_folder=None): + def create_blueprint( + self, + appbuilder: "AppBuilder", + endpoint: Optional[str] = None, + static_folder: Optional[str] = None, + ) -> Blueprint: # Store appbuilder instance self.appbuilder = appbuilder # If endpoint name is not provided, get it from the class name @@ -515,9 +566,9 @@ def add_api_spec(self, api_spec: APISpec) -> None: ): continue if attr_name in self.exclude_route_methods: - log.info(f"Not registering api spec for method {attr_name}") + log.info("Not registering api spec for method %s", attr_name) continue - operations = dict() + operations = {} path = self.path_helper(path=url, operations=operations) self.operation_helper( path=path, operations=operations, methods=methods, func=attr @@ -531,7 +582,10 @@ def add_api_spec(self, api_spec: APISpec) -> None: def add_apispec_components(self, api_spec: APISpec) -> None: for k, v in self.responses.items(): - api_spec.components._responses[k] = v + try: + api_spec.components.response(k, v) + except DuplicateComponentNameError: + pass for k, v in self._apispec_parameter_schemas.items(): try: api_spec.components.schema(k, v) @@ -558,13 +612,16 @@ def _register_urls(self) -> None: ): continue if attr_name in self.exclude_route_methods: - log.info(f"Not registering route for method {attr_name}") + log.info("Not registering route for method %s", attr_name) continue attr = getattr(self, attr_name) if hasattr(attr, "_urls"): for url, methods in attr._urls: log.info( - f"Registering route {self.blueprint.url_prefix}{url} {methods}" + "Registering route %s%s %s", + self.blueprint.url_prefix, + url, + methods, ) route_handler = wrap_route_handler_with_hooks( attr_name, attr, before_request_hooks @@ -574,7 +631,10 @@ def _register_urls(self) -> None: ) def path_helper( - self, path: str = None, operations: Dict[str, Dict] = None, **kwargs + self, + path: str = None, + operations: Optional[Dict[str, Dict]] = None, + **kwargs: Any, ) -> str: """ Works like an apispec plugin @@ -592,12 +652,17 @@ def path_helper( return f"{self.route_base}{path}" def operation_helper( - self, path=None, operations=None, methods=None, func=None, **kwargs - ): + self, + path: Optional[str] = None, + operations: Dict[str, Any] = None, + methods: List[str] = None, + func: Callable[..., Response] = None, + **kwargs: Any, + ) -> None: """May mutate operations. :param str path: Path to the resource :param dict operations: A `dict` mapping HTTP methods to operation object. - :param list methods: A list of methods registered for this path + :param list methods: A list of HTTP methods registered for this path """ for method in methods: try: @@ -622,45 +687,45 @@ def operation_helper( operations[method.lower()] = {} @staticmethod - def _prettify_name(name): + def _prettify_name(name: str) -> str: """ - Prettify pythonic variable name. + Prettify pythonic variable name. - For example, 'HelloWorld' will be converted to 'Hello World' + For example, 'HelloWorld' will be converted to 'Hello World' - :param name: - Name to prettify. + :param name: + Name to prettify. """ return re.sub(r"(?<=.)([A-Z])", r" \1", name) @staticmethod - def _prettify_column(name): + def _prettify_column(name: str) -> str: """ - Prettify pythonic variable name. + Prettify pythonic variable name. - For example, 'hello_world' will be converted to 'Hello World' + For example, 'hello_world' will be converted to 'Hello World' - :param name: - Name to prettify. + :param name: + Name to prettify. """ return re.sub("[._]", " ", name).title() - def get_uninit_inner_views(self): + def get_uninit_inner_views(self) -> List[Type[AbstractViewApi]]: """ - Will return a list with views that need to be initialized. - Normally related_views from ModelView + Will return a list with views that need to be initialized. + Normally related_views from ModelView """ return [] - def get_init_inner_views(self, views): + def get_init_inner_views(self) -> List[AbstractViewApi]: """ - Sets initialized inner views + Sets initialized inner views """ pass # pragma: no cover def get_method_permission(self, method_name: str) -> str: """ - Returns the permission name for a method + Returns the permission name for a method """ if self.method_permission_name: return self.method_permission_name.get(method_name, method_name) @@ -668,7 +733,13 @@ def get_method_permission(self, method_name: str) -> str: if hasattr(getattr(self, method_name), "_permission_name"): return getattr(getattr(self, method_name), "_permission_name") - def set_response_key_mappings(self, response, func, rison_args, **kwargs): + def set_response_key_mappings( + self, + response: Dict[str, Any], + func: Callable[..., Response], + rison_args: Dict[str, Any], + **kwargs: Any, + ) -> None: if not hasattr(func, "_response_key_func_mappings"): return # pragma: no cover _keys = rison_args.get("keys", None) @@ -680,7 +751,9 @@ def set_response_key_mappings(self, response, func, rison_args, **kwargs): if k in _keys: v(self, response, **kwargs) - def merge_current_user_permissions(self, response, **kwargs): + def merge_current_user_permissions( + self, response: Dict[str, Any], **kwargs: Any + ) -> None: response[API_PERMISSIONS_RES_KEY] = [ permission for permission in self.base_permissions @@ -688,9 +761,9 @@ def merge_current_user_permissions(self, response, **kwargs): ] @staticmethod - def response(code, **kwargs) -> Response: + def response(code: int, **kwargs: Any) -> Response: """ - Generic HTTP JSON response method + Generic HTTP JSON response method :param code: HTTP code (int) :param kwargs: Data structure for response (dict) @@ -703,7 +776,7 @@ def response(code, **kwargs) -> Response: def response_400(self, message: str = None) -> Response: """ - Helper method for HTTP 400 response + Helper method for HTTP 400 response :param message: Error message (str) :return: HTTP Json response @@ -713,7 +786,7 @@ def response_400(self, message: str = None) -> Response: def response_422(self, message: str = None) -> Response: """ - Helper method for HTTP 422 response + Helper method for HTTP 422 response :param message: Error message (str) :return: HTTP Json response @@ -723,7 +796,7 @@ def response_422(self, message: str = None) -> Response: def response_401(self) -> Response: """ - Helper method for HTTP 401 response + Helper method for HTTP 401 response :param message: Error message (str) :return: HTTP Json response @@ -732,7 +805,7 @@ def response_401(self) -> Response: def response_403(self) -> Response: """ - Helper method for HTTP 403 response + Helper method for HTTP 403 response :param message: Error message (str) :return: HTTP Json response @@ -741,7 +814,7 @@ def response_403(self) -> Response: def response_404(self) -> Response: """ - Helper method for HTTP 404 response + Helper method for HTTP 404 response :param message: Error message (str) :return: HTTP Json response @@ -750,7 +823,7 @@ def response_404(self) -> Response: def response_500(self, message: str = None) -> Response: """ - Helper method for HTTP 500 response + Helper method for HTTP 500 response :param message: Error message (str) :return: HTTP Json response @@ -760,83 +833,80 @@ def response_500(self, message: str = None) -> Response: class BaseModelApi(BaseApi): - datamodel = None + datamodel: Optional[SQLAInterface] = None """ - Your sqla model you must initialize it like:: + Your sqla model you must initialize it like:: - class MyModelApi(BaseModelApi): - datamodel = SQLAInterface(MyTable) + class MyModelApi(BaseModelApi): + datamodel = SQLAInterface(MyTable) """ search_columns = None """ - List with allowed search columns, if not provided all possible search - columns will be used. If you want to limit the search (*filter*) columns - possibilities, define it with a list of column names from your model:: + List with allowed search columns, if not provided all possible search + columns will be used. If you want to limit the search (*filter*) columns + possibilities, define it with a list of column names from your model:: - class MyView(ModelRestApi): - datamodel = SQLAInterface(MyTable) - search_columns = ['name', 'address'] + class MyView(ModelRestApi): + datamodel = SQLAInterface(MyTable) + search_columns = ['name', 'address'] """ search_filters = None """ - Override default search filters for columns + Override default search filters for columns """ search_exclude_columns = None """ - List with columns to exclude from search. Search includes all possible - columns by default + List with columns to exclude from search. Search includes all possible + columns by default """ label_columns = None """ - Dictionary of labels for your columns, override this if you want - different pretify labels + Dictionary of labels for your columns, override this if you want + different pretify labels - example (will just override the label for name column):: + example (will just override the label for name column):: - class MyView(ModelRestApi): - datamodel = SQLAInterface(MyTable) - label_columns = {'name':'My Name Label Override'} + class MyView(ModelRestApi): + datamodel = SQLAInterface(MyTable) + label_columns = {'name':'My Name Label Override'} """ base_filters = None """ - Filter the view use: [['column_name',BaseFilter,'value'],] + Filter the view use: [['column_name',BaseFilter,'value'],] - example:: + example:: - def get_user(): - return g.user + def get_user(): + return g.user - class MyView(ModelRestApi): - datamodel = SQLAInterface(MyTable) - base_filters = [['created_by', FilterEqualFunction, get_user], - ['name', FilterStartsWith, 'a']] + class MyView(ModelRestApi): + datamodel = SQLAInterface(MyTable) + base_filters = [['created_by', FilterEqualFunction, get_user], + ['name', FilterStartsWith, 'a']] """ base_order = None """ - Use this property to set default ordering for lists - ('col_name','asc|desc'):: + Use this property to set default ordering for lists + ('col_name','asc|desc'):: - class MyView(ModelRestApi): - datamodel = SQLAInterface(MyTable) - base_order = ('my_column_name','asc') + class MyView(ModelRestApi): + datamodel = SQLAInterface(MyTable) + base_order = ('my_column_name','asc') """ _base_filters = None """ Internal base Filter from class Filters will always filter view """ _filters = None """ - Filters object will calculate all possible filter types - based on search_columns + Filters object will calculate all possible filter types + based on search_columns """ - def __init__(self, **kwargs): - """ - Constructor - """ + def __init__(self, **kwargs: Any) -> None: datamodel = kwargs.get("datamodel", None) if datamodel: self.datamodel = datamodel @@ -844,17 +914,17 @@ def __init__(self, **kwargs): self._init_titles() super(BaseModelApi, self).__init__() - def _gen_labels_columns(self, list_columns): + def _gen_labels_columns(self, list_columns: List[str]) -> None: """ - Auto generates pretty label_columns from list of columns + Auto generates pretty label_columns from list of columns """ for col in list_columns: if not self.label_columns.get(col): self.label_columns[col] = self._prettify_column(col) - def _label_columns_json(self, cols=None): + def _label_columns_json(self, cols: Optional[List[str]] = None) -> Dict[str, Any]: """ - Prepares dict with labels to be JSON serializable + Prepares dict with labels to be JSON serializable """ ret = {} cols = cols or [] @@ -863,7 +933,7 @@ def _label_columns_json(self, cols=None): ret[key] = as_unicode(_(value).encode("UTF-8")) return ret - def _init_properties(self): + def _init_properties(self) -> None: self.label_columns = self.label_columns or {} self.base_filters = self.base_filters or [] self.search_exclude_columns = self.search_exclude_columns or [] @@ -879,169 +949,183 @@ def _init_properties(self): ] self._gen_labels_columns(self.datamodel.get_columns_list()) - def _init_titles(self): + def _init_titles(self) -> None: pass class ModelRestApi(BaseModelApi): list_title = "" """ - List Title, if not configured the default is - 'List ' with pretty model name + List Title, if not configured the default is + 'List ' with pretty model name """ show_title: Optional[str] = "" """ - Show Title , if not configured the default is - 'Show ' with pretty model name + Show Title , if not configured the default is + 'Show ' with pretty model name """ add_title: Optional[str] = "" """ - Add Title , if not configured the default is - 'Add ' with pretty model name + Add Title , if not configured the default is + 'Add ' with pretty model name """ edit_title: Optional[str] = "" """ - Edit Title , if not configured the default is - 'Edit ' with pretty model name + Edit Title , if not configured the default is + 'Edit ' with pretty model name """ list_select_columns: Optional[List[str]] = None """ - A List of column names that will be included on the SQL select. - This is useful for including all necessary columns that are referenced - by properties listed on `list_columns` without generating N+1 queries. + A List of column names that will be included on the SQL select. + This is useful for including all necessary columns that are referenced + by properties listed on `list_columns` without generating N+1 queries. + """ + list_outer_default_load = False + """ + If True, the default load for outer joins will be applied on the get item endpoint. + This is useful for when you want to control the load of the many-to-many and + many-to-one relationships at the model level. Will apply: + https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html#sqlalchemy.orm.Load.defaultload """ list_columns: Optional[List[str]] = None """ - A list of columns (or model's methods) to be displayed on the list view. - Use it to control the order of the display + A list of columns (or model's methods) to be displayed on the list view. + Use it to control the order of the display """ show_select_columns: Optional[List[str]] = None """ - A List of column names that will be included on the SQL select. - This is useful for including all necessary columns that are referenced - by properties listed on `show_columns` without generating N+1 queries. + A List of column names that will be included on the SQL select. + This is useful for including all necessary columns that are referenced + by properties listed on `show_columns` without generating N+1 queries. + """ + show_outer_default_load = False + """ + If True, the default load for outer joins will be applied on the get item endpoint. + This is useful for when you want to control the load of the many-to-many and + many-to-one relationships at the model level. Will apply: + https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html#sqlalchemy.orm.Load.defaultload """ show_columns: Optional[List[str]] = None """ - A list of columns (or model's methods) for the get item endpoint. - Use it to control the order of the results + A list of columns (or model's methods) for the get item endpoint. + Use it to control the order of the results """ add_columns: Optional[List[str]] = None """ - A list of columns (or model's methods) to be allowed to post + A list of columns (or model's methods) to be allowed to post """ edit_columns: Optional[List[str]] = None """ - A list of columns (or model's methods) to be allowed to update + A list of columns (or model's methods) to be allowed to update """ list_exclude_columns: Optional[List[str]] = None """ - A list of columns to exclude from the get list endpoint. - By default all columns are included. + A list of columns to exclude from the get list endpoint. + By default all columns are included. """ show_exclude_columns: Optional[List[str]] = None """ - A list of columns to exclude from the get item endpoint. - By default all columns are included. + A list of columns to exclude from the get item endpoint. + By default all columns are included. """ add_exclude_columns: Optional[List[str]] = None """ - A list of columns to exclude from the add endpoint. - By default all columns are included. + A list of columns to exclude from the add endpoint. + By default all columns are included. """ edit_exclude_columns: Optional[List[str]] = None """ - A list of columns to exclude from the edit endpoint. - By default all columns are included. + A list of columns to exclude from the edit endpoint. + By default all columns are included. """ order_columns: Optional[List[str]] = None """ Allowed order columns """ page_size = 20 """ - Use this property to change default page size + Use this property to change default page size """ max_page_size: Optional[int] = None """ - class override for the FAB_API_MAX_SIZE, use special -1 to allow for any page - size + class override for the FAB_API_MAX_SIZE, use special -1 to allow for any page + size """ description_columns: Optional[Dict[str, str]] = None """ - Dictionary with column descriptions that will be shown on the forms:: + Dictionary with column descriptions that will be shown on the forms:: - class MyView(ModelView): - datamodel = SQLAModel(MyTable, db.session) + class MyView(ModelView): + datamodel = SQLAModel(MyTable, db.session) - description_columns = {'name':'your models name column', - 'address':'the address column'} + description_columns = {'name':'your models name column', + 'address':'the address column'} """ validators_columns: Optional[Dict[str, Callable]] = None """ Dictionary to add your own marshmallow validators """ add_query_rel_fields = None """ - Add Customized query for related add fields. - Assign a dictionary where the keys are the column names of - the related models to filter, the value for each key, is a list of lists with the - same format as base_filter - {'relation col name':[['Related model col',FilterClass,'Filter Value'],...],...} - Add a custom filter to form related fields:: + Add Customized query for related add fields. + Assign a dictionary where the keys are the column names of + the related models to filter, the value for each key, is a list of lists with the + same format as base_filter + {'relation col name':[['Related model col',FilterClass,'Filter Value'],...],...} + Add a custom filter to form related fields:: - class ContactModelView(ModelRestApi): - datamodel = SQLAModel(Contact) - add_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} + class ContactModelView(ModelRestApi): + datamodel = SQLAModel(Contact) + add_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} """ edit_query_rel_fields = None """ - Add Customized query for related edit fields. - Assign a dictionary where the keys are the column names of - the related models to filter, the value for each key, is a list of lists with the - same format as base_filter - {'relation col name':[['Related model col',FilterClass,'Filter Value'],...],...} - Add a custom filter to form related fields:: + Add Customized query for related edit fields. + Assign a dictionary where the keys are the column names of + the related models to filter, the value for each key, is a list of lists with the + same format as base_filter + {'relation col name':[['Related model col',FilterClass,'Filter Value'],...],...} + Add a custom filter to form related fields:: - class ContactModelView(ModelRestApi): - datamodel = SQLAModel(Contact, db.session) - edit_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} + class ContactModelView(ModelRestApi): + datamodel = SQLAModel(Contact, db.session) + edit_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} """ order_rel_fields = None """ - Impose order on related fields. - assign a dictionary where the keys are the related column names:: + Impose order on related fields. + assign a dictionary where the keys are the related column names:: - class ContactModelView(ModelRestApi): - datamodel = SQLAModel(Contact) - order_rel_fields = { - 'group': ('name', 'asc') - 'gender': ('name', 'asc') - } + class ContactModelView(ModelRestApi): + datamodel = SQLAModel(Contact) + order_rel_fields = { + 'group': ('name', 'asc') + 'gender': ('name', 'asc') + } """ list_model_schema: Optional[Schema] = None """ - Override to provide your own marshmallow Schema - for JSON to SQLA dumps + Override to provide your own marshmallow Schema + for JSON to SQLA dumps """ add_model_schema: Optional[Schema] = None """ - Override to provide your own marshmallow Schema - for JSON to SQLA dumps + Override to provide your own marshmallow Schema + for JSON to SQLA dumps """ edit_model_schema: Optional[Schema] = None """ - Override to provide your own marshmallow Schema - for JSON to SQLA dumps + Override to provide your own marshmallow Schema + for JSON to SQLA dumps """ show_model_schema: Optional[Schema] = None """ - Override to provide your own marshmallow Schema - for JSON to SQLA dumps + Override to provide your own marshmallow Schema + for JSON to SQLA dumps """ model2schemaconverter = Model2SchemaConverter """ - Override to use your own Model2SchemaConverter - (inherit from BaseModel2SchemaConverter) + Override to use your own Model2SchemaConverter + (inherit from BaseModel2SchemaConverter) """ _apispec_parameter_schemas = { "get_info_schema": get_info_schema, @@ -1049,14 +1133,16 @@ class ContactModelView(ModelRestApi): "get_list_schema": get_list_schema, } - def __init__(self): + def __init__(self) -> None: super(ModelRestApi, self).__init__() self.validators_columns = self.validators_columns or {} self.model2schemaconverter = self.model2schemaconverter( self.datamodel, self.validators_columns ) - def create_blueprint(self, appbuilder, *args, **kwargs): + def create_blueprint( + self, appbuilder: "AppBuilder", *args: Any, **kwargs: Any + ) -> Blueprint: self._init_model_schemas() return super(ModelRestApi, self).create_blueprint(appbuilder, *args, **kwargs) @@ -1076,7 +1162,7 @@ def add_model_schema_name(self) -> str: def edit_model_schema_name(self) -> str: return f"{self.__class__.__name__}.put" - def add_apispec_components(self, api_spec): + def add_apispec_components(self, api_spec: APISpec) -> None: super(ModelRestApi, self).add_apispec_components(api_spec) api_spec.components.schema( self.list_model_schema_name, schema=self.list_model_schema @@ -1091,7 +1177,7 @@ def add_apispec_components(self, api_spec): self.show_model_schema_name, schema=self.show_model_schema ) - def _init_model_schemas(self): + def _init_model_schemas(self) -> None: # Create Marshmalow schemas if one is not specified if self.list_model_schema is None: self.list_model_schema = self.model2schemaconverter.convert( @@ -1101,14 +1187,12 @@ def _init_model_schemas(self): self.add_model_schema = self.model2schemaconverter.convert( self.add_columns, nested=False, - enum_dump_by_name=True, parent_schema_name=self.add_model_schema_name, ) if self.edit_model_schema is None: self.edit_model_schema = self.model2schemaconverter.convert( self.edit_columns, nested=False, - enum_dump_by_name=True, parent_schema_name=self.edit_model_schema_name, ) if self.show_model_schema is None: @@ -1116,9 +1200,9 @@ def _init_model_schemas(self): self.show_columns, parent_schema_name=self.show_model_schema_name ) - def _init_titles(self): + def _init_titles(self) -> None: """ - Init Titles if not defined + Init Titles if not defined """ super(ModelRestApi, self)._init_titles() class_name = self.datamodel.model_name @@ -1134,7 +1218,7 @@ def _init_titles(self): def _init_properties(self) -> None: """ - Init Properties + Initializes all properties """ super(ModelRestApi, self)._init_properties() # Reset init props @@ -1183,45 +1267,48 @@ def _init_properties(self) -> None: self.edit_query_rel_fields = self.edit_query_rel_fields or dict() self.add_query_rel_fields = self.add_query_rel_fields or dict() - def merge_add_field_info(self, response, **kwargs): - _kwargs = kwargs.get("add_columns", {}) + def merge_add_field_info(self, response: Dict[str, Any], **kwargs: Any) -> None: + add_columns_info = kwargs.get("add_columns", {}) response[API_ADD_COLUMNS_RES_KEY] = self._get_fields_info( self.add_columns, self.add_model_schema, self.add_query_rel_fields, - **_kwargs, + **add_columns_info, ) - def merge_edit_field_info(self, response, **kwargs): - _kwargs = kwargs.get("edit_columns", {}) + def merge_edit_field_info(self, response: Dict[str, Any], **kwargs: Any) -> None: + edit_columns_info = kwargs.get("edit_columns", {}) response[API_EDIT_COLUMNS_RES_KEY] = self._get_fields_info( self.edit_columns, self.edit_model_schema, self.edit_query_rel_fields, - **_kwargs, + **edit_columns_info, ) - def merge_search_filters(self, response, **kwargs): + def merge_search_filters(self, response: Dict[str, Any], **kwargs: Any) -> None: # Get possible search fields and all possible operations - search_filters = dict() + search_filters = {} dict_filters = self._filters.get_search_filters() for col in self.search_columns: + if col not in dict_filters: + # column not in search filters but defined has one + continue search_filters[col] = [ {"name": as_unicode(flt.name), "operator": flt.arg_name} for flt in dict_filters[col] ] response[API_FILTERS_RES_KEY] = search_filters - def merge_add_title(self, response, **kwargs): + def merge_add_title(self, response: Dict[str, Any], **kwargs: Any) -> None: response[API_ADD_TITLE_RES_KEY] = self.add_title - def merge_edit_title(self, response, **kwargs): + def merge_edit_title(self, response: Dict[str, Any], **kwargs: Any) -> None: response[API_EDIT_TITLE_RES_KEY] = self.edit_title - def merge_label_columns(self, response, **kwargs): - _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) - if _pruned_select_cols: - columns = _pruned_select_cols + def merge_label_columns(self, response: Dict[str, Any], **kwargs: Any) -> None: + pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) + if pruned_select_cols: + columns = pruned_select_cols else: # Send the exact labels for the caller operation if kwargs.get("caller") == "list": @@ -1232,24 +1319,26 @@ def merge_label_columns(self, response, **kwargs): columns = self.label_columns # pragma: no cover response[API_LABEL_COLUMNS_RES_KEY] = self._label_columns_json(columns) - def merge_list_label_columns(self, response, **kwargs): + def merge_list_label_columns(self, response: Dict[str, Any], **kwargs: Any) -> None: self.merge_label_columns(response, caller="list", **kwargs) - def merge_show_label_columns(self, response, **kwargs): + def merge_show_label_columns(self, response: Dict[str, Any], **kwargs: Any) -> None: self.merge_label_columns(response, caller="show", **kwargs) - def merge_show_columns(self, response, **kwargs): - _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) - if _pruned_select_cols: - response[API_SHOW_COLUMNS_RES_KEY] = _pruned_select_cols + def merge_show_columns(self, response: Dict[str, Any], **kwargs: Any) -> None: + pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) + if pruned_select_cols: + response[API_SHOW_COLUMNS_RES_KEY] = pruned_select_cols else: response[API_SHOW_COLUMNS_RES_KEY] = self.show_columns - def merge_description_columns(self, response, **kwargs): - _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) - if _pruned_select_cols: + def merge_description_columns( + self, response: Dict[str, Any], **kwargs: Any + ) -> None: + pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) + if pruned_select_cols: response[API_DESCRIPTION_COLUMNS_RES_KEY] = self._description_columns_json( - _pruned_select_cols + pruned_select_cols ) else: # Send all descriptions if cols are or request pruned @@ -1257,38 +1346,38 @@ def merge_description_columns(self, response, **kwargs): self.description_columns ) - def merge_list_columns(self, response, **kwargs): - _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) - if _pruned_select_cols: - response[API_LIST_COLUMNS_RES_KEY] = _pruned_select_cols + def merge_list_columns(self, response: Dict[str, Any], **kwargs: Any) -> None: + pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) + if pruned_select_cols: + response[API_LIST_COLUMNS_RES_KEY] = pruned_select_cols else: response[API_LIST_COLUMNS_RES_KEY] = self.list_columns - def merge_order_columns(self, response, **kwargs): - _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) - if _pruned_select_cols: + def merge_order_columns(self, response: Dict[str, Any], **kwargs: Any) -> None: + pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) + if pruned_select_cols: response[API_ORDER_COLUMNS_RES_KEY] = [ order_col for order_col in self.order_columns - if order_col in _pruned_select_cols + if order_col in pruned_select_cols ] else: response[API_ORDER_COLUMNS_RES_KEY] = self.order_columns - def merge_list_title(self, response, **kwargs): + def merge_list_title(self, response: Dict[str, Any], **kwargs: Any) -> None: response[API_LIST_TITLE_RES_KEY] = self.list_title - def merge_show_title(self, response, **kwargs): + def merge_show_title(self, response: Dict[str, Any], **kwargs: Any) -> None: response[API_SHOW_TITLE_RES_KEY] = self.show_title - def info_headless(self, **kwargs) -> Response: + def info_headless(self, **kwargs: Any) -> Response: """ - response for CRUD REST meta data + response for CRUD REST meta data """ - _response = dict() - _args = kwargs.get("rison", {}) - self.set_response_key_mappings(_response, self.info, _args, **_args) - return self.response(200, **_response) + payload = {} + rison_args = kwargs.get("rison", {}) + self.set_response_key_mappings(payload, self.info, rison_args, **rison_args) + return self.response(200, **payload) @expose("/_info", methods=["GET"]) @protect() @@ -1303,8 +1392,8 @@ def info_headless(self, **kwargs) -> Response: @merge_response_func(merge_search_filters, API_FILTERS_RIS_KEY) @merge_response_func(merge_add_title, API_ADD_TITLE_RIS_KEY) @merge_response_func(merge_edit_title, API_EDIT_TITLE_RIS_KEY) - def info(self, **kwargs): - """ Endpoint that renders a response for CRUD REST meta data + def info(self, **kwargs: Any) -> Response: + """Endpoint that renders a response for CRUD REST meta data --- get: description: >- @@ -1360,7 +1449,7 @@ def info(self, **kwargs): """ return self.info_headless(**kwargs) - def get_headless(self, pk, **kwargs) -> Response: + def get_headless(self, pk: ModelKeyType, **kwargs: Any) -> Response: """ Get an item from Model @@ -1368,29 +1457,31 @@ def get_headless(self, pk, **kwargs) -> Response: :param kwargs: Query string parameter arguments :return: HTTP Response """ - item = self.datamodel.get(pk, self._base_filters, self.show_select_columns) + item = self.datamodel.get( + pk, + self._base_filters, + self.show_select_columns, + self.show_outer_default_load, + ) if not item: return self.response_404() - _response = dict() - _args = kwargs.get("rison", {}) - select_cols = _args.get(API_SELECT_COLUMNS_RIS_KEY, []) - _pruned_select_cols = [col for col in select_cols if col in self.show_columns] + response = {} + args = kwargs.get("rison", {}) + select_cols = args.get(API_SELECT_COLUMNS_RIS_KEY, []) + pruned_select_cols = [col for col in select_cols if col in self.show_columns] self.set_response_key_mappings( - _response, - self.get, - _args, - **{API_SELECT_COLUMNS_RIS_KEY: _pruned_select_cols}, + response, self.get, args, **{API_SELECT_COLUMNS_RIS_KEY: pruned_select_cols} ) - if _pruned_select_cols: - _show_model_schema = self.model2schemaconverter.convert(_pruned_select_cols) + if pruned_select_cols: + show_model_schema = self.model2schemaconverter.convert(pruned_select_cols) else: - _show_model_schema = self.show_model_schema + show_model_schema = self.show_model_schema - _response["id"] = pk - _response[API_RESULT_RES_KEY] = _show_model_schema.dump(item, many=False) - self.pre_get(_response) - return self.response(200, **_response) + response["id"] = pk + response[API_RESULT_RES_KEY] = show_model_schema.dump(item, many=False) + self.pre_get(response) + return self.response(200, **response) @expose("/", methods=["GET"]) @protect() @@ -1401,7 +1492,7 @@ def get_headless(self, pk, **kwargs) -> Response: @merge_response_func(merge_show_columns, API_SHOW_COLUMNS_RIS_KEY) @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) @merge_response_func(merge_show_title, API_SHOW_TITLE_RIS_KEY) - def get(self, pk, **kwargs): + def get(self, pk: ModelKeyType, **kwargs: Any) -> Response: """Get item from Model --- get: @@ -1474,40 +1565,40 @@ def get(self, pk, **kwargs): """ return self.get_headless(pk, **kwargs) - def get_list_headless(self, **kwargs) -> Response: + def get_list_headless(self, **kwargs: Any) -> Response: """ - Get list of items from Model + Get list of items from Model """ - _response = dict() - _args = kwargs.get("rison", {}) + response = dict() + args = kwargs.get("rison", {}) # handle select columns - select_cols = _args.get(API_SELECT_COLUMNS_RIS_KEY, []) - _pruned_select_cols = [col for col in select_cols if col in self.list_columns] + select_cols = args.get(API_SELECT_COLUMNS_RIS_KEY, []) + pruned_select_cols = [col for col in select_cols if col in self.list_columns] # map decorated metadata self.set_response_key_mappings( - _response, + response, self.get_list, - _args, - **{API_SELECT_COLUMNS_RIS_KEY: _pruned_select_cols}, + args, + **{API_SELECT_COLUMNS_RIS_KEY: pruned_select_cols}, ) # Create a response schema with the computed response columns, # defined or requested - if _pruned_select_cols: - _list_model_schema = self.model2schemaconverter.convert(_pruned_select_cols) + if pruned_select_cols: + list_model_schema = self.model2schemaconverter.convert(pruned_select_cols) else: - _list_model_schema = self.list_model_schema + list_model_schema = self.list_model_schema # handle filters try: - joined_filters = self._handle_filters_args(_args) + joined_filters = self._handle_filters_args(args) except FABException as e: return self.response_400(message=str(e)) # handle base order try: - order_column, order_direction = self._handle_order_args(_args) + order_column, order_direction = self._handle_order_args(args) except InvalidOrderByColumnFABException as e: return self.response_400(message=str(e)) # handle pagination - page_index, page_size = self._handle_page_args(_args) + page_index, page_size = self._handle_page_args(args) # Make the query count, lst = self.datamodel.query( joined_filters, @@ -1516,13 +1607,14 @@ def get_list_headless(self, **kwargs) -> Response: page=page_index, page_size=page_size, select_columns=self.list_select_columns, + outer_default_load=self.list_outer_default_load, ) pks = self.datamodel.get_keys(lst) - _response[API_RESULT_RES_KEY] = _list_model_schema.dump(lst, many=True) - _response["ids"] = pks - _response["count"] = count - self.pre_get_list(_response) - return self.response(200, **_response) + response[API_RESULT_RES_KEY] = list_model_schema.dump(lst, many=True) + response["ids"] = pks + response["count"] = count + self.pre_get_list(response) + return self.response(200, **response) @expose("/", methods=["GET"]) @protect() @@ -1534,7 +1626,7 @@ def get_list_headless(self, **kwargs) -> Response: @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) @merge_response_func(merge_list_columns, API_LIST_COLUMNS_RIS_KEY) @merge_response_func(merge_list_title, API_LIST_TITLE_RIS_KEY) - def get_list(self, **kwargs): + def get_list(self, **kwargs: Any) -> Response: """Get list of items from Model --- get: @@ -1620,8 +1712,7 @@ def get_list(self, **kwargs): def post_headless(self) -> Response: """ - POST/Add item to Model - :return: + POST/Add item to Model """ if not request.is_json: return self.response_400(message="Request is not JSON") @@ -1648,7 +1739,7 @@ def post_headless(self) -> Response: @protect() @safe @permission_name("post") - def post(self): + def post(self) -> Response: """POST item to Model --- post: @@ -1682,9 +1773,9 @@ def post(self): """ return self.post_headless() - def put_headless(self, pk) -> Response: + def put_headless(self, pk: ModelKeyType) -> Response: """ - PUT/Edit item to Model + PUT/Edit item to Model """ item = self.datamodel.get(pk, self._base_filters) if not request.is_json: @@ -1711,7 +1802,7 @@ def put_headless(self, pk) -> Response: @protect() @safe @permission_name("put") - def put(self, pk): + def put(self, pk: ModelKeyType) -> Response: """PUT item to Model --- put: @@ -1750,9 +1841,9 @@ def put(self, pk): """ return self.put_headless(pk) - def delete_headless(self, pk) -> Response: + def delete_headless(self, pk: ModelKeyType) -> Response: """ - Delete item from Model + Delete item from Model """ item = self.datamodel.get(pk, self._base_filters) if not item: @@ -1769,7 +1860,7 @@ def delete_headless(self, pk) -> Response: @protect() @safe @permission_name("delete") - def delete(self, pk): + def delete(self, pk: ModelKeyType) -> Response: """Delete item from Model --- delete: @@ -1803,11 +1894,13 @@ def delete(self, pk): ------------------------------------------------ """ - def _handle_page_args(self, rison_args): + def _handle_page_args( + self, rison_args: Dict[str, Any] + ) -> Tuple[Optional[int], Optional[int]]: """ - Helper function to handle rison page - arguments, sets defaults and impose - FAB_API_MAX_PAGE_SIZE + Helper function to handle rison page + arguments, sets defaults and impose + FAB_API_MAX_PAGE_SIZE :param rison_args: :return: (tuple) page, page_size @@ -1816,26 +1909,28 @@ def _handle_page_args(self, rison_args): page_size = rison_args.get(API_PAGE_SIZE_RIS_KEY, self.page_size) return self._sanitize_page_args(page, page_size) - def _sanitize_page_args(self, page, page_size): - _page = page or 0 - _page_size = page_size or self.page_size + def _sanitize_page_args( + self, page: Optional[int], page_size: Optional[int] + ) -> Tuple[Optional[int], Optional[int]]: + page_ = page or 0 + page_size_ = page_size or self.page_size max_page_size = self.max_page_size or current_app.config.get( "FAB_API_MAX_PAGE_SIZE" ) # Accept special -1 to uncap the page size if max_page_size == -1: - if _page_size == -1: + if page_size_ == -1: return None, None else: - return _page, _page_size - if _page_size > max_page_size or _page_size < 1: - _page_size = max_page_size - return _page, _page_size + return page_, page_size_ + if page_size_ > max_page_size or page_size_ < 1: + page_size_ = max_page_size + return page_, page_size_ - def _handle_order_args(self, rison_args): + def _handle_order_args(self, rison_args: Dict[str, Any]) -> Tuple[str, str]: """ - Help function to handle rison order - arguments + Help function to handle rison order + arguments :param rison_args: :return: @@ -1846,20 +1941,22 @@ def _handle_order_args(self, rison_args): return self.base_order if not order_column: return "", "" - elif order_column not in self.order_columns: + elif self.order_columns and order_column not in self.order_columns: raise InvalidOrderByColumnFABException( f"Invalid order by column: {order_column}" ) return order_column, order_direction - def _handle_filters_args(self, rison_args): + def _handle_filters_args(self, rison_args: Dict[str, Any]) -> Filters: self._filters.clear_filters() self._filters.rest_add_filters(rison_args.get(API_FILTERS_RIS_KEY, [])) return self._filters.get_joined_filters(self._base_filters) - def _description_columns_json(self, cols=None): + def _description_columns_json( + self, cols: Optional[List[str]] = None + ) -> Dict[str, Any]: """ - Prepares dict with col descriptions to be JSON serializable + Prepares dict with col descriptions to be JSON serializable """ ret = {} cols = cols or [] @@ -1868,18 +1965,29 @@ def _description_columns_json(self, cols=None): ret[key] = as_unicode(_(value).encode("UTF-8")) return ret - def _get_field_info(self, field, filter_rel_field, page=None, page_size=None): + def _get_field_info( + self, + field: Field, + filter_rel_field: Dict[str, Any], + page: Optional[int] = None, + page_size: Optional[int] = None, + ) -> Dict[str, Any]: """ - Return a dict with field details - ready to serve as a response + Return a dict with field details + ready to serve as a response :param field: marshmallow field :return: dict with field details """ - ret = dict() - ret["name"] = field.name - ret["label"] = _(self.label_columns.get(field.name, "")) - ret["description"] = _(self.description_columns.get(field.name, "")) + ret = { + "name": field.name, + "label": _(self.label_columns.get(field.name, "")), + "description": _(self.description_columns.get(field.name, "")), + "type": field.__class__.__name__, + "required": field.required, + # When using custom marshmallow schemas fields don't have unique property + "unique": getattr(field, "unique", False), + } # Handles related fields if isinstance(field, Related) or isinstance(field, RelatedList): ret["count"], ret["values"] = self._get_list_related_field( @@ -1889,16 +1997,18 @@ def _get_field_info(self, field, filter_rel_field, page=None, page_size=None): ret["validate"] = [str(v) for v in field.validate] elif field.validate: ret["validate"] = [str(field.validate)] - ret["type"] = field.__class__.__name__ - ret["required"] = field.required - # When using custom marshmallow schemas fields don't have unique property - ret["unique"] = getattr(field, "unique", False) return ret - def _get_fields_info(self, cols, model_schema, filter_rel_fields, **kwargs): + def _get_fields_info( + self, + cols: List[str], + model_schema: Schema, + filter_rel_fields: QueryRelatedFieldsFilters, + **kwargs: Any, + ) -> List[Dict[str, Any]]: """ - Returns a dict with fields detail - from a marshmallow schema + Returns a dict with fields detail + from a marshmallow schema :param cols: list of columns to show info for :param model_schema: Marshmallow model schema @@ -1907,7 +2017,7 @@ def _get_fields_info(self, cols, model_schema, filter_rel_fields, **kwargs): :param kwargs: Receives all rison arguments for pagination :return: dict with all fields details """ - ret = list() + ret = [] for col in cols: page = page_size = None col_args = kwargs.get(col, {}) @@ -1925,18 +2035,24 @@ def _get_fields_info(self, cols, model_schema, filter_rel_fields, **kwargs): return ret def _get_list_related_field( - self, field, filter_rel_field, page=None, page_size=None - ): + self, + field: Field, + filter_rel_field: List[Any], + page: Optional[int] = None, + page_size: Optional[int] = None, + ) -> Tuple[int, List[Dict[str, Any]]]: """ - Return a list of values for a related field + Return a list of values for a related field :param field: Marshmallow field - :param filter_rel_field: Filters for the related field + :param filter_rel_field: Filters for the related field, + expects [field_name, Type[BaseFilter], value] :param page: The page index :param page_size: The page size - :return: (int, list) total record count and list of dict with id and value + :return: Total record count and list of dict with id and value """ - ret = list() + ret = [] + count = 0 if isinstance(field, Related) or isinstance(field, RelatedList): datamodel = self.datamodel.get_related_interface(field.name) filters = datamodel.get_filters(datamodel.get_search_columns_list()) @@ -1955,13 +2071,12 @@ def _get_list_related_field( ret.append({"id": datamodel.get_pk_value(value), "value": str(value)}) return count, ret - def _merge_update_item(self, model_item, data): + def _merge_update_item( + self, model_item: Model, data: Dict[str, Any] + ) -> Dict[str, Any]: """ - Merge a model with a python data structure - This is useful to turn PUT method into a PATCH also - :param model_item: SQLA Model - :param data: python data structure - :return: python data structure + Merge a model with a python data structure + This is useful to turn PUT method into a PATCH also """ data_item = self.edit_model_schema.dump(model_item, many=False) for _col in self.edit_columns: @@ -1975,56 +2090,56 @@ def _merge_update_item(self, model_item, data): ------------------------------------------------ """ - def pre_update(self, item): + def pre_update(self, item: Model) -> None: """ - Override this, this method is called before the update takes place. + Override this, this method is called before the update takes place. """ pass - def post_update(self, item): + def post_update(self, item: Model) -> None: """ - Override this, will be called after update + Override this, will be called after update """ pass - def pre_add(self, item): + def pre_add(self, item: Model) -> None: """ - Override this, will be called before add. + Override this, will be called before add. """ pass - def post_add(self, item): + def post_add(self, item: Model) -> None: """ - Override this, will be called after update + Override this, will be called after update """ pass - def pre_delete(self, item): + def pre_delete(self, item: Model) -> None: """ - Override this, will be called before delete + Override this, will be called before delete """ pass - def post_delete(self, item): + def post_delete(self, item: Model) -> None: """ - Override this, will be called after delete + Override this, will be called after delete """ pass - def pre_get(self, data): + def pre_get(self, data: Dict[str, Any]) -> None: """ - Override this, will be called before data is sent - to the requester on get item endpoint. - You can use it to mutate the response sent. - Note that any new field added will not be reflected on the OpenApi spec. + Override this, will be called before data is sent + to the requester on get item endpoint. + You can use it to mutate the response sent. + Note that any new field added will not be reflected on the OpenApi spec. """ pass - def pre_get_list(self, data): + def pre_get_list(self, data: Dict[str, Any]) -> None: """ - Override this, will be called before data is sent - to the requester on get list endpoint. - You can use it to mutate the response sent - Note that any new field added will not be reflected on the OpenApi spec. + Override this, will be called before data is sent + to the requester on get list endpoint. + You can use it to mutate the response sent + Note that any new field added will not be reflected on the OpenApi spec. """ pass diff --git a/flask_appbuilder/api/convert.py b/flask_appbuilder/api/convert.py index eb208740c9..f3def78b53 100644 --- a/flask_appbuilder/api/convert.py +++ b/flask_appbuilder/api/convert.py @@ -1,21 +1,20 @@ -from typing import List, Optional, Type +from typing import Any, Callable, Dict, List, Optional, Type from flask_appbuilder.models.sqla import Model from flask_appbuilder.models.sqla.interface import SQLAInterface -from marshmallow import fields +from marshmallow import fields, Schema from marshmallow.fields import Field -from marshmallow_enum import EnumField from marshmallow_sqlalchemy import field_for from marshmallow_sqlalchemy import SQLAlchemyAutoSchema class TreeNode: - def __init__(self, data): - self.data = data - self.childs = list() + def __init__(self, name: str) -> None: + self.name = name + self.children: List["TreeNode"] = [] - def __repr__(self): - return f"{self.data}.{str(self.childs)}" + def __repr__(self) -> str: + return f"{self.name}.{str(self.children)}" class Tree: @@ -23,26 +22,26 @@ class Tree: Simplistic one level Tree """ - def __init__(self): + def __init__(self) -> None: self.root = TreeNode("+") - def add(self, data): - node = TreeNode(data) - self.root.childs.append(node) + def add(self, name: str) -> None: + node = TreeNode(name) + self.root.children.append(node) - def add_child(self, parent, data): - node = TreeNode(data) - for n in self.root.childs: - if n.data == parent: - n.childs.append(node) + def add_child(self, parent: str, name: str) -> None: + node = TreeNode(name) + for child in self.root.children: + if child.name == parent: + child.children.append(node) return root = TreeNode(parent) - self.root.childs.append(root) - root.childs.append(node) + self.root.children.append(root) + root.children.append(node) - def __repr__(self): + def __repr__(self) -> str: ret = "" - for node in self.root.childs: + for node in self.root.children: ret += str(node) return ret @@ -51,43 +50,62 @@ def columns2Tree(columns: List[str]) -> Tree: tree = Tree() for column in columns: if "." in column: - tree.add_child(column.split(".")[0], column.split(".")[1]) + parent, child = column.split(".") + tree.add_child(parent, child) else: tree.add(column) return tree class BaseModel2SchemaConverter(object): - def __init__(self, datamodel: SQLAInterface, validators_columns): + def __init__( + self, + datamodel: SQLAInterface, + validators_columns: Dict[str, Callable[[Any], Any]], + ): """ :param datamodel: SQLAInterface """ self.datamodel = datamodel self.validators_columns = validators_columns - def convert(self, columns, **kwargs): + def convert( + self, + columns: List[str], + model: Optional[Type[Model]] = None, + nested: bool = True, + parent_schema_name: Optional[str] = None, + ) -> SQLAlchemyAutoSchema: pass class Model2SchemaConverter(BaseModel2SchemaConverter): """ - Class that converts Models to marshmallow Schemas + Class that converts Models to marshmallow Schemas """ - def __init__(self, datamodel: SQLAInterface, validators_columns): + def __init__( + self, + datamodel: SQLAInterface, + validators_columns: Dict[str, Callable[[Any], Any]], + ): """ :param datamodel: SQLAInterface """ super(Model2SchemaConverter, self).__init__(datamodel, validators_columns) @staticmethod - def _debug_schema(schema): + def _debug_schema(schema: SQLAlchemyAutoSchema) -> None: for k, v in schema._declared_fields.items(): print(k, v) def _meta_schema_factory( - self, columns: List[str], model: Model, class_mixin, parent_schema_name=None - ): + self, + columns: List[str], + model: Optional[Type[Model]], + class_mixin: Type[Schema], + parent_schema_name: Optional[str] = None, + ) -> Type[SQLAlchemyAutoSchema]: """ Creates ModelSchema marshmallow-sqlalchemy @@ -100,7 +118,7 @@ def _meta_schema_factory( _parent_schema_name = parent_schema_name if columns: - class MetaSchema(SQLAlchemyAutoSchema, class_mixin): + class MetaSchema(SQLAlchemyAutoSchema, class_mixin): # type: ignore class Meta: model = _model fields = columns @@ -110,35 +128,34 @@ class Meta: # This name comes from ModelRestApi parent_schema_name = _parent_schema_name - else: + return MetaSchema - class MetaSchema(SQLAlchemyAutoSchema, class_mixin): - class Meta: - model = _model - load_instance = True - sqla_session = self.datamodel.session - # The parent_schema_name is useful to humanize nested schema names - # This name comes from ModelRestApi - parent_schema_name = _parent_schema_name + class MetaSchema(SQLAlchemyAutoSchema, class_mixin): # type: ignore + class Meta: + model = _model + load_instance = True + sqla_session = self.datamodel.session + # The parent_schema_name is useful to humanize nested schema names + # This name comes from ModelRestApi + parent_schema_name = _parent_schema_name return MetaSchema - def _column2enum( - self, - datamodel: SQLAInterface, - column: TreeNode, - enum_dump_by_name: bool = False, - ): - required = not datamodel.is_nullable(column.data) - enum_class = datamodel.list_columns[column.data].info.get( - "enum_class", datamodel.list_columns[column.data].type - ) - if enum_dump_by_name: - enum_dump_by = EnumField.NAME + def _column2enum(self, datamodel: SQLAInterface, column: TreeNode) -> Field: + required = not datamodel.is_nullable(column.name) + sqla_column = datamodel.list_columns[column.name] + # get SQLAlchemy column user info, we use it to get the marshmallow enum options + column_info = sqla_column.info + # TODO: Default should be False, but keeping this to True to keep compatibility + # Turn this to False in the next major release + by_value = column_info.get("marshmallow_by_value", True) + # Get the original enum class from SQLAlchemy Enum field + enum_class = sqla_column.type.enum_class + if not enum_class: + field = field_for(datamodel.obj, column.name) else: - enum_dump_by = EnumField.VALUE - field = EnumField(enum_class, dump_by=enum_dump_by, required=required) - field.unique = datamodel.is_unique(column.data) + field = fields.Enum(enum_class, required=required, by_value=by_value) + field.unique = datamodel.is_unique(column.name) return field def _column2relation( @@ -147,40 +164,37 @@ def _column2relation( column: TreeNode, nested: bool = False, parent_schema_name: Optional[str] = None, - ): + ) -> Field: if nested: - required = not datamodel.is_nullable(column.data) - nested_model = datamodel.get_related_model(column.data) - lst = [item.data for item in column.childs] + required = not datamodel.is_nullable(column.name) + nested_model = datamodel.get_related_model(column.name) + lst = [item.name for item in column.children] nested_schema = self.convert( lst, nested_model, nested=False, parent_schema_name=parent_schema_name ) - if datamodel.is_relation_many_to_one(column.data): + if datamodel.is_relation_many_to_one(column.name): many = False - elif datamodel.is_relation_many_to_many(column.data): + elif datamodel.is_relation_many_to_many(column.name): many = True required = False - elif datamodel.is_relation_one_to_many(column.data): + elif datamodel.is_relation_one_to_many(column.name): many = True else: many = False field = fields.Nested(nested_schema, many=many, required=required) - field.unique = datamodel.is_unique(column.data) + field.unique = datamodel.is_unique(column.name) return field # Handle bug on marshmallow-sqlalchemy # https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/163 if datamodel.is_relation_many_to_many( - column.data - ) or datamodel.is_relation_one_to_many(column.data): - if datamodel.get_info(column.data).get("required", False): - required = True - else: - required = False + column.name + ) or datamodel.is_relation_one_to_many(column.name): + required = datamodel.get_info(column.name).get("required", False) else: - required = not datamodel.is_nullable(column.data) - field = field_for(datamodel.obj, column.data) + required = not datamodel.is_nullable(column.name) + field = field_for(datamodel.obj, column.name) field.required = required - field.unique = datamodel.is_unique(column.data) + field.unique = datamodel.is_unique(column.name) return field def _column2field( @@ -188,7 +202,6 @@ def _column2field( datamodel: SQLAInterface, column: TreeNode, nested: bool = True, - enum_dump_by_name: bool = False, parent_schema_name: Optional[str] = None, ) -> Field: """ @@ -196,34 +209,31 @@ def _column2field( :param datamodel: SQLAInterface :param column: TreeNode column (childs are dotted columns) :param nested: Boolean if will create nested fields - :param enum_dump_by_name: :return: Schema.field """ # Handle relations - if datamodel.is_relation(column.data): + if datamodel.is_relation(column.name): return self._column2relation( datamodel, column, nested=nested, parent_schema_name=parent_schema_name ) # Handle Enums - elif datamodel.is_enum(column.data): - return self._column2enum( - datamodel, column, enum_dump_by_name=enum_dump_by_name - ) + if datamodel.is_enum(column.name): + return self._column2enum(datamodel, column) # is custom property method field? - if hasattr(getattr(datamodel.obj, column.data), "fget"): + if hasattr(getattr(datamodel.obj, column.name), "fget"): return fields.Raw(dump_only=True) # its a model function - if hasattr(getattr(datamodel.obj, column.data), "__call__"): - return fields.Function(getattr(datamodel.obj, column.data), dump_only=True) + if hasattr(getattr(datamodel.obj, column.name), "__call__"): + return fields.Function(getattr(datamodel.obj, column.name), dump_only=True) # is a normal model field not a function? - if not hasattr(getattr(datamodel.obj, column.data), "__call__"): - field = field_for(datamodel.obj, column.data) - field.unique = datamodel.is_unique(column.data) - if column.data in self.validators_columns: + if not hasattr(getattr(datamodel.obj, column.name), "__call__"): + field = field_for(datamodel.obj, column.name) + field.unique = datamodel.is_unique(column.name) + if column.name in self.validators_columns: if field.validate is None: field.validate = [] - field.validate.append(self.validators_columns[column.data]) - field.validators.append(self.validators_columns[column.data]) + field.validate.append(self.validators_columns[column.name]) + field.validators.append(self.validators_columns[column.name]) return field def convert( @@ -231,9 +241,8 @@ def convert( columns: List[str], model: Optional[Type[Model]] = None, nested: bool = True, - enum_dump_by_name: bool = False, parent_schema_name: Optional[str] = None, - ): + ) -> SQLAlchemyAutoSchema: """ Creates a Marshmallow ModelSchema class @@ -257,16 +266,12 @@ class SchemaMixin: _columns = list() tree_columns = columns2Tree(columns) - for column in tree_columns.root.childs: + for column in tree_columns.root.children: # Get child model is column is dotted notation - ma_sqla_fields_override[column.data] = self._column2field( - _datamodel, - column, - nested, - enum_dump_by_name, - parent_schema_name=parent_schema_name, + ma_sqla_fields_override[column.name] = self._column2field( + _datamodel, column, nested, parent_schema_name=parent_schema_name ) - _columns.append(column.data) + _columns.append(column.name) for k, v in ma_sqla_fields_override.items(): setattr(SchemaMixin, k, v) return self._meta_schema_factory( diff --git a/flask_appbuilder/babel/manager.py b/flask_appbuilder/babel/manager.py index 523d8681e3..b011e01025 100644 --- a/flask_appbuilder/babel/manager.py +++ b/flask_appbuilder/babel/manager.py @@ -1,11 +1,10 @@ import os from flask import has_request_context, request, session +from flask_appbuilder.babel.views import LocaleView +from flask_appbuilder.basemanager import BaseManager from flask_babel import Babel -from .views import LocaleView -from ..basemanager import BaseManager - class BabelManager(BaseManager): diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index 78529bcace..14aedfc6d0 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -1,8 +1,9 @@ from functools import reduce import logging -from typing import Dict +from typing import Any, Callable, cast, Dict, List, Optional, Type, TYPE_CHECKING, Union -from flask import Blueprint, current_app, url_for +from flask import Blueprint, current_app, Flask, url_for +from sqlalchemy.orm.session import Session as SessionBase from . import __version__ from .api.manager import OpenApiManager @@ -20,14 +21,28 @@ from .menu import Menu, MenuApiManager from .views import IndexView, UtilView +if TYPE_CHECKING: + from flask_appbuilder.basemanager import BaseManager + from flask_appbuilder.baseviews import BaseView, AbstractViewApi + from flask_appbuilder.security.manager import BaseSecurityManager + log = logging.getLogger(__name__) -def dynamic_class_import(class_path): +DynamicImportType = Union[ + Type["BaseManager"], + Type["BaseView"], + Type["BaseSecurityManager"], + Type[Menu], + Type["AbstractViewApi"], +] + + +def dynamic_class_import(class_path: str) -> Optional[DynamicImportType]: """ - Will dynamically import a class from a string path - :param class_path: string with class path - :return: class + Will dynamically import a class from a string path + :param class_path: string with class path + :return: class """ # Split first occurrence of path try: @@ -37,104 +52,84 @@ def dynamic_class_import(class_path): return reduce(getattr, tmp[1:], package) except Exception as e: log.exception(e) - log.error(LOGMSG_ERR_FAB_ADDON_IMPORT.format(class_path, e)) + log.error(LOGMSG_ERR_FAB_ADDON_IMPORT, class_path, e) + return None -class AppBuilder(object): +class AppBuilder: """ + This is the base class for all the framework. + This is where you will register all your views and create the menu structure. + Will hold your flask app object, all your views, and security classes. + initialize your application like this for SQLAlchemy:: - This is the base class for all the framework. - This is were you will register all your views - and create the menu structure. - Will hold your flask app object, all your views, and security classes. + from flask import Flask + from flask_appbuilder import SQLA, AppBuilder - initialize your application like this for SQLAlchemy:: + app = Flask(__name__) + app.config.from_object('config') + db = SQLA(app) + appbuilder = AppBuilder(app, db.session) - from flask import Flask - from flask_appbuilder import SQLA, AppBuilder + When using MongoEngine:: - app = Flask(__name__) - app.config.from_object('config') - db = SQLA(app) - appbuilder = AppBuilder(app, db.session) + from flask import Flask + from flask_appbuilder import AppBuilder + from flask_appbuilder.security.mongoengine.manager import SecurityManager + from flask_mongoengine import MongoEngine - When using MongoEngine:: + app = Flask(__name__) + app.config.from_object('config') + dbmongo = MongoEngine(app) + appbuilder = AppBuilder(app, security_manager_class=SecurityManager) - from flask import Flask - from flask_appbuilder import AppBuilder - from flask_appbuilder.security.mongoengine.manager import SecurityManager - from flask_mongoengine import MongoEngine - - app = Flask(__name__) - app.config.from_object('config') - dbmongo = MongoEngine(app) - appbuilder = AppBuilder(app, security_manager_class=SecurityManager) - - You can also create everything as an application factory. + You can also create everything as an application factory. """ - baseviews = [] security_manager_class = None - # Flask app - app = None - # Database Session - session = None - # Security Manager Class - sm = None - # Babel Manager Class - bm = None - # OpenAPI Manager Class - openapi_manager = None - # dict with addon name has key and intantiated class has value - addon_managers = None - # temporary list that hold addon_managers config key - _addon_managers = None - - menu = None - indexview = None - - static_folder = None - static_url_path = None template_filters = None def __init__( self, - app=None, - session=None, - menu=None, - indexview=None, - base_template="appbuilder/baselayout.html", - static_folder="static/appbuilder", - static_url_path="/appbuilder", - security_manager_class=None, - update_perms=True, - ): - """ - AppBuilder constructor - - :param app: - The flask app object - :param session: - The SQLAlchemy session object - :param menu: - optional, a previous contructed menu - :param indexview: - optional, your customized indexview - :param static_folder: - optional, your override for the global static folder - :param static_url_path: - optional, your override for the global static url path - :param security_manager_class: - optional, pass your own security manager class - :param update_perms: - optional, update permissions flag (Boolean) you can use - FAB_UPDATE_PERMS config key also - """ - self.baseviews = [] - self._addon_managers = [] - self.addon_managers = {} + app: Optional[Flask] = None, + session: Optional[SessionBase] = None, + menu: Optional[Menu] = None, + indexview: Optional[Type["AbstractViewApi"]] = None, + base_template: str = "appbuilder/baselayout.html", + static_folder: str = "static/appbuilder", + static_url_path: str = "/appbuilder", + security_manager_class: Optional[Type["BaseSecurityManager"]] = None, + update_perms: bool = True, + ) -> None: + """ + AppBuilder init + + :param app: + The flask app object + :param session: + The SQLAlchemy session object + :param menu: + optional, a previous contructed menu + :param indexview: + optional, your customized indexview + :param static_folder: + optional, your override for the global static folder + :param static_url_path: + optional, your override for the global static url path + :param security_manager_class: + optional, pass your own security manager class + :param update_perms: + optional, update permissions flag (Boolean) you can use + FAB_UPDATE_PERMS config key also + """ + self.baseviews: List[Union[Type["AbstractViewApi"], "AbstractViewApi"]] = [] + + # temporary list that hold addon_managers config key + self._addon_managers: List[str] = [] + # dict with addon name has key and instantiated class has value + self.addon_managers: Dict[str, Any] = {} self.menu = menu self.base_template = base_template self.security_manager_class = security_manager_class @@ -144,15 +139,22 @@ def __init__( self.app = app self.update_perms = update_perms + # Security Manager Class + self.sm: BaseSecurityManager = None # type: ignore + # Babel Manager Class + self.bm: BabelManager = None # type: ignore + self.openapi_manager: OpenApiManager = None # type: ignore + self.menuapi_manager: MenuApiManager = None # type: ignore + if app is not None: self.init_app(app, session) - def init_app(self, app, session): + def init_app(self, app: Flask, session: SessionBase) -> None: """ - Will initialize the Flask app, supporting the app factory pattern. + Will initialize the Flask app, supporting the app factory pattern. - :param app: - :param session: The SQLAlchemy session + :param app: + :param session: The SQLAlchemy session """ app.config.setdefault("APP_NAME", "F.A.B.") @@ -160,6 +162,7 @@ def init_app(self, app, session): app.config.setdefault("APP_ICON", "") app.config.setdefault("LANGUAGES", {"en": {"flag": "gb", "name": "English"}}) app.config.setdefault("ADDON_MANAGERS", []) + app.config.setdefault("RATELIMIT_ENABLED", False) app.config.setdefault("FAB_API_MAX_PAGE_SIZE", 100) app.config.setdefault("FAB_BASE_TEMPLATE", self.base_template) app.config.setdefault("FAB_STATIC_FOLDER", self.static_folder) @@ -173,13 +176,18 @@ def init_app(self, app, session): "FAB_STATIC_URL_PATH", self.static_url_path ) _index_view = app.config.get("FAB_INDEX_VIEW", None) - if _index_view is not None: - self.indexview = dynamic_class_import(_index_view) + if _index_view: + self.indexview = dynamic_class_import(_index_view) # type: ignore else: self.indexview = self.indexview or IndexView + _menu = app.config.get("FAB_MENU", None) + + # Setup Menu if _menu is not None: - self.menu = dynamic_class_import(_menu) + menu = dynamic_class_import(_menu) + if menu is not None and issubclass(menu, Menu): + self.menu = menu() else: self.menu = self.menu or Menu() @@ -189,8 +197,9 @@ def init_app(self, app, session): "FAB_SECURITY_MANAGER_CLASS", None ) if _security_manager_class_name is not None: - self.security_manager_class = dynamic_class_import( - _security_manager_class_name + security_manager_class = dynamic_class_import(_security_manager_class_name) + self.security_manager_class = cast( + Type["BaseSecurityManager"], security_manager_class ) if self.security_manager_class is None: from flask_appbuilder.security.sqla.manager import SecurityManager @@ -214,16 +223,16 @@ def init_app(self, app, session): self.post_init() self._init_extension(app) - def _init_extension(self, app): + def _init_extension(self, app: Flask) -> None: app.appbuilder = self if not hasattr(app, "extensions"): app.extensions = {} app.extensions["appbuilder"] = self - def post_init(self): + def post_init(self) -> None: for baseview in self.baseviews: # instantiate the views and add session - self._check_and_init(baseview) + baseview = self._check_and_init(baseview) # Register the views has blueprints if baseview.__class__.__name__ not in self.get_app.blueprints.keys(): self.register_blueprint(baseview) @@ -231,11 +240,11 @@ def post_init(self): self.add_permissions() @property - def get_app(self): + def get_app(self) -> Flask: """ - Get current or configured flask app + Get current or configured flask app - :return: Flask App + :return: Flask App """ if self.app: return self.app @@ -243,58 +252,58 @@ def get_app(self): return current_app @property - def get_session(self): + def get_session(self) -> SessionBase: """ - Get the current sqlalchemy session. + Get the current sqlalchemy session. - :return: SQLAlchemy Session + :return: SQLAlchemy Session """ return self.session @property - def app_name(self): + def app_name(self) -> str: """ - Get the App name + Get the App name - :return: String with app name + :return: String with app name """ return self.get_app.config["APP_NAME"] @property - def app_theme(self): + def app_theme(self) -> str: """ - Get the App theme name + Get the App theme name - :return: String app theme name + :return: String app theme name """ return self.get_app.config["APP_THEME"] @property - def app_icon(self): + def app_icon(self) -> str: """ - Get the App icon location + Get the App icon location - :return: String with relative app icon location + :return: String with relative app icon location """ return self.get_app.config["APP_ICON"] @property - def languages(self): + def languages(self) -> Dict[str, Any]: return self.get_app.config["LANGUAGES"] @property - def version(self): + def version(self) -> str: """ - Get the current F.A.B. version + Get the current F.A.B. version - :return: String with the current F.A.B. version + :return: String with the current F.A.B. version """ return __version__ - def _add_global_filters(self): + def _add_global_filters(self) -> None: self.template_filters = TemplateFilters(self.get_app, self.sm) - def _add_global_static(self): + def _add_global_static(self) -> None: bp = Blueprint( "appbuilder", __name__, @@ -305,59 +314,62 @@ def _add_global_static(self): ) self.get_app.register_blueprint(bp) - def _add_admin_views(self): + def _add_admin_views(self) -> None: """ - Registers indexview, utilview (back function), babel views and Security views. + Registers indexview, utilview (back function), babel views and Security views. """ - self.indexview = self._check_and_init(self.indexview) - self.add_view_no_menu(self.indexview) - self.add_view_no_menu(UtilView()) + if self.indexview: + self._indexview = self.add_view_no_menu(self.indexview) + self.add_view_no_menu(UtilView) self.bm.register_views() self.sm.register_views() self.openapi_manager.register_views() self.menuapi_manager.register_views() - def _add_addon_views(self): + def _add_addon_views(self) -> None: """ - Registers declared addon's + Registers declared addon's """ for addon in self._addon_managers: - addon_class = dynamic_class_import(addon) + addon_class_ = dynamic_class_import(addon) + addon_class = cast(Type["BaseManager"], addon_class_) if addon_class: # Instantiate manager with appbuilder (self) - addon_class = addon_class(self) + inst_addon_class: "BaseManager" = addon_class(self) try: - addon_class.pre_process() - addon_class.register_views() - addon_class.post_process() - self.addon_managers[addon] = addon_class - log.info(LOGMSG_INF_FAB_ADDON_ADDED.format(str(addon))) + inst_addon_class.pre_process() + inst_addon_class.register_views() + inst_addon_class.post_process() + self.addon_managers[addon] = inst_addon_class + log.info(LOGMSG_INF_FAB_ADDON_ADDED, addon) except Exception as e: log.exception(e) - log.error(LOGMSG_ERR_FAB_ADDON_PROCESS.format(addon, e)) + log.error(LOGMSG_ERR_FAB_ADDON_PROCESS, addon, e) - def _check_and_init(self, baseview): + def _check_and_init( + self, baseview: Union[Type["AbstractViewApi"], "AbstractViewApi"] + ) -> "AbstractViewApi": # If class if not instantiated, instantiate it # and add db session from security models. if hasattr(baseview, "datamodel"): - if baseview.datamodel.session is None: - baseview.datamodel.session = self.session - if hasattr(baseview, "__call__"): + if getattr(baseview, "datamodel").session is None: + getattr(baseview, "datamodel").session = self.session + if isinstance(baseview, type): baseview = baseview() return baseview def add_view( self, - baseview, - name, - href="", - icon="", - label="", - category="", - category_icon="", - category_label="", - menu_cond=None, - ): + baseview: Union[Type["AbstractViewApi"], "AbstractViewApi"], + name: str, + href: str = "", + icon: str = "", + label: str = "", + category: str = "", + category_icon: str = "", + category_label: str = "", + menu_cond: Optional[Callable[..., bool]] = None, + ) -> "AbstractViewApi": """ Add your views associated with menus using this method. @@ -426,7 +438,7 @@ def add_view( appbuilder.add_link("google", href="www.google.com", icon = "fa-google-plus") """ baseview = self._check_and_init(baseview) - log.info(LOGMSG_INF_FAB_ADD_VIEW.format(baseview.__class__.__name__, name)) + log.info(LOGMSG_INF_FAB_ADD_VIEW, baseview.__class__.__name__, name) if not self._view_exists(baseview): baseview.appbuilder = self @@ -435,6 +447,7 @@ def add_view( if self.app: self.register_blueprint(baseview) self._add_permission(baseview) + self.add_limits(baseview) self.add_link( name=name, href=href, @@ -450,44 +463,47 @@ def add_view( def add_link( self, - name, - href, - icon="", - label="", - category="", - category_icon="", - category_label="", - baseview=None, - cond=None, - ): - """ - Add your own links to menu using this method - - :param name: - The string name that identifies the menu. - :param href: - Override the generated href for the menu. - You can use an url string or an endpoint name - :param icon: - Font-Awesome icon name, optional. - :param label: - The label that will be displayed on the menu, - if absent param name will be used - :param category: - The menu category where the menu will be included, - if non provided the view will be accessible as a top menu. - :param category_icon: - Font-Awesome icon name for the category, optional. - :param category_label: - The label that will be displayed on the menu, - if absent param name will be used - :param cond: - If a callable, :code:`cond` will be invoked when - constructing the menu items. If it returns :code:`True`, - then this link will be a part of the menu. Otherwise, it - will not be included in the menu items. Defaults to - :code:`None`, meaning the item will always be present. + name: str, + href: str, + icon: str = "", + label: str = "", + category: str = "", + category_icon: str = "", + category_label: str = "", + baseview: Optional["AbstractViewApi"] = None, + cond: Optional[Callable[..., bool]] = None, + ) -> None: + """ + Add your own links to menu using this method + + :param baseview: + :param name: + The string name that identifies the menu. + :param href: + Override the generated href for the menu. + You can use an url string or an endpoint name + :param icon: + Font-Awesome icon name, optional. + :param label: + The label that will be displayed on the menu, + if absent param name will be used + :param category: + The menu category where the menu will be included, + if non provided the view will be accessible as a top menu. + :param category_icon: + Font-Awesome icon name for the category, optional. + :param category_label: + The label that will be displayed on the menu, + if absent param name will be used + :param cond: + If a callable, :code:`cond` will be invoked when + constructing the menu items. If it returns :code:`True`, + then this link will be a part of the menu. Otherwise, it + will not be included in the menu items. Defaults to + :code:`None`, meaning the item will always be present. """ + if self.menu is None: + return self.menu.add_link( name=name, href=href, @@ -504,31 +520,42 @@ def add_link( if category: self._add_permissions_menu(category) - def add_separator(self, category, cond=None): + def add_separator( + self, category: str, cond: Optional[Callable[..., bool]] = None + ) -> None: """ - Add a separator to the menu, you will sequentially create the menu + Add a separator to the menu, you will sequentially create the menu - :param category: - The menu category where the separator will be included. - :param cond: - If a callable, :code:`cond` will be invoked when - constructing the menu items. If it returns :code:`True`, - then this separator will be a part of the menu. Otherwise, - it will not be included in the menu items. Defaults to - :code:`None`, meaning the separator will always be present. + :param category: + The menu category where the separator will be included. + :param cond: + If a callable, :code:`cond` will be invoked when + constructing the menu items. If it returns :code:`True`, + then this separator will be a part of the menu. Otherwise, + it will not be included in the menu items. Defaults to + :code:`None`, meaning the separator will always be present. """ + if self.menu is None: + return self.menu.add_separator(category, cond=cond) - def add_view_no_menu(self, baseview, endpoint=None, static_folder=None): + def add_view_no_menu( + self, + baseview: Union[Type["AbstractViewApi"], "AbstractViewApi"], + endpoint: Optional[str] = None, + static_folder: Optional[str] = None, + ) -> "AbstractViewApi": """ - Add your views without creating a menu. + Add your views without creating a menu. :param baseview: A BaseView type class instantiated. + :param endpoint: The endpoint path for the Flask blueprint + :param static_folder: The static folder for the Flask blueprint """ baseview = self._check_and_init(baseview) - log.info(LOGMSG_INF_FAB_ADD_VIEW.format(baseview.__class__.__name__, "")) + log.info(LOGMSG_INF_FAB_ADD_VIEW, baseview.__class__.__name__, "") if not self._view_exists(baseview): baseview.appbuilder = self @@ -539,79 +566,108 @@ def add_view_no_menu(self, baseview, endpoint=None, static_folder=None): baseview, endpoint=endpoint, static_folder=static_folder ) self._add_permission(baseview) + self.add_limits(baseview) else: - log.warning(LOGMSG_WAR_FAB_VIEW_EXISTS.format(baseview.__class__.__name__)) + log.warning(LOGMSG_WAR_FAB_VIEW_EXISTS, baseview.__class__.__name__) return baseview - def add_api(self, baseview): + def add_api(self, baseview: Type["AbstractViewApi"]) -> "AbstractViewApi": """ - Add a BaseApi class or child to AppBuilder + Add a BaseApi class or child to AppBuilder :param baseview: A BaseApi type class :return: The instantiated base view """ return self.add_view_no_menu(baseview) - def security_cleanup(self): + def security_cleanup(self) -> None: """ - This method is useful if you have changed - the name of your menus or classes, - changing them will leave behind permissions - that are not associated with anything. + This method is useful if you have changed + the name of your menus or classes, + changing them will leave behind permissions + that are not associated with anything. - You can use it always or just sometimes to - perform a security cleanup. Warning this will delete any permission - that is no longer part of any registered view or menu. + You can use it always or just sometimes to + perform a security cleanup. Warning this will delete any permission + that is no longer part of any registered view or menu. - Remember invoke ONLY AFTER YOU HAVE REGISTERED ALL VIEWS + Remember invoke ONLY AFTER YOU HAVE REGISTERED ALL VIEWS """ self.sm.security_cleanup(self.baseviews, self.menu) - def security_converge(self, dry=False) -> Dict: + def security_converge(self, dry: bool = False) -> Dict[str, Any]: """ - This method is useful when you use: + This method is useful when you use: - - `class_permission_name` - - `previous_class_permission_name` - - `method_permission_name` - - `previous_method_permission_name` + - `class_permission_name` + - `previous_class_permission_name` + - `method_permission_name` + - `previous_method_permission_name` - migrates all permissions to the new names on all the Roles + migrates all permissions to the new names on all the Roles :param dry: If True will not change DB :return: Dict with all computed necessary operations """ - return self.sm.security_converge(self.baseviews, self.menu, dry) + if self.menu is None: + return {} + return self.sm.security_converge(self.baseviews, self.menu.menu, dry) + + def get_url_for_login_with(self, next_url: str = None) -> str: + if self.sm.auth_view is None: + return "" + return url_for("%s.%s" % (self.sm.auth_view.endpoint, "login"), next=next_url) @property - def get_url_for_login(self): + def get_url_for_login(self) -> str: + if self.sm.auth_view is None: + return "" return url_for("%s.%s" % (self.sm.auth_view.endpoint, "login")) @property - def get_url_for_logout(self): + def get_url_for_logout(self) -> str: + if self.sm.auth_view is None: + return "" return url_for("%s.%s" % (self.sm.auth_view.endpoint, "logout")) @property - def get_url_for_index(self): - return url_for("%s.%s" % (self.indexview.endpoint, self.indexview.default_view)) + def get_url_for_index(self) -> str: + if self._indexview is None: + return "" + return url_for( + "%s.%s" % (self._indexview.endpoint, self._indexview.default_view) + ) @property - def get_url_for_userinfo(self): + def get_url_for_userinfo(self) -> str: + if self.sm.user_view is None: + return "" return url_for("%s.%s" % (self.sm.user_view.endpoint, "userinfo")) - def get_url_for_locale(self, lang): + def get_url_for_locale(self, lang: str) -> str: + if self.bm.locale_view is None: + return "" return url_for( "%s.%s" % (self.bm.locale_view.endpoint, self.bm.locale_view.default_view), locale=lang, ) - def add_permissions(self, update_perms=False): + def add_limits(self, baseview: "AbstractViewApi") -> None: + if hasattr(baseview, "limits"): + self.sm.add_limit_view(baseview) + + def add_permissions(self, update_perms: bool = False) -> None: + from flask_appbuilder.baseviews import AbstractViewApi + if self.update_perms or update_perms: for baseview in self.baseviews: + baseview = cast(AbstractViewApi, baseview) self._add_permission(baseview, update_perms=update_perms) self._add_menu_permissions(update_perms=update_perms) - def _add_permission(self, baseview, update_perms=False): + def _add_permission( + self, baseview: "AbstractViewApi", update_perms: bool = False + ) -> None: if self.update_perms or update_perms: try: self.sm.add_permissions_view( @@ -619,17 +675,19 @@ def _add_permission(self, baseview, update_perms=False): ) except Exception as e: log.exception(e) - log.error(LOGMSG_ERR_FAB_ADD_PERMISSION_VIEW.format(str(e))) + log.error(LOGMSG_ERR_FAB_ADD_PERMISSION_VIEW, e) - def _add_permissions_menu(self, name, update_perms=False): + def _add_permissions_menu(self, name: str, update_perms: bool = False) -> None: if self.update_perms or update_perms: try: self.sm.add_permissions_menu(name) except Exception as e: log.exception(e) - log.error(LOGMSG_ERR_FAB_ADD_PERMISSION_MENU.format(str(e))) + log.error(LOGMSG_ERR_FAB_ADD_PERMISSION_MENU, e) - def _add_menu_permissions(self, update_perms=False): + def _add_menu_permissions(self, update_perms: bool = False) -> None: + if self.menu is None: + return if self.update_perms or update_perms: for category in self.menu.get_list(): self._add_permissions_menu(category.name, update_perms=update_perms) @@ -638,21 +696,29 @@ def _add_menu_permissions(self, update_perms=False): if item.name != "-": self._add_permissions_menu(item.name, update_perms=update_perms) - def register_blueprint(self, baseview, endpoint=None, static_folder=None): + def register_blueprint( + self, + baseview: "AbstractViewApi", + endpoint: Optional[str] = None, + static_folder: Optional[str] = None, + ) -> None: self.get_app.register_blueprint( baseview.create_blueprint( self, endpoint=endpoint, static_folder=static_folder ) ) - def _view_exists(self, view): + def _view_exists(self, view: "AbstractViewApi") -> bool: for baseview in self.baseviews: if baseview.__class__ == view.__class__: return True return False - def _process_inner_views(self): + def _process_inner_views(self) -> None: + from flask_appbuilder.baseviews import AbstractViewApi + for view in self.baseviews: + view = cast(AbstractViewApi, view) for inner_class in view.get_uninit_inner_views(): for v in self.baseviews: if ( diff --git a/flask_appbuilder/baseviews.py b/flask_appbuilder/baseviews.py index 9fb0b16354..78788f84a6 100644 --- a/flask_appbuilder/baseviews.py +++ b/flask_appbuilder/baseviews.py @@ -3,6 +3,7 @@ import json import logging import re +from typing import List, Optional, TYPE_CHECKING from flask import ( abort, @@ -29,17 +30,21 @@ ) from .widgets import FormWidget, ListWidget, SearchWidget, ShowWidget +if TYPE_CHECKING: + from flask_appbuilder.base import AppBuilder + + log = logging.getLogger(__name__) def expose(url="/", methods=("GET",)): """ - Use this decorator to expose views on your view classes. + Use this decorator to expose views on your view classes. - :param url: - Relative URL for the view - :param methods: - Allowed HTTP methods. By default only GET is allowed. + :param url: + Relative URL for the view + :param methods: + Allowed HTTP methods. By default only GET is allowed. """ def wrap(f): @@ -65,14 +70,44 @@ def wrap(f): return wrap -class BaseView(object): +class AbstractViewApi: + + appbuilder: "AppBuilder" + base_permissions: Optional[List[str]] + class_permission_name: str + endpoint: str + default_view: str + + def create_blueprint( + self, + appbuilder: "AppBuilder", + endpoint: Optional[str] = None, + static_folder: Optional[str] = None, + ): + ... + + def get_uninit_inner_views(self): + """ + Will return a list with views that need to be initialized. + Normally related_views from ModelView + """ + ... + + def get_init_inner_views(self): + """ + Sets initialized inner views + """ + ... + + +class BaseView(AbstractViewApi): """ - All views inherit from this class. - it's constructor will register your exposed urls on flask as a Blueprint. + All views inherit from this class. + it's constructor will register your exposed urls on flask as a Blueprint. - This class does not expose any urls, but provides a common base for all views. + This class does not expose any urls, but provides a common base for all views. - Extend this class if you want to expose methods for your own templates + Extend this class if you want to expose methods for your own templates """ appbuilder = None @@ -146,16 +181,28 @@ class ContactModelView(ModelView): default_view = "list" """ the default view for this BaseView, to be used with url_for (method name) """ extra_args = None - """ dictionary for injecting extra arguments into template """ + + limits = None + """ + List of limits for this view. + + Use it like this if you want to restrict the rate of requests to a view: + + class MyView(ModelView): + limits = [Limit("2 per 5 second")] + + or use the decorator @limit. + """ + _apis = None def __init__(self): """ - Initialization of base permissions - based on exposed methods and actions + Initialization of base permissions + based on exposed methods and actions - Initialization of extra args + Initialization of extra args """ # Init class permission override attrs if not self.previous_class_permission_name and self.class_permission_name: @@ -177,6 +224,9 @@ def __init__(self): self.base_permissions = set() is_add_base_permissions = True + if self.limits is None: + self.limits = [] + for attr_name in dir(self): # If include_route_methods is not None white list if ( @@ -204,19 +254,21 @@ def __init__(self): _extra = getattr(getattr(self, attr_name), "_extra") for key in _extra: self._apis[key] = _extra[key] + if hasattr(getattr(self, attr_name), "_limit"): + self.limits.append(getattr(getattr(self, attr_name), "_limit")) def create_blueprint(self, appbuilder, endpoint=None, static_folder=None): """ - Create Flask blueprint. You will generally not use it + Create Flask blueprint. You will generally not use it - :param appbuilder: - the AppBuilder object - :param endpoint: - endpoint override for this blueprint, - will assume class name if not provided - :param static_folder: - the relative override for static folder, - if omitted application will use the appbuilder static + :param appbuilder: + the AppBuilder object + :param endpoint: + endpoint override for this blueprint, + will assume class name if not provided + :param static_folder: + the relative override for static folder, + if omitted application will use the appbuilder static """ # Store appbuilder instance self.appbuilder = appbuilder @@ -257,15 +309,19 @@ def _register_urls(self): continue if attr_name in self.exclude_route_methods: log.info( - f"Not registering route for method " - f"{self.__class__.__name__}.{attr_name}" + "Not registering route for method %s.%s", + self.__class__.__name__, + attr_name, ) continue attr = getattr(self, attr_name) if hasattr(attr, "_urls"): for url, methods in attr._urls: log.info( - f"Registering route {self.blueprint.url_prefix}{url} {methods}" + "Registering route %s%s %s", + self.blueprint.url_prefix, + url, + methods, ) route_handler = wrap_route_handler_with_hooks( attr_name, attr, before_request_hooks @@ -276,11 +332,11 @@ def _register_urls(self): def render_template(self, template, **kwargs): """ - Use this method on your own endpoints, will pass the extra_args - to the templates. + Use this method on your own endpoints, will pass the extra_args + to the templates. - :param template: The template relative path - :param kwargs: arguments to be passed to the template + :param template: The template relative path + :param kwargs: arguments to be passed to the template """ kwargs["base_template"] = self.appbuilder.base_template kwargs["appbuilder"] = self.appbuilder @@ -290,30 +346,30 @@ def render_template(self, template, **kwargs): def _prettify_name(self, name): """ - Prettify pythonic variable name. + Prettify pythonic variable name. - For example, 'HelloWorld' will be converted to 'Hello World' + For example, 'HelloWorld' will be converted to 'Hello World' - :param name: - Name to prettify. + :param name: + Name to prettify. """ return re.sub(r"(?<=.)([A-Z])", r" \1", name) def _prettify_column(self, name): """ - Prettify pythonic variable name. + Prettify pythonic variable name. - For example, 'hello_world' will be converted to 'Hello World' + For example, 'hello_world' will be converted to 'Hello World' - :param name: - Name to prettify. + :param name: + Name to prettify. """ return re.sub("[._]", " ", name).title() def update_redirect(self): """ - Call it on your own endpoint's to update the back history navigation. - If you bypass it, the next submit or back will go over it. + Call it on your own endpoint's to update the back history navigation. + If you bypass it, the next submit or back will go over it. """ page_history = Stack(session.get("page_history", [])) page_history.push(request.url) @@ -321,7 +377,7 @@ def update_redirect(self): def get_redirect(self): """ - Returns the previous url. + Returns the previous url. """ index_url = self.appbuilder.get_url_for_index page_history = Stack(session.get("page_history", [])) @@ -335,26 +391,25 @@ def get_redirect(self): @classmethod def get_default_url(cls, **kwargs): """ - Returns the url for this class default endpoint + Returns the url for this class default endpoint """ return url_for(cls.__name__ + "." + cls.default_view, **kwargs) def get_uninit_inner_views(self): """ - Will return a list with views that need to be initialized. - Normally related_views from ModelView + Will return a list with views that need to be initialized. + Normally related_views from ModelView """ return [] - def get_init_inner_views(self, views): + def get_init_inner_views(self): """ - Sets initialized inner views + Sets initialized inner views """ - pass def get_method_permission(self, method_name: str) -> str: """ - Returns the permission name for a method + Returns the permission name for a method """ permission = self.method_permission_name.get(method_name) if permission: @@ -365,7 +420,7 @@ def get_method_permission(self, method_name: str) -> str: class BaseFormView(BaseView): """ - Base class FormView's + Base class FormView's """ form_template = "appbuilder/general/model/edit.html" @@ -399,20 +454,18 @@ def _init_vars(self): def form_get(self, form): """ - Override this method to implement your form processing + Override this method to implement your form processing """ - pass def form_post(self, form): """ - Override this method to implement your form processing + Override this method to implement your form processing - :param form: WTForm form + :param form: WTForm form - Return None or a flask response to render - a custom template or redirect the user + Return None or a flask response to render + a custom template or redirect the user """ - pass def _get_edit_widget(self, form=None, exclude_cols=None, widgets=None): exclude_cols = exclude_cols or [] @@ -429,10 +482,10 @@ def _get_edit_widget(self, form=None, exclude_cols=None, widgets=None): class BaseModelView(BaseView): """ - The base class of ModelView and ChartView, all properties are inherited - Customize ModelView and ChartView overriding this properties + The base class of ModelView and ChartView, all properties are inherited + Customize ModelView and ChartView overriding this properties - This class supports all the basics for query + This class supports all the basics for query """ datamodel = None @@ -537,7 +590,7 @@ class MyView(ModelView): def __init__(self, **kwargs): """ - Constructor + Constructor """ datamodel = kwargs.get("datamodel", None) if datamodel: @@ -549,7 +602,7 @@ def __init__(self, **kwargs): def _gen_labels_columns(self, list_columns): """ - Auto generates pretty label_columns from list of columns + Auto generates pretty label_columns from list of columns """ for col in list_columns: if not self.label_columns.get(col): @@ -601,7 +654,7 @@ def _get_search_widget(self, form=None, exclude_cols=None, widgets=None): def _label_columns_json(self): """ - Prepares dict with labels to be JSON serializable + Prepares dict with labels to be JSON serializable """ ret = {} for key, value in list(self.label_columns.items()): @@ -611,8 +664,8 @@ def _label_columns_json(self): class BaseCRUDView(BaseModelView): """ - The base class for ModelView, all properties are inherited - Customize ModelView overriding this properties + The base class for ModelView, all properties are inherited + Customize ModelView overriding this properties """ related_views = None @@ -828,7 +881,7 @@ def __init__(self, **kwargs): def _init_forms(self): """ - Init forms for Add and Edit + Init forms for Add and Edit """ super(BaseCRUDView, self)._init_forms() conv = GeneralModelConverter(self.datamodel) @@ -853,7 +906,7 @@ def _init_forms(self): def _init_titles(self): """ - Init Titles if not defined + Init Titles if not defined """ super(BaseCRUDView, self)._init_titles() class_name = self.datamodel.model_name @@ -869,7 +922,7 @@ def _init_titles(self): def _init_properties(self): """ - Init Properties + Init Properties """ super(BaseCRUDView, self)._init_properties() # Reset init props @@ -940,7 +993,6 @@ def _get_related_view_widget( page=None, page_size=None, ): - fk = related_view.datamodel.get_related_fk(self.datamodel.obj) filters = related_view.datamodel.get_filters() # Check if it's a many to one model relation @@ -962,7 +1014,7 @@ def _get_related_view_widget( name = related_view.__name__ else: name = related_view.__class__.__name__ - log.error("Can't find relation on related view {0}".format(name)) + log.error("Can't find relation on related view %s", name) return None return related_view._get_view_widget( filters=filters, @@ -976,9 +1028,9 @@ def _get_related_views_widgets( self, item, orders=None, pages=None, page_sizes=None, widgets=None, **args ): """ - :return: - Returns a dict with 'related_views' key with a list of - Model View widgets + :return: + Returns a dict with 'related_views' key with a list of + Model View widgets """ widgets = widgets or {} widgets["related_views"] = [] @@ -1001,8 +1053,8 @@ def _get_related_views_widgets( def _get_view_widget(self, **kwargs): """ - :return: - Returns a Model View widget + :return: + Returns a Model View widget """ return self._get_list_widget(**kwargs).get("list") @@ -1018,7 +1070,7 @@ def _get_list_widget( **args, ): - """ get joined base filter and current active filter for query """ + """get joined base filter and current active filter for query""" widgets = widgets or {} actions = actions or self.actions page_size = page_size or self.page_size @@ -1095,14 +1147,14 @@ def _get_edit_widget(self, form, exclude_cols=None, widgets=None): def get_uninit_inner_views(self): """ - Will return a list with views that need to be initialized. - Normally related_views from ModelView + Will return a list with views that need to be initialized. + Normally related_views from ModelView """ return self.related_views def get_init_inner_views(self): """ - Get the list of related ModelViews after they have been initialized + Get the list of related ModelViews after they have been initialized """ return self._related_views @@ -1114,8 +1166,8 @@ def get_init_inner_views(self): def _list(self): """ - list function logic, override to implement different logic - returns list and search widget + list function logic, override to implement different logic + returns list and search widget """ if get_order_args().get(self.__class__.__name__): order_column, order_direction = get_order_args().get( @@ -1139,8 +1191,8 @@ def _list(self): def _show(self, pk): """ - show function logic, override to implement different logic - returns show and related list widget + show function logic, override to implement different logic + returns show and related list widget """ pages = get_page_args() page_sizes = get_page_size_args() @@ -1157,11 +1209,11 @@ def _show(self, pk): def _add(self): """ - Add function logic, override to implement different logic - returns add widget or None + Add function logic, override to implement different logic + returns add widget or None """ is_valid_form = True - get_filter_args(self._filters) + get_filter_args(self._filters, disallow_if_not_in_search=False) exclude_cols = self._filters.get_relation_cols() form = self.add_form.refresh() @@ -1190,14 +1242,14 @@ def _add(self): def _edit(self, pk): """ - Edit function logic, override to implement different logic - returns Edit widget and related list or None + Edit function logic, override to implement different logic + returns Edit widget and related list or None """ is_valid_form = True pages = get_page_args() page_sizes = get_page_size_args() orders = get_order_args() - get_filter_args(self._filters) + get_filter_args(self._filters, disallow_if_not_in_search=False) exclude_cols = self._filters.get_relation_cols() item = self.datamodel.get(pk, self._base_filters) @@ -1249,11 +1301,11 @@ def _edit(self, pk): def _delete(self, pk): """ - Delete function logic, override to implement different logic - deletes the record with primary_key = pk + Delete function logic, override to implement different logic + deletes the record with primary_key = pk - :param pk: - record primary key to delete + :param pk: + record primary key to delete """ item = self.datamodel.get(pk, self._base_filters) if not item: @@ -1310,7 +1362,7 @@ def date_deserializer(obj): def _fill_form_exclude_cols(self, exclude_cols, form): """ - fill the form with the suppressed cols, generated from exclude_cols + fill the form with the suppressed cols, generated from exclude_cols """ for filter_key in exclude_cols: filter_value = self._filters.get_filter_value(filter_key) @@ -1321,8 +1373,8 @@ def _fill_form_exclude_cols(self, exclude_cols, form): def is_get_mutation_allowed(self) -> bool: """ - Check is mutations on HTTP GET methods are allowed. - Always called on a request + Check is mutations on HTTP GET methods are allowed. + Always called on a request """ if current_app.config.get("FAB_ALLOW_GET_UNSAFE_MUTATIONS", False): return True @@ -1332,82 +1384,74 @@ def is_get_mutation_allowed(self) -> bool: def prefill_form(self, form, pk): """ - Override this, will be called only if the current action is rendering - an edit form (a GET request), and is used to perform additional action to - prefill the form. + Override this, will be called only if the current action is rendering + an edit form (a GET request), and is used to perform additional action to + prefill the form. - This is useful when you have added custom fields that depend on the - database contents. Fields that were added by name of a normal column - or relationship should work out of the box. + This is useful when you have added custom fields that depend on the + database contents. Fields that were added by name of a normal column + or relationship should work out of the box. - example:: + example:: - def prefill_form(self, form, pk): - if form.email.data: - form.email_confirmation.data = form.email.data + def prefill_form(self, form, pk): + if form.email.data: + form.email_confirmation.data = form.email.data """ - pass def process_form(self, form, is_created): """ - Override this, will be called only if the current action is submitting - a create/edit form (a POST request), and is used to perform additional - action before the form is used to populate the item. + Override this, will be called only if the current action is submitting + a create/edit form (a POST request), and is used to perform additional + action before the form is used to populate the item. - By default does nothing. + By default does nothing. - example:: + example:: - def process_form(self, form, is_created): - if not form.owner: - form.owner.data = 'n/a' + def process_form(self, form, is_created): + if not form.owner: + form.owner.data = 'n/a' """ - pass def pre_update(self, item): """ - Override this, this method is called before the update takes place. - If an exception is raised by this method, - the message is shown to the user and the update operation is - aborted. Because of this behavior, it can be used as a way to - implement more complex logic around updates. For instance - allowing only the original creator of the object to update it. + Override this, this method is called before the update takes place. + If an exception is raised by this method, + the message is shown to the user and the update operation is + aborted. Because of this behavior, it can be used as a way to + implement more complex logic around updates. For instance + allowing only the original creator of the object to update it. """ - pass def post_update(self, item): """ - Override this, will be called after update + Override this, will be called after update """ - pass def pre_add(self, item): """ - Override this, will be called before add. - If an exception is raised by this method, - the message is shown to the user and the add operation is aborted. + Override this, will be called before add. + If an exception is raised by this method, + the message is shown to the user and the add operation is aborted. """ - pass def post_add(self, item): """ - Override this, will be called after update + Override this, will be called after update """ - pass def pre_delete(self, item): """ - Override this, will be called before delete - If an exception is raised by this method, - the message is shown to the user and the delete operation is - aborted. Because of this behavior, it can be used as a way to - implement more complex logic around deletes. For instance - allowing only the original creator of the object to delete it. + Override this, will be called before delete + If an exception is raised by this method, + the message is shown to the user and the delete operation is + aborted. Because of this behavior, it can be used as a way to + implement more complex logic around deletes. For instance + allowing only the original creator of the object to delete it. """ - pass def post_delete(self, item): """ - Override this, will be called after delete + Override this, will be called after delete """ - pass diff --git a/flask_appbuilder/cli.py b/flask_appbuilder/cli.py index 32062f59b0..5823039bfa 100644 --- a/flask_appbuilder/cli.py +++ b/flask_appbuilder/cli.py @@ -8,6 +8,7 @@ import click from flask import current_app from flask.cli import with_appcontext +import jinja2 from .const import AUTH_DB, AUTH_LDAP, AUTH_OAUTH, AUTH_OID, AUTH_REMOTE_USER @@ -22,6 +23,14 @@ "https://github.com/dpgaspar/Flask-AppBuilder-Skeleton-AddOn/archive/master.zip" ) +MIN_SECRET_KEY_SIZE = 20 + + +def validate_secret_key(ctx, param, value): + if len(value) < MIN_SECRET_KEY_SIZE: + raise click.BadParameter(f"SECRET_KEY size is less then {MIN_SECRET_KEY_SIZE}") + return value + def echo_header(title): click.echo(click.style(title, fg="green")) @@ -45,7 +54,7 @@ def cast_int_like_to_int(cli_arg: Union[None, str, int]) -> Union[None, str, int @click.group() def fab(): - """ FAB flask group commands""" + """FAB flask group commands""" pass @@ -58,7 +67,7 @@ def fab(): @with_appcontext def create_admin(username, firstname, lastname, email, password): """ - Creates an admin user + Creates an admin user """ auth_type = { AUTH_DB: "Database Authentications", @@ -105,7 +114,7 @@ def create_admin(username, firstname, lastname, email, password): @with_appcontext def create_user(role, username, firstname, lastname, email, password): """ - Create a user + Create a user """ user = current_app.appbuilder.sm.find_user(username=username) if user: @@ -139,7 +148,7 @@ def create_user(role, username, firstname, lastname, email, password): @with_appcontext def reset_password(username, password): """ - Resets a user's password + Resets a user's password """ user = current_app.appbuilder.sm.find_user(username=username) if not user: @@ -153,7 +162,7 @@ def reset_password(username, password): @with_appcontext def create_db(): """ - Create all your database objects (SQLAlchemy specific). + Create all your database objects (SQLAlchemy specific). """ from flask_appbuilder.models.sqla import Model @@ -181,7 +190,7 @@ def export_roles( "--path", "-p", help="Path to a JSON file containing roles", required=True ) def import_roles(path: str) -> None: - """ Imports roles with permissions and view menus from JSON file """ + """Imports roles with permissions and view menus from JSON file""" current_app.appbuilder.sm.import_roles(path) @@ -189,7 +198,7 @@ def import_roles(path: str) -> None: @with_appcontext def version(): """ - Flask-AppBuilder package version + Flask-AppBuilder package version """ click.echo( click.style( @@ -204,7 +213,7 @@ def version(): @with_appcontext def security_cleanup(): """ - Cleanup unused permissions from views and roles. + Cleanup unused permissions from views and roles. """ current_app.appbuilder.security_cleanup() click.echo(click.style("Finished security cleanup", fg="green")) @@ -217,7 +226,7 @@ def security_cleanup(): @with_appcontext def security_converge(dry_run=False): """ - Converges security deletes previous_class_permission_name + Converges security deletes previous_class_permission_name """ state_transitions = current_app.appbuilder.security_converge(dry=dry_run) if dry_run: @@ -242,7 +251,7 @@ def security_converge(dry_run=False): @with_appcontext def create_permissions(): """ - Creates all permissions and add them to the ADMIN Role. + Creates all permissions and add them to the ADMIN Role. """ current_app.appbuilder.add_permissions(update_perms=True) click.echo(click.style("Created all permissions", fg="green")) @@ -252,7 +261,7 @@ def create_permissions(): @with_appcontext def list_views(): """ - List all registered views + List all registered views """ echo_header("List of registered views") for view in current_app.appbuilder.baseviews: @@ -267,7 +276,7 @@ def list_views(): @with_appcontext def list_users(): """ - List all users on the database + List all users on the database """ echo_header("List of users") for user in current_app.appbuilder.sm.get_all_users(): @@ -291,9 +300,18 @@ def list_users(): default="SQLAlchemy", help="Write your engine type", ) -def create_app(name, engine): +@click.option( + "--secret-key", + prompt="Your app SECRET_KEY. It should be a long random string. Minimal size is 20", + callback=validate_secret_key, + help="This secret key is used by Flask for" + "securely signing the session cookie and can be used for any other security" + "related needs by extensions or your application." + "It should be a long random bytes or str", +) +def create_app(name: str, engine: str, secret_key: str) -> None: """ - Create a Skeleton application (needs internet connection to github) + Create a Skeleton application (needs internet connection to github) """ try: if engine.lower() == "sqlalchemy": @@ -305,6 +323,14 @@ def create_app(name, engine): zipfile = ZipFile(BytesIO(url.read())) zipfile.extractall() os.rename(dirname, name) + + template_filename = os.path.join(os.path.abspath(name), "config.py.tpl") + config_filename = os.path.join(os.path.abspath(name), "config.py") + template = jinja2.Template(open(template_filename).read()) + rendered_template = template.render({"secret_key": secret_key}) + with open(config_filename, "w") as fd: + fd.write(rendered_template) + click.echo(click.style("Downloaded the skeleton app, good coding!", fg="green")) return True except Exception as e: @@ -332,7 +358,7 @@ def create_app(name, engine): ) def create_addon(name): """ - Create a Skeleton AddOn (needs internet connection to github) + Create a Skeleton AddOn (needs internet connection to github) """ try: full_name = "fab_addon_" + name @@ -362,7 +388,7 @@ def create_addon(name): ) def collect_static(static_folder): """ - Copies flask-appbuilder static files to your projects static folder + Copies flask-appbuilder static files to your projects static folder """ appbuilder_static_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "static/appbuilder" @@ -398,7 +424,7 @@ def collect_static(static_folder): ) def babel_extract(config, input, output, target, keywords): """ - Babel, Extracts and updates all messages marked for translation + Babel, Extracts and updates all messages marked for translation """ click.echo( click.style( @@ -427,7 +453,7 @@ def babel_extract(config, input, output, target, keywords): ) def babel_compile(target): """ - Babel, Compiles all translations + Babel, Compiles all translations """ click.echo(click.style("Starting Compile target:{0}".format(target), fg="green")) os.popen("pybabel compile -f -d {0}".format(target)) diff --git a/flask_appbuilder/const.py b/flask_appbuilder/const.py index 74c1b77e7e..db3030cc9d 100644 --- a/flask_appbuilder/const.py +++ b/flask_appbuilder/const.py @@ -17,111 +17,109 @@ """ -LOGMSG_ERR_SEC_ACCESS_DENIED = "Access is Denied for: {0} on: {1}" +LOGMSG_ERR_SEC_ACCESS_DENIED = "Access is Denied for: %s on: %s" """ Access denied log message, format with user and view/resource """ -LOGMSG_WAR_SEC_LOGIN_FAILED = "Login Failed for user: {0}" -LOGMSG_ERR_SEC_CREATE_DB = "DB Creation and initialization failed: {0}" +LOGMSG_WAR_SEC_LOGIN_FAILED = "Login Failed for user: %s" +LOGMSG_ERR_SEC_CREATE_DB = "DB Creation and initialization failed: %s" """ security models creation fails, format with error message """ -LOGMSG_ERR_SEC_ADD_ROLE = "Add Role: {0}" +LOGMSG_ERR_SEC_ADD_ROLE = "Add Role: %s" """ Error adding role, format with err message """ -LOGMSG_ERR_SEC_ADD_PERMISSION = "Add Permission: {0}" +LOGMSG_ERR_SEC_ADD_PERMISSION = "Add Permission: %s" """ Error adding permission, format with err message """ -LOGMSG_ERR_SEC_ADD_VIEWMENU = "Add View Menu Error: {0}" +LOGMSG_ERR_SEC_ADD_VIEWMENU = "Add View Menu Error: %s" """ Error adding view menu, format with err message """ -LOGMSG_ERR_SEC_DEL_PERMISSION = "Del Permission Error: {0}" +LOGMSG_ERR_SEC_DEL_PERMISSION = "Del Permission Error: %s" """ Error deleting permission, format with err message """ -LOGMSG_ERR_SEC_ADD_PERMVIEW = "Creation of Permission View Error: {0}" +LOGMSG_ERR_SEC_ADD_PERMVIEW = "Creation of Permission View Error: %s" """ Error adding permission view, format with err message """ -LOGMSG_ERR_SEC_DEL_PERMVIEW = "Remove Permission from View Error: {0}" +LOGMSG_ERR_SEC_DEL_PERMVIEW = "Remove Permission from View Error: %s" """ Error deleting permission view, format with err message """ LOGMSG_WAR_SEC_DEL_PERMVIEW = ( - "Refused to delete permission view, assoc with role exists {}.{} {}" + "Refused to delete permission view, assoc with role exists %s.%s %s" ) -LOGMSG_WAR_SEC_DEL_PERMISSION = "Refused to delete, permission {} does not exist" -LOGMSG_WAR_SEC_DEL_VIEWMENU = "Refused to delete, view menu {} does not exist" -LOGMSG_WAR_SEC_DEL_PERM_PVM = "Refused to delete permission {}, PVM exists {}" -LOGMSG_WAR_SEC_DEL_VIEWMENU_PVM = "Refused to delete view menu {}, PVM exists {}" -LOGMSG_ERR_SEC_ADD_PERMROLE = "Add Permission to Role Error: {0}" +LOGMSG_WAR_SEC_DEL_PERMISSION = "Refused to delete, permission %s does not exist" +LOGMSG_WAR_SEC_DEL_VIEWMENU = "Refused to delete, view menu %s does not exist" +LOGMSG_WAR_SEC_DEL_PERM_PVM = "Refused to delete permission %s, PVM exists %s" +LOGMSG_WAR_SEC_DEL_VIEWMENU_PVM = "Refused to delete view menu %s, PVM exists %s" +LOGMSG_ERR_SEC_ADD_PERMROLE = "Add Permission to Role Error: %s" """ Error adding permission to role, format with err message """ -LOGMSG_ERR_SEC_DEL_PERMROLE = "Remove Permission to Role Error: {0}" +LOGMSG_ERR_SEC_DEL_PERMROLE = "Remove Permission to Role Error: %s" """ Error deleting permission to role, format with err message """ -LOGMSG_ERR_SEC_ADD_REGISTER_USER = "Add Register User Error: {0}" +LOGMSG_ERR_SEC_ADD_REGISTER_USER = "Add Register User Error: %s" """ Error adding registered user, format with err message """ -LOGMSG_ERR_SEC_DEL_REGISTER_USER = "Remove Register User Error: {0}" +LOGMSG_ERR_SEC_DEL_REGISTER_USER = "Remove Register User Error: %s" """ Error deleting registered user, format with err message """ -LOGMSG_ERR_SEC_NO_REGISTER_HASH = "Attempt to activate user with false hash: {0}" +LOGMSG_ERR_SEC_NO_REGISTER_HASH = "Attempt to activate user with false hash: %s" """ Attempt to activate user with not registered hash, format with hash """ -LOGMSG_ERR_SEC_AUTH_LDAP = "LDAP Error {0}" +LOGMSG_ERR_SEC_AUTH_LDAP = "LDAP Error %s" """ Generic LDAP error, format with err message """ LOGMSG_ERR_SEC_AUTH_LDAP_TLS = ( - "LDAP Could not activate TLS on established connection with {0}" + "LDAP Could not activate TLS on established connection with %s" ) """ LDAP Could not activate TLS on established connection with server """ -LOGMSG_ERR_SEC_ADD_USER = "Error adding new user to database. {0}" +LOGMSG_ERR_SEC_ADD_USER = "Error adding new user to database. %s" """ Error adding user, format with err message """ -LOGMSG_ERR_SEC_UPD_USER = "Error updating user to database. {0} " +LOGMSG_ERR_SEC_UPD_USER = "Error updating user to database. %s " """ Error updating user, format with err message """ LOGMSG_WAR_SEC_NO_USER = "No user yet created, use flask fab command to do it." """ Warning when app starts if no user exists on db """ -LOGMSG_WAR_SEC_NOLDAP_OBJ = "No LDAP object found for: {0}" +LOGMSG_WAR_SEC_NOLDAP_OBJ = "No LDAP object found for: %s" -LOGMSG_INF_SEC_ADD_PERMVIEW = "Created Permission View: {0}" +LOGMSG_INF_SEC_ADD_PERMVIEW = "Created Permission View: %s" """ Info when adding permission view, format with permission view class string """ -LOGMSG_INF_SEC_DEL_PERMVIEW = "Removed Permission View: {0} on {1}" +LOGMSG_INF_SEC_DEL_PERMVIEW = "Removed Permission View: %s on %s" """ Info when deleting permission view, format with permission name and view name """ -LOGMSG_INF_SEC_ADD_PERMROLE = "Added Permission {0} to role {1}" +LOGMSG_INF_SEC_ADD_PERMROLE = "Added Permission %s to role %s" """ Info when adding permission to role, format with permission view class string and role name """ -LOGMSG_INF_SEC_DEL_PERMROLE = "Removed Permission {0} to role {1}" +LOGMSG_INF_SEC_DEL_PERMROLE = "Removed Permission %s to role %s" """ Info when deleting permission to role, format with permission view class string and role name """ -LOGMSG_INF_SEC_ADD_ROLE = "Inserted Role: {0}" +LOGMSG_INF_SEC_ADD_ROLE = "Inserted Role: %s" """ Info when added role, format with role name """ LOGMSG_INF_SEC_NO_DB = "Security DB not found Creating all Models from Base" LOGMSG_INF_SEC_ADD_DB = "Security DB Created" -LOGMSG_INF_SEC_ADD_USER = "Added user {0}" +LOGMSG_INF_SEC_ADD_USER = "Added user %s" """ User added, format with username """ -LOGMSG_INF_SEC_UPD_USER = "Updated user {0}" +LOGMSG_INF_SEC_UPD_USER = "Updated user %s" """ User updated, format with username """ -LOGMSG_INF_SEC_UPD_ROLE = "Updated role {0}" +LOGMSG_INF_SEC_UPD_ROLE = "Updated role %s" """ Role updated, format with role name """ -LOGMSG_ERR_SEC_UPD_ROLE = "An error occurred updating role {0}" +LOGMSG_ERR_SEC_UPD_ROLE = "An error occurred updating role %s" """ Role updated Error, format with role name """ -LOGMSG_INF_FAB_ADDON_ADDED = "Registered AddOn: {0}" +LOGMSG_INF_FAB_ADDON_ADDED = "Registered AddOn: %s" """ Addon imported and registered """ -LOGMSG_ERR_FAB_ADDON_IMPORT = "An error occurred when importing declared addon {0}: {1}" +LOGMSG_ERR_FAB_ADDON_IMPORT = "An error occurred when importing declared addon %s: %s" """ Error on addon import, format with addon class path and error message """ -LOGMSG_ERR_FAB_ADDON_PROCESS = ( - "An error occurred when processing declared addon {0}: {1}" -) +LOGMSG_ERR_FAB_ADDON_PROCESS = "An error occurred when processing declared addon %s: %s" """ Error on addon processing (pre, register, post), format with addon class path and error message """ -LOGMSG_ERR_FAB_ADD_PERMISSION_MENU = "Add Permission on Menu Error: {0}" +LOGMSG_ERR_FAB_ADD_PERMISSION_MENU = "Add Permission on Menu Error: %s" """ Error when adding a permission to a menu, format with err """ -LOGMSG_ERR_FAB_ADD_PERMISSION_VIEW = "Add Permission on View Error: {0}" +LOGMSG_ERR_FAB_ADD_PERMISSION_VIEW = "Add Permission on View Error: %s" """ Error when adding a permission to a menu, format with err """ -LOGMSG_ERR_DBI_ADD_GENERIC = "Add record error: {0}" +LOGMSG_ERR_DBI_ADD_GENERIC = "Add record error: %s" """ Database add generic error, format with err message """ -LOGMSG_ERR_DBI_EDIT_GENERIC = "Edit record error: {0}" +LOGMSG_ERR_DBI_EDIT_GENERIC = "Edit record error: %s" """ Database edit generic error, format with err message """ -LOGMSG_ERR_DBI_DEL_GENERIC = "Delete record error: {0}" +LOGMSG_ERR_DBI_DEL_GENERIC = "Delete record error: %s" """ Database delete generic error, format with err message """ LOGMSG_WAR_DBI_AVG_ZERODIV = "Zero division on aggregate_avg" -LOGMSG_WAR_FAB_VIEW_EXISTS = "View already exists {0} ignoring" +LOGMSG_WAR_FAB_VIEW_EXISTS = "View already exists %s ignoring" """ Attempt to add an already added view, format with view name """ -LOGMSG_WAR_DBI_ADD_INTEGRITY = "Add record integrity error: {0}" +LOGMSG_WAR_DBI_ADD_INTEGRITY = "Add record integrity error: %s" """ Dabase integrity error, format with err message """ -LOGMSG_WAR_DBI_EDIT_INTEGRITY = "Edit record integrity error: {0}" +LOGMSG_WAR_DBI_EDIT_INTEGRITY = "Edit record integrity error: %s" """ Dabase integrity error, format with err message """ -LOGMSG_WAR_DBI_DEL_INTEGRITY = "Delete record integrity error: {0}" +LOGMSG_WAR_DBI_DEL_INTEGRITY = "Delete record integrity error: %s" """ Dabase integrity error, format with err message """ -LOGMSG_INF_FAB_ADD_VIEW = "Registering class {0} on menu {1}" +LOGMSG_INF_FAB_ADD_VIEW = "Registering class %s on menu %s" """ Inform that view class was added, format with class name, name""" diff --git a/flask_appbuilder/fieldwidgets.py b/flask_appbuilder/fieldwidgets.py index 7654e2cb4b..3ddc714653 100644 --- a/flask_appbuilder/fieldwidgets.py +++ b/flask_appbuilder/fieldwidgets.py @@ -4,7 +4,7 @@ from wtforms.widgets import html_params -class DatePickerWidget(object): +class DatePickerWidget: """ Date Time picker from Eonasdan GitHub @@ -30,7 +30,7 @@ def __call__(self, field, **kwargs): ) -class DateTimePickerWidget(object): +class DateTimePickerWidget: """ Date Time picker from Eonasdan GitHub @@ -58,7 +58,7 @@ def __call__(self, field, **kwargs): class BS3TextFieldWidget(widgets.TextInput): def __call__(self, field, **kwargs): - kwargs["class"] = u"form-control" + kwargs["class"] = "form-control" if field.label: kwargs["placeholder"] = field.label.text if "name_" in kwargs: @@ -68,7 +68,7 @@ def __call__(self, field, **kwargs): class BS3TextAreaFieldWidget(widgets.TextArea): def __call__(self, field, **kwargs): - kwargs["class"] = u"form-control" + kwargs["class"] = "form-control" kwargs["rows"] = 3 if field.label: kwargs["placeholder"] = field.label.text @@ -77,25 +77,26 @@ def __call__(self, field, **kwargs): class BS3PasswordFieldWidget(widgets.PasswordInput): def __call__(self, field, **kwargs): - kwargs["class"] = u"form-control" + kwargs["class"] = "form-control" if field.label: kwargs["placeholder"] = field.label.text return super(BS3PasswordFieldWidget, self).__call__(field, **kwargs) -class Select2AJAXWidget(object): +class Select2AJAXWidget: data_template = "" def __init__(self, endpoint, extra_classes=None, style=None): self.endpoint = endpoint self.extra_classes = extra_classes - self.style = style or u"width:250px" + self.style = style or "" def __call__(self, field, **kwargs): kwargs.setdefault("id", field.id) kwargs.setdefault("name", field.name) kwargs.setdefault("endpoint", self.endpoint) - kwargs.setdefault("style", self.style) + if self.style: + kwargs.setdefault("style", self.style) input_classes = "input-group my_select2_ajax" if self.extra_classes: input_classes = input_classes + " " + self.extra_classes @@ -109,21 +110,22 @@ def __call__(self, field, **kwargs): ) -class Select2SlaveAJAXWidget(object): +class Select2SlaveAJAXWidget: data_template = '' def __init__(self, master_id, endpoint, extra_classes=None, style=None): self.endpoint = endpoint self.master_id = master_id self.extra_classes = extra_classes - self.style = style or u"width:250px" + self.style = style or "" def __call__(self, field, **kwargs): kwargs.setdefault("id", field.id) kwargs.setdefault("name", field.name) kwargs.setdefault("endpoint", self.endpoint) kwargs.setdefault("master_id", self.master_id) - kwargs.setdefault("style", self.style) + if self.style: + kwargs.setdefault("style", self.style) input_classes = "input-group my_select2_ajax" if self.extra_classes: input_classes = input_classes + " " + self.extra_classes @@ -143,14 +145,15 @@ class Select2Widget(widgets.Select): def __init__(self, extra_classes=None, style=None): self.extra_classes = extra_classes - self.style = style or u"width:250px" + self.style = style super(Select2Widget, self).__init__() def __call__(self, field, **kwargs): - kwargs["class"] = u"my_select2 form-control" + kwargs["class"] = "my_select2 form-control" if self.extra_classes: kwargs["class"] = kwargs["class"] + " " + self.extra_classes - kwargs["style"] = self.style + if self.style: + kwargs["style"] = self.style kwargs["data-placeholder"] = _("Select Value") if "name_" in kwargs: field.name = kwargs["name_"] @@ -162,16 +165,17 @@ class Select2ManyWidget(widgets.Select): def __init__(self, extra_classes=None, style=None): self.extra_classes = extra_classes - self.style = style or u"width:250px" + self.style = style super(Select2ManyWidget, self).__init__() def __call__(self, field, **kwargs): - kwargs["class"] = u"my_select2 form-control" + kwargs["class"] = "my_select2 form-control" if self.extra_classes: kwargs["class"] = kwargs["class"] + " " + self.extra_classes - kwargs["style"] = self.style + if self.style: + kwargs["style"] = self.style kwargs["data-placeholder"] = _("Select Value") - kwargs["multiple"] = u"true" + kwargs["multiple"] = "true" if "name_" in kwargs: field.name = kwargs["name_"] return super(Select2ManyWidget, self).__call__(field, **kwargs) diff --git a/flask_appbuilder/filemanager.py b/flask_appbuilder/filemanager.py index 2658b17634..3f1404056b 100644 --- a/flask_appbuilder/filemanager.py +++ b/flask_appbuilder/filemanager.py @@ -243,7 +243,7 @@ def get_file_original_name(name): """ Use this function to get the user's original filename. Filename is concatenated with _sep_, to avoid collisions. - Use this function on your models on an aditional function + Use this function on your models on an additional function :: diff --git a/flask_appbuilder/forms.py b/flask_appbuilder/forms.py index 994ad2e875..509bdc074c 100644 --- a/flask_appbuilder/forms.py +++ b/flask_appbuilder/forms.py @@ -104,7 +104,7 @@ def convert(self): validators=self.validators, default=self.default, ) - log.error("Column %s Type not supported" % self.colname) + log.error("Column %s Type not supported", self.colname) class GeneralModelConverter(object): @@ -188,13 +188,11 @@ def _convert_many_to_many( ): query_func = self._get_related_query_func(col_name, filter_rel_fields) get_pk_func = self._get_related_pk_func(col_name) - allow_blank = True form_props[col_name] = QuerySelectMultipleField( label, description=description, query_func=query_func, get_pk_func=get_pk_func, - allow_blank=allow_blank, validators=lst_validators, widget=Select2ManyWidget(), ) @@ -259,7 +257,7 @@ def _convert_col( form_props, ) else: - log.warning("Relation {0} not supported".format(col_name)) + log.warning("Relation %s not supported", col_name) else: return self._convert_simple( col_name, label, description, lst_validators, form_props diff --git a/flask_appbuilder/models/base.py b/flask_appbuilder/models/base.py index 6a1529e6e1..3d42730382 100644 --- a/flask_appbuilder/models/base.py +++ b/flask_appbuilder/models/base.py @@ -41,6 +41,8 @@ class BaseInterface: ) general_error_message = lazy_gettext("General Error") + database_error_message = lazy_gettext("Database Error") + """ Tuple with message and text with severity type ex: ("Added Row", "info") """ message = () @@ -103,13 +105,13 @@ def get_values_item(self, item, show_columns): def _get_values(self, lst, list_columns): """ - Get Values: formats values for list template. - returns [{'col_name':'col_value',....},{'col_name':'col_value',....}] + Get Values: formats values for list template. + returns [{'col_name':'col_value',....},{'col_name':'col_value',....}] - :param lst: - The list of item objects from query - :param list_columns: - The list of columns to include + :param lst: + The list of item objects from query + :param list_columns: + The list of columns to include """ retlst = [] for item in lst: @@ -121,13 +123,13 @@ def _get_values(self, lst, list_columns): def get_values(self, lst, list_columns): """ - Get Values: formats values for list template. - returns [{'col_name':'col_value',....},{'col_name':'col_value',....}] + Get Values: formats values for list template. + returns [{'col_name':'col_value',....},{'col_name':'col_value',....}] - :param lst: - The list of item objects from query - :param list_columns: - The list of columns to include + :param lst: + The list of item objects from query + :param list_columns: + The list of columns to include """ for item in lst: retdict = {} @@ -137,7 +139,7 @@ def get_values(self, lst, list_columns): def get_values_json(self, lst, list_columns): """ - Converts list of objects from query to JSON + Converts list of objects from query to JSON """ result = [] for item in self.get_values(lst, list_columns): @@ -264,19 +266,19 @@ def get_min_length(self, col_name): def add(self, item): """ - Adds object + Adds object """ raise NotImplementedError def edit(self, item): """ - Edit (change) object + Edit (change) object """ raise NotImplementedError def delete(self, item): """ - Deletes object + Deletes object """ raise NotImplementedError @@ -285,7 +287,7 @@ def get_col_default(self, col_name): def get_keys(self, lst): """ - return a list of pk values from object list + return a list of pk values from object list """ pk_name = self.get_pk_name() if self.is_pk_composite(): @@ -295,7 +297,7 @@ def get_keys(self, lst): def get_pk_name(self): """ - Returns the primary key name + Returns the primary key name """ raise NotImplementedError @@ -308,8 +310,8 @@ def get_pk_value(self, item): def get(self, pk, filter=None): """ - return the record from key, you can optionally pass filters - if pk exits on the db but filters exclude it it will return none. + return the record from key, you can optionally pass filters + if pk exits on the db but filters exclude it it will return none. """ pass @@ -318,11 +320,11 @@ def get_related_model(self, prop): def get_related_interface(self, col_name): """ - Returns a BaseInterface for the related model - of column name. + Returns a BaseInterface for the related model + of column name. - :param col_name: Column name with relation - :return: BaseInterface + :param col_name: Column name with relation + :return: BaseInterface """ raise NotImplementedError @@ -334,25 +336,25 @@ def get_related_fk(self, model): def get_columns_list(self): """ - Returns a list of all the columns names + Returns a list of all the columns names """ return [] def get_user_columns_list(self): """ - Returns a list of user viewable columns names + Returns a list of user viewable columns names """ return self.get_columns_list() def get_search_columns_list(self): """ - Returns a list of searchable columns names + Returns a list of searchable columns names """ return [] def get_order_columns_list(self, list_columns=None): """ - Returns a list of order columns names + Returns a list of order columns names """ return [] diff --git a/flask_appbuilder/models/filters.py b/flask_appbuilder/models/filters.py index fc12a03989..493302920a 100644 --- a/flask_appbuilder/models/filters.py +++ b/flask_appbuilder/models/filters.py @@ -114,7 +114,7 @@ def convert(self, col_name): for conversion in self.conversion_table: if getattr(self.datamodel, conversion[0])(col_name): return [item(col_name, self.datamodel) for item in conversion[1]] - log.warning("Filter type not supported for column: %s" % col_name) + log.warning("Filter type not supported for column: %s", col_name) class Filters(object): @@ -240,7 +240,7 @@ def add_filter_list(self, active_filter_list=None): self._add_filter(filter_class(column_name, self.datamodel), value) return self - def get_joined_filters(self, filters): + def get_joined_filters(self, filters) -> "Filters": """ Creates a new filters class with active filters joined """ diff --git a/flask_appbuilder/models/group.py b/flask_appbuilder/models/group.py index f3e5cc4839..0182b9f046 100644 --- a/flask_appbuilder/models/group.py +++ b/flask_appbuilder/models/group.py @@ -255,7 +255,7 @@ def to_dict(self, data): for group_col_data, i in zip(item[0], enumerate(item[0])): row[self.group_bys_cols[i]] = str(group_col_data) for col_data, i in zip(item[1:], enumerate(item[1:])): - log.debug("{0},{1}".format(col_data, i)) + log.debug("%s,%s", col_data, i) key = self.aggr_by_cols[i].__name__ + self.aggr_by_cols[i] if isinstance(col_data, datetime.date): row[key] = str(col_data) diff --git a/flask_appbuilder/models/mixins.py b/flask_appbuilder/models/mixins.py index c44f0fec8e..ab6985d05f 100644 --- a/flask_appbuilder/models/mixins.py +++ b/flask_appbuilder/models/mixins.py @@ -1,4 +1,4 @@ -import datetime +from datetime import datetime import logging from flask import g @@ -46,11 +46,11 @@ class AuditMixin(object): :changed by: """ - created_on = Column(DateTime, default=datetime.datetime.now, nullable=False) + created_on = Column(DateTime, default=lambda: datetime.now(), nullable=False) changed_on = Column( DateTime, - default=datetime.datetime.now, - onupdate=datetime.datetime.now, + default=lambda: datetime.now(), + onupdate=lambda: datetime.now(), nullable=False, ) diff --git a/flask_appbuilder/models/mongoengine/fields.py b/flask_appbuilder/models/mongoengine/fields.py index bce85f917f..bbae95301e 100644 --- a/flask_appbuilder/models/mongoengine/fields.py +++ b/flask_appbuilder/models/mongoengine/fields.py @@ -24,17 +24,17 @@ class MongoFileField(fields.FileField): widget = BS3FileUploadFieldWidget() def __init__(self, label=None, validators=None, **kwargs): - super(MongoFileField, self).__init__(label, validators, **kwargs) + super().__init__(label, validators, **kwargs) self._should_delete = False - def process(self, formdata, data=unset_value): + def process(self, formdata, data=unset_value, **kwargs): if formdata: marker = "_%s-delete" % self.name if marker in formdata: self._should_delete = True - return super(MongoFileField, self).process(formdata, data) + return super().process(formdata, data, **kwargs) def populate_obj(self, obj, name): field = getattr(obj, name, None) diff --git a/flask_appbuilder/models/mongoengine/interface.py b/flask_appbuilder/models/mongoengine/interface.py index 68eb1bf18e..95baf2fd9f 100644 --- a/flask_appbuilder/models/mongoengine/interface.py +++ b/flask_appbuilder/models/mongoengine/interface.py @@ -79,9 +79,9 @@ def query( if page_size is None: # error checking and warnings if page is not None: - log.error("Attempting to get page %s but page_size is undefined" % page) + log.error("Attempting to get page %s but page_size is undefined", page) if count > 100: - log.warn("Retrieving %s %s items from DB" % (count, str(self.obj))) + log.warn("Retrieving %s %s items from DB", count, self.obj) else: # get data segment for paginated page offset = (page or 0) * page_size objs = objs[offset : offset + page_size] @@ -207,7 +207,7 @@ def add(self, item): as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])), "danger", ) - log.exception(LOGMSG_ERR_DBI_ADD_GENERIC.format(str(e))) + log.exception(LOGMSG_ERR_DBI_ADD_GENERIC, e) return False def edit(self, item): @@ -220,7 +220,7 @@ def edit(self, item): as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])), "danger", ) - log.exception(LOGMSG_ERR_DBI_EDIT_GENERIC.format(str(e))) + log.exception(LOGMSG_ERR_DBI_EDIT_GENERIC, e) return False def delete(self, item): @@ -233,7 +233,7 @@ def delete(self, item): as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])), "danger", ) - log.exception(LOGMSG_ERR_DBI_DEL_GENERIC.format(str(e))) + log.exception(LOGMSG_ERR_DBI_DEL_GENERIC, e) return False def get_columns_list(self): diff --git a/flask_appbuilder/models/sqla/filters.py b/flask_appbuilder/models/sqla/filters.py index 3848683cc9..bf7c9a1ba3 100644 --- a/flask_appbuilder/models/sqla/filters.py +++ b/flask_appbuilder/models/sqla/filters.py @@ -34,13 +34,13 @@ def get_field_setup_query(query, model, column_name): """ - Help function for SQLA filters, checks for dot notation on column names. - If it exists, will join the query with the model - from the first part of the field name. + Help function for SQLA filters, checks for dot notation on column names. + If it exists, will join the query with the model + from the first part of the field name. - example: - Contact.created_by: if created_by is a User model, - it will be joined to the query. + example: + Contact.created_by: if created_by is a User model, + it will be joined to the query. """ if not hasattr(model, column_name): # it's an inner obj attr @@ -195,7 +195,11 @@ def apply(self, query, value): logging.warning( "Filter exception for %s with value %s, will not apply", field, value ) - self.datamodel.session.rollback() + try: + self.datamodel.session.rollback() + except SQLAlchemyError: + # on MSSQL a rollback would fail here + pass raise ApplyFilterException(exception=exc) return query.filter(field == rel_obj) @@ -212,7 +216,11 @@ def apply(self, query, value): logging.warning( "Filter exception for %s with value %s, will not apply", field, value ) - self.datamodel.session.rollback() + try: + self.datamodel.session.rollback() + except SQLAlchemyError: + # on MSSQL a rollback would fail here + pass raise ApplyFilterException(exception=exc) return query.filter(field != rel_obj) @@ -234,7 +242,11 @@ def apply_item(self, query, field, value_item): field, value_item, ) - self.datamodel.session.rollback() + try: + self.datamodel.session.rollback() + except SQLAlchemyError: + # on MSSQL a rollback would fail here + pass raise ApplyFilterException(exception=exc) if rel_obj: @@ -279,8 +291,8 @@ def apply(self, query, func): class SQLAFilterConverter(BaseFilterConverter): """ - Class for converting columns into a supported list of filters - specific for SQLAlchemy. + Class for converting columns into a supported list of filters + specific for SQLAlchemy. """ diff --git a/flask_appbuilder/models/sqla/interface.py b/flask_appbuilder/models/sqla/interface.py index 79e602bb6c..eea96fcc9e 100644 --- a/flask_appbuilder/models/sqla/interface.py +++ b/flask_appbuilder/models/sqla/interface.py @@ -1,14 +1,11 @@ # -*- coding: utf-8 -*- from contextlib import suppress import logging -import sys from typing import Any, Dict, List, Optional, Tuple, Type, Union from flask_appbuilder._compat import as_unicode from flask_appbuilder.const import ( - LOGMSG_ERR_DBI_ADD_GENERIC, LOGMSG_ERR_DBI_DEL_GENERIC, - LOGMSG_ERR_DBI_EDIT_GENERIC, LOGMSG_WAR_DBI_ADD_INTEGRITY, LOGMSG_WAR_DBI_DEL_INTEGRITY, LOGMSG_WAR_DBI_EDIT_INTEGRITY, @@ -39,7 +36,6 @@ from sqlalchemy.sql.sqltypes import TypeEngine from sqlalchemy_utils.types.uuid import UUIDType - log = logging.getLogger(__name__) @@ -76,8 +72,8 @@ def __init__(self, obj: Type[Model], session: Optional[SessionBase] = None) -> N @property def model_name(self): """ - Returns the models class name - useful for auto title on views + Returns the models class name + useful for auto title on views """ return self.obj.__name__ @@ -255,63 +251,79 @@ def apply_inner_select_joins( """ if not select_columns: return query - joined_models = list() + + joined_models = [] for column in select_columns: - if is_column_dotted(column): - root_relation = get_column_root_relation(column) - leaf_column = get_column_leaf(column) - if self.is_relation_many_to_one( - root_relation - ) or self.is_relation_one_to_one(root_relation): - if root_relation not in joined_models: - query = self._query_join_relation( - query, root_relation, aliases_mapping=aliases_mapping - ) - query = query.add_entity( - self.get_alias_mapping(root_relation, aliases_mapping) - ) - # Add relation FK to avoid N+1 performance issue - query = self._apply_relation_fks_select_options( - query, root_relation - ) - joined_models.append(root_relation) - - related_model_ = self.get_alias_mapping( - root_relation, aliases_mapping + if not is_column_dotted(column): + query = self._apply_normal_col_select_option(query, column) + continue + + # Dotted column + root_relation = get_column_root_relation(column) + leaf_column = get_column_leaf(column) + related_model = self.get_alias_mapping(root_relation, aliases_mapping) + relation = getattr(self.obj, root_relation) + + if self.is_relation_many_to_one( + root_relation + ) or self.is_relation_many_to_many_special(root_relation): + if root_relation not in joined_models: + query = self._query_join_relation( + query, root_relation, aliases_mapping=aliases_mapping ) - relation = getattr(self.obj, root_relation) - # The Zen of eager loading :( - # https://docs.sqlalchemy.org/en/13/orm/loading_relationships.html - query = query.options( - contains_eager(relation.of_type(related_model_)).load_only( - leaf_column - ) + query = query.add_entity( + self.get_alias_mapping(root_relation, aliases_mapping) ) - query = query.options(Load(related_model_).load_only(leaf_column)) - else: - query = self._apply_normal_col_select_option(query, column) + # Add relation FK to avoid N+1 performance issue + query = self._apply_relation_fks_select_options( + query, root_relation + ) + joined_models.append(root_relation) + + related_model = self.get_alias_mapping(root_relation, aliases_mapping) + relation = getattr(self.obj, root_relation) + # The Zen of eager loading :( + # https://docs.sqlalchemy.org/en/13/orm/loading_relationships.html + query = query.options( + contains_eager(relation.of_type(related_model)).load_only( + leaf_column + ) + ) + query = query.options(Load(related_model).load_only(leaf_column)) return query def apply_outer_select_joins( - self, query: Query, select_columns: List[str] = None + self, + query: Query, + select_columns: List[str] = None, + outer_default_load: bool = False, ) -> Query: if not select_columns: return query + for column in select_columns: - if is_column_dotted(column): - root_relation = get_column_root_relation(column) - leaf_column = get_column_leaf(column) - if self.is_relation_many_to_many( - root_relation - ) or self.is_relation_one_to_many(root_relation): + if not is_column_dotted(column): + query = self._apply_normal_col_select_option(query, column) + continue + + root_relation = get_column_root_relation(column) + leaf_column = get_column_leaf(column) + + if self.is_relation_many_to_many( + root_relation + ) or self.is_relation_one_to_many(root_relation): + if outer_default_load: query = query.options( - Load(self.obj).joinedload(root_relation).load_only(leaf_column) + Load(self.obj).defaultload(root_relation).load_only(leaf_column) ) else: - related_model = self.get_related_model(root_relation) - query = query.options(Load(related_model).load_only(leaf_column)) + query = query.options( + Load(self.obj).joinedload(root_relation).load_only(leaf_column) + ) else: - query = self._apply_normal_col_select_option(query, column) + related_model = self.get_related_model(root_relation) + query = query.options(Load(related_model).load_only(leaf_column)) + return query def get_inner_filters(self, filters: Optional[Filters]) -> Filters: @@ -329,8 +341,10 @@ def get_inner_filters(self, filters: Optional[Filters]) -> Filters: if not is_column_dotted(flt.column_name): _filters.append((flt.column_name, flt.__class__, value)) elif self.is_relation_many_to_one( - flt.column_name - ) or self.is_relation_one_to_one(flt.column_name): + get_column_root_relation(flt.column_name) + ) or self.is_relation_one_to_one( + get_column_root_relation(flt.column_name) + ): _filters.append((flt.column_name, flt.__class__, value)) inner_filters.add_filter_list(_filters) return inner_filters @@ -392,6 +406,7 @@ def apply_all( page: Optional[int] = None, page_size: Optional[int] = None, select_columns: Optional[List[str]] = None, + outer_default_load: bool = False, ) -> Query: """ Accepts a SQLAlchemy Query and applies all filtering logic, order by and @@ -410,6 +425,11 @@ def apply_all( the current page size :param select_columns: A List of columns to be specifically selected on the query + :param outer_default_load: If True, the default load for outer joins will be + applied. This is useful for when you want to control + the load of the many-to-many relationships at the model level. + we will apply: + https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html#sqlalchemy.orm.Load.defaultload :return: A SQLAlchemy Query with all the applied logic """ aliases_mapping = {} @@ -428,7 +448,9 @@ def apply_all( if select_columns and order_column: select_columns = select_columns + [order_column] outer_query = inner_query.from_self() - outer_query = self.apply_outer_select_joins(outer_query, select_columns) + outer_query = self.apply_outer_select_joins( + outer_query, select_columns, outer_default_load=outer_default_load + ) return self.apply_order_by(outer_query, order_column, order_direction) else: return inner_query @@ -441,6 +463,7 @@ def query( page: Optional[int] = None, page_size: Optional[int] = None, select_columns: Optional[List[str]] = None, + outer_default_load: bool = False, ) -> Tuple[int, List[Model]]: """ Returns the results for a model query, applies filters, sorting and pagination @@ -452,6 +475,11 @@ def query( :param page_size: the current page size :param select_columns: A List of columns to be specifically selected on the query. Supports dotted notation. + :param outer_default_load: If True, the default load for outer joins will be + applied. This is useful for when you want to control + the load of the many-to-many relationships at the model level. + we will apply: + https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html#sqlalchemy.orm.Load.defaultload :return: A tuple with the query count (non paginated) and the results """ if not self.session: @@ -470,7 +498,7 @@ def query( ) query_results = query.all() - result = list() + result = [] for item in query_results: if hasattr(item, self.obj.__name__): result.append(getattr(item, self.obj.__name__)) @@ -599,7 +627,17 @@ def is_relation_many_to_one(self, col_name: str) -> bool: def is_relation_many_to_many(self, col_name: str) -> bool: try: if self.is_relation(col_name): - return self.list_properties[col_name].direction.name == "MANYTOMANY" + relation = self.list_properties[col_name] + return relation.direction.name == "MANYTOMANY" + return False + except KeyError: + return False + + def is_relation_many_to_many_special(self, col_name: str) -> bool: + try: + if self.is_relation(col_name): + relation = self.list_properties[col_name] + return relation.direction.name == "ONETOONE" and relation.uselist return False except KeyError: return False @@ -607,7 +645,10 @@ def is_relation_many_to_many(self, col_name: str) -> bool: def is_relation_one_to_one(self, col_name: str) -> bool: try: if self.is_relation(col_name): - return self.list_properties[col_name].direction.name == "ONETOONE" + relation = self.list_properties[col_name] + return self.list_properties[col_name].direction.name == "ONETOONE" or ( + relation.direction.name == "ONETOMANY" and relation.uselist is False + ) return False except KeyError: return False @@ -615,7 +656,8 @@ def is_relation_one_to_one(self, col_name: str) -> bool: def is_relation_one_to_many(self, col_name: str) -> bool: try: if self.is_relation(col_name): - return self.list_properties[col_name].direction.name == "ONETOMANY" + relation = self.list_properties[col_name] + return relation.direction.name == "ONETOMANY" and relation.uselist return False except KeyError: return False @@ -685,17 +727,14 @@ def add(self, item: Model, raise_exception: bool = False) -> bool: return True except IntegrityError as e: self.message = (as_unicode(self.add_integrity_error_message), "warning") - log.warning(LOGMSG_WAR_DBI_ADD_INTEGRITY.format(str(e))) + log.warning(LOGMSG_WAR_DBI_ADD_INTEGRITY, e) self.session.rollback() if raise_exception: raise e return False except Exception as e: - self.message = ( - as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])), - "danger", - ) - log.exception(LOGMSG_ERR_DBI_ADD_GENERIC.format(str(e))) + self.message = (as_unicode(self.database_error_message), "danger") + log.exception("Database error") self.session.rollback() if raise_exception: raise e @@ -709,17 +748,14 @@ def edit(self, item: Model, raise_exception: bool = False) -> bool: return True except IntegrityError as e: self.message = (as_unicode(self.edit_integrity_error_message), "warning") - log.warning(LOGMSG_WAR_DBI_EDIT_INTEGRITY.format(str(e))) + log.warning(LOGMSG_WAR_DBI_EDIT_INTEGRITY, e) self.session.rollback() if raise_exception: raise e return False except Exception as e: - self.message = ( - as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])), - "danger", - ) - log.exception(LOGMSG_ERR_DBI_EDIT_GENERIC.format(str(e))) + self.message = (as_unicode(self.database_error_message), "danger") + log.exception("Database error") self.session.rollback() if raise_exception: raise e @@ -734,17 +770,14 @@ def delete(self, item: Model, raise_exception: bool = False) -> bool: return True except IntegrityError as e: self.message = (as_unicode(self.delete_integrity_error_message), "warning") - log.warning(LOGMSG_WAR_DBI_DEL_INTEGRITY.format(str(e))) + log.warning(LOGMSG_WAR_DBI_DEL_INTEGRITY, e) self.session.rollback() if raise_exception: raise e return False except Exception as e: - self.message = ( - as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])), - "danger", - ) - log.exception(LOGMSG_ERR_DBI_DEL_GENERIC.format(str(e))) + self.message = (as_unicode(self.database_error_message), "danger") + log.exception("Database error") self.session.rollback() if raise_exception: raise e @@ -760,15 +793,12 @@ def delete_all(self, items: List[Model]) -> bool: return True except IntegrityError as e: self.message = (as_unicode(self.delete_integrity_error_message), "warning") - log.warning(LOGMSG_WAR_DBI_DEL_INTEGRITY.format(str(e))) + log.warning(LOGMSG_WAR_DBI_DEL_INTEGRITY, e) self.session.rollback() return False except Exception as e: - self.message = ( - as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])), - "danger", - ) - log.exception(LOGMSG_ERR_DBI_DEL_GENERIC.format(str(e))) + self.message = (as_unicode(self.database_error_message), "danger") + log.exception(LOGMSG_ERR_DBI_DEL_GENERIC, e) self.session.rollback() return False @@ -790,15 +820,13 @@ def _add_files(self, this_request, item: Model): def _delete_files(self, item: Model): for file_col in self.get_file_column_list(): - if self.is_file(file_col): - if getattr(item, file_col): - fm = FileManager() - fm.delete_file(getattr(item, file_col)) + if self.is_file(file_col) and getattr(item, file_col): + fm = FileManager() + fm.delete_file(getattr(item, file_col)) for file_col in self.get_image_column_list(): - if self.is_image(file_col): - if getattr(item, file_col): - im = ImageManager() - im.delete_file(getattr(item, file_col)) + if self.is_image(file_col) and getattr(item, file_col): + im = ImageManager() + im.delete_file(getattr(item, file_col)) """ ------------------------------ @@ -808,15 +836,20 @@ def _delete_files(self, item: Model): def get_col_default(self, col_name: str) -> Any: default = getattr(self.list_columns[col_name], "default", None) - if default is not None: - value = getattr(default, "arg", None) - if value is not None: - if getattr(default, "is_callable", False): - return lambda: default.arg(None) - else: - if not getattr(default, "is_scalar", True): - return None - return value + if default is None: + return None + + value = getattr(default, "arg", None) + if value is None: + return None + + if getattr(default, "is_callable", False): + return lambda: default.arg(None) + + if not getattr(default, "is_scalar", True): + return None + + return value def get_related_model(self, col_name: str) -> Type[Model]: return self.list_properties[col_name].mapper.class_ @@ -872,15 +905,15 @@ def get_user_columns_list(self) -> List[str]: """ Returns all model's columns except pk or fk """ - ret_lst = list() - for col_name in self.get_columns_list(): - if (not self.is_pk(col_name)) and (not self.is_fk(col_name)): - ret_lst.append(col_name) - return ret_lst + return [ + col_name + for col_name in self.get_columns_list() + if (not self.is_pk(col_name)) and (not self.is_fk(col_name)) + ] # TODO get different solution, more integrated with filters def get_search_columns_list(self) -> List[str]: - ret_lst = list() + ret_lst = [] for col_name in self.get_columns_list(): if not self.is_relation(col_name): tmp_prop = self.get_property_first_col(col_name).name @@ -897,22 +930,25 @@ def get_search_columns_list(self) -> List[str]: def get_order_columns_list(self, list_columns: List[str] = None) -> List[str]: """ - Returns the columns that can be ordered + Returns the columns that can be ordered. :param list_columns: optional list of columns name, if provided will use this list only. """ - ret_lst = list() + ret_lst = [] list_columns = list_columns or self.get_columns_list() + for col_name in list_columns: - if not self.is_relation(col_name): - if hasattr(self.obj, col_name): - if not hasattr(getattr(self.obj, col_name), "__call__") or hasattr( - getattr(self.obj, col_name), "_col_name" - ): - ret_lst.append(col_name) - else: + if self.is_relation(col_name): + continue + + if hasattr(self.obj, col_name): + attribute = getattr(self.obj, col_name) + if not callable(attribute) or hasattr(attribute, "_col_name"): ret_lst.append(col_name) + else: + ret_lst.append(col_name) + return ret_lst def get_file_column_list(self) -> List[str]: @@ -942,6 +978,7 @@ def get( id, filters: Optional[Filters] = None, select_columns: Optional[List[str]] = None, + outer_default_load: bool = False, ) -> Optional[Model]: """ Returns the result for a model get, applies filters and supports dotted @@ -966,7 +1003,10 @@ def get( _filters.add_filter(pk, self.FilterEqual, id) query = self.session.query(self.obj) item = self.apply_all( - query, _filters, select_columns=select_columns + query, + _filters, + select_columns=select_columns, + outer_default_load=outer_default_load, ).one_or_none() if item: if hasattr(item, self.obj.__name__): diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py index 014d74c46a..59c893c9c7 100644 --- a/flask_appbuilder/security/api.py +++ b/flask_appbuilder/security/api.py @@ -13,7 +13,7 @@ create_access_token, create_refresh_token, get_jwt_identity, - jwt_refresh_token_required, + jwt_required, ) from marshmallow import ValidationError @@ -115,7 +115,7 @@ def login(self) -> Response: return self.response(200, **resp) @expose("/refresh", methods=["POST"]) - @jwt_refresh_token_required + @jwt_required(refresh=True) @safe def refresh(self) -> Response: """ diff --git a/flask_appbuilder/security/decorators.py b/flask_appbuilder/security/decorators.py index 0091d74773..740580b08c 100644 --- a/flask_appbuilder/security/decorators.py +++ b/flask_appbuilder/security/decorators.py @@ -1,6 +1,6 @@ import functools import logging -from typing import TYPE_CHECKING +from typing import Callable, List, Optional, TypeVar, Union from flask import ( current_app, @@ -18,28 +18,19 @@ LOGMSG_ERR_SEC_ACCESS_DENIED, PERMISSION_PREFIX, ) +from flask_appbuilder.utils.limit import Limit from flask_jwt_extended import verify_jwt_in_request +from flask_limiter.wrappers import RequestLimit from flask_login import current_user - +from typing_extensions import ParamSpec log = logging.getLogger(__name__) -if TYPE_CHECKING: - from flask_appbuilder.api import BaseApi - +R = TypeVar("R") +P = ParamSpec("P") -def response_unauthorized(base_class: "BaseApi") -> Response: - if current_app.config.get("AUTH_STRICT_RESPONSE_CODES", False): - return base_class.response_403() - return base_class.response_401() - -def response_unauthorized_mvc() -> Response: - status_code = 401 - if current_app.appbuilder.sm.current_user and current_app.config.get( - "AUTH_STRICT_RESPONSE_CODES", False - ): - status_code = 403 +def response_unauthorized_mvc(status_code: int) -> Response: response = make_response( jsonify({"message": str(FLAMSG_ERR_SEC_ACCESS_DENIED), "severity": "danger"}), status_code, @@ -88,7 +79,7 @@ def wraps(self, *args, **kwargs): class_permission_name = self.class_permission_name # Check if permission is allowed on the class if permission_str not in self.base_permissions: - return response_unauthorized(self) + return self.response_403() # Check if the resource is public if current_app.appbuilder.sm.is_item_public( permission_str, class_permission_name @@ -112,11 +103,9 @@ def wraps(self, *args, **kwargs): ): return f(self, *args, **kwargs) log.warning( - LOGMSG_ERR_SEC_ACCESS_DENIED.format( - permission_str, class_permission_name - ) + LOGMSG_ERR_SEC_ACCESS_DENIED, permission_str, class_permission_name ) - return response_unauthorized(self) + return self.response_403() f._permission_name = permission_str return functools.update_wrapper(wraps, f) @@ -148,9 +137,7 @@ def wraps(self, *args, **kwargs): return f(self, *args, **kwargs) else: log.warning( - LOGMSG_ERR_SEC_ACCESS_DENIED.format( - permission_str, self.__class__.__name__ - ) + LOGMSG_ERR_SEC_ACCESS_DENIED, permission_str, self.__class__.__name__ ) flash(as_unicode(FLAMSG_ERR_SEC_ACCESS_DENIED), "danger") return redirect( @@ -190,11 +177,11 @@ def wraps(self, *args, **kwargs): return f(self, *args, **kwargs) else: log.warning( - LOGMSG_ERR_SEC_ACCESS_DENIED.format( - permission_str, self.__class__.__name__ - ) + LOGMSG_ERR_SEC_ACCESS_DENIED, permission_str, self.__class__.__name__ ) - return response_unauthorized_mvc() + if not current_user.is_authenticated: + return response_unauthorized_mvc(401) + return response_unauthorized_mvc(403) f._permission_name = permission_str return functools.update_wrapper(wraps, f) @@ -241,3 +228,68 @@ def wraps(f): return f return wraps + + +def limit( + limit_value: Union[str, Callable[[], str]], + key_func: Optional[Callable[[], str]] = None, + per_method: bool = False, + methods: Optional[List[str]] = None, + error_message: Optional[str] = None, + exempt_when: Optional[Callable[[], bool]] = None, + override_defaults: bool = True, + deduct_when: Optional[Callable[[Response], bool]] = None, + on_breach: Optional[Callable[[RequestLimit], Optional[Response]]] = None, + cost: Union[int, Callable[[], int]] = 1, +): + """ + Decorator to be used for rate limiting individual routes or blueprints. + + :param limit_value: rate limit string or a callable that returns a + string. :ref:`ratelimit-string` for more details. + :param key_func: function/lambda to extract the unique + identifier for the rate limit. defaults to remote address of the + request. + :param per_method: whether the limit is sub categorized into the + http method of the request. + :param methods: if specified, only the methods in this list will + be rate limited (default: ``None``). + :param error_message: string (or callable that returns one) to override + the error message used in the response. + :param exempt_when: function/lambda used to decide if the rate + limit should skipped. + :param override_defaults: whether the decorated limit overrides + the default limits (Default: ``True``). + + .. note:: When used with a :class:`~BaseView` the meaning + of the parameter extends to any parents the blueprint instance is + registered under. For more details see :ref:`recipes:nested blueprints` + + :param deduct_when: a function that receives the current + :class:`flask.Response` object and returns True/False to decide if a + deduction should be done from the rate limit + :param on_breach: a function that will be called when this limit + is breached. If the function returns an instance of :class:`flask.Response` + that will be the response embedded into the :exc:`RateLimitExceeded` exception + raised. + :param cost: The cost of a hit or a function that + takes no parameters and returns the cost as an integer (Default: ``1``). + """ + + def wraps(f: Callable[P, R]) -> Callable[P, R]: + _limit = Limit( + limit_value=limit_value, + key_func=key_func, + per_method=per_method, + methods=methods, + error_message=error_message, + exempt_when=exempt_when, + override_defaults=override_defaults, + deduct_when=deduct_when, + on_breach=on_breach, + cost=cost, + ) + f._limit = _limit + return f + + return wraps diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index bd173a6813..ae4fbe96a2 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -5,10 +5,12 @@ import re from typing import Any, Dict, List, Optional, Set, Tuple, Union -from flask import g, session, url_for +from flask import Flask, g, session, url_for from flask_babel import lazy_gettext as _ from flask_jwt_extended import current_user as current_user_jwt from flask_jwt_extended import JWTManager +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from flask_login import current_user, LoginManager from werkzeug.security import check_password_hash, generate_password_hash @@ -60,51 +62,51 @@ class AbstractSecurityManager(BaseManager): """ - Abstract SecurityManager class, declares all methods used by the - framework. There is no assumptions about security models or auth types. + Abstract SecurityManager class, declares all methods used by the + framework. There is no assumptions about security models or auth types. """ def add_permissions_view(self, base_permissions, view_menu): """ - Adds a permission on a view menu to the backend + Adds a permission on a view menu to the backend - :param base_permissions: - list of permissions from view (all exposed methods): - 'can_add','can_edit' etc... - :param view_menu: - name of the view or menu to add + :param base_permissions: + list of permissions from view (all exposed methods): + 'can_add','can_edit' etc... + :param view_menu: + name of the view or menu to add """ raise NotImplementedError def add_permissions_menu(self, view_menu_name): """ - Adds menu_access to menu on permission_view_menu + Adds menu_access to menu on permission_view_menu - :param view_menu_name: - The menu name + :param view_menu_name: + The menu name """ raise NotImplementedError def register_views(self): """ - Generic function to create the security views + Generic function to create the security views """ raise NotImplementedError def is_item_public(self, permission_name, view_name): """ - Check if view has public permissions + Check if view has public permissions - :param permission_name: - the permission: can_show, can_edit... - :param view_name: - the name of the class view (child of BaseView) + :param permission_name: + the permission: can_show, can_edit... + :param view_name: + the name of the class view (child of BaseView) """ raise NotImplementedError def has_access(self, permission_name, view_name): """ - Check if current user or public has access to view or menu + Check if current user or public has access to view or menu """ raise NotImplementedError @@ -120,11 +122,11 @@ def noop_user_update(self, user) -> None: def _oauth_tokengetter(token=None): """ - Default function to return the current user oauth token - from session cookie. + Default function to return the current user oauth token + from session cookie. """ token = session.get("oauth") - log.debug("Token Get: {0}".format(token)) + log.debug("Token Get: %s", token) return token @@ -255,6 +257,10 @@ def __init__(self, appbuilder): app.config.setdefault("AUTH_LDAP_LASTNAME_FIELD", "sn") app.config.setdefault("AUTH_LDAP_EMAIL_FIELD", "mail") + # Rate limiting + app.config.setdefault("AUTH_RATE_LIMITED", False) + app.config.setdefault("AUTH_RATE_LIMIT", "10 per 20 second") + if self.auth_type == AUTH_OID: from flask_openid import OpenID @@ -266,7 +272,7 @@ def __init__(self, appbuilder): self.oauth_remotes = dict() for _provider in self.oauth_providers: provider_name = _provider["name"] - log.debug("OAuth providers init {0}".format(provider_name)) + log.debug("OAuth providers init %s", provider_name) obj_provider = self.oauth.register( provider_name, **_provider["remote_app"] ) @@ -285,11 +291,19 @@ def __init__(self, appbuilder): # Setup Flask-Jwt-Extended self.jwt_manager = self.create_jwt_manager(app) + # Setup Flask-Limiter + self.limiter = self.create_limiter(app) + + def create_limiter(self, app: Flask) -> Limiter: + limiter = Limiter(key_func=get_remote_address) + limiter.init_app(app) + return limiter + def create_login_manager(self, app) -> LoginManager: """ - Override to implement your custom login manager instance + Override to implement your custom login manager instance - :param app: Flask app + :param app: Flask app """ lm = LoginManager(app) lm.login_view = "login" @@ -298,13 +312,13 @@ def create_login_manager(self, app) -> LoginManager: def create_jwt_manager(self, app) -> JWTManager: """ - Override to implement your custom JWT manager instance + Override to implement your custom JWT manager instance - :param app: Flask app + :param app: Flask app """ jwt_manager = JWTManager() jwt_manager.init_app(app) - jwt_manager.user_loader_callback_loader(self.load_user_jwt) + jwt_manager.user_lookup_loader(self.load_user_jwt) return jwt_manager def create_builtin_roles(self): @@ -331,9 +345,8 @@ def get_roles_from_keys(self, role_keys: List[str]) -> Set[role_model]: _roles.add(fab_role) else: log.warning( - "Can't find role specified in AUTH_ROLES_MAPPING: {0}".format( - fab_role_name - ) + "Can't find role specified in AUTH_ROLES_MAPPING: %s", + fab_role_name, ) return _roles @@ -489,6 +502,14 @@ def openid_providers(self): def oauth_providers(self): return self.appbuilder.get_app.config["OAUTH_PROVIDERS"] + @property + def is_auth_limited(self) -> bool: + return self.appbuilder.get_app.config["AUTH_RATE_LIMITED"] + + @property + def auth_rate_limit(self) -> str: + return self.appbuilder.get_app.config["AUTH_RATE_LIMIT"] + @property def current_user(self): if current_user.is_authenticated: @@ -498,21 +519,21 @@ def current_user(self): def oauth_user_info_getter(self, f): """ - Decorator function to be the OAuth user info getter - for all the providers, receives provider and response - return a dict with the information returned from the provider. - The returned user info dict should have it's keys with the same - name as the User Model. + Decorator function to be the OAuth user info getter + for all the providers, receives provider and response + return a dict with the information returned from the provider. + The returned user info dict should have it's keys with the same + name as the User Model. - Use it like this an example for GitHub :: + Use it like this an example for GitHub :: - @appbuilder.sm.oauth_user_info_getter - def my_oauth_user_info(sm, provider, response=None): - if provider == 'github': - me = sm.oauth_remotes[provider].get('user') - return {'username': me.data.get('login')} - else: - return {} + @appbuilder.sm.oauth_user_info_getter + def my_oauth_user_info(sm, provider, response=None): + if provider == 'github': + me = sm.oauth_remotes[provider].get('user') + return {'username': me.data.get('login')} + else: + return {} """ def wraps(provider, response=None): @@ -521,7 +542,8 @@ def wraps(provider, response=None): if not type(ret) == dict: log.error( "OAuth user info decorated function " - "did not returned a dict, but: {0}".format(type(ret)) + "did not returned a dict, but: %s", + type(ret), ) return {} return ret @@ -531,9 +553,9 @@ def wraps(provider, response=None): def get_oauth_token_key_name(self, provider): """ - Returns the token_key name for the oauth provider - if none is configured defaults to oauth_token - this is configured using OAUTH_PROVIDERS and token_key key. + Returns the token_key name for the oauth provider + if none is configured defaults to oauth_token + this is configured using OAUTH_PROVIDERS and token_key key. """ for _provider in self.oauth_providers: if _provider["name"] == provider: @@ -541,9 +563,9 @@ def get_oauth_token_key_name(self, provider): def get_oauth_token_secret_name(self, provider): """ - Returns the token_secret name for the oauth provider - if none is configured defaults to oauth_secret - this is configured using OAUTH_PROVIDERS and token_secret + Returns the token_secret name for the oauth provider + if none is configured defaults to oauth_secret + this is configured using OAUTH_PROVIDERS and token_secret """ for _provider in self.oauth_providers: if _provider["name"] == provider: @@ -551,7 +573,7 @@ def get_oauth_token_secret_name(self, provider): def set_oauth_session(self, provider, oauth_response): """ - Set the current session with OAuth user secrets + Set the current session with OAuth user secrets """ # Get this provider key names for token_key and token_secret token_key = self.appbuilder.sm.get_oauth_token_key_name(provider) @@ -565,20 +587,20 @@ def set_oauth_session(self, provider, oauth_response): def get_oauth_user_info(self, provider, resp): """ - Since there are different OAuth API's with different ways to - retrieve user info + Since there are different OAuth API's with different ways to + retrieve user info """ # for GITHUB if provider == "github" or provider == "githublocal": me = self.appbuilder.sm.oauth_remotes[provider].get("user") data = me.json() - log.debug("User info from Github: {0}".format(data)) + log.debug("User info from Github: %s", data) return {"username": "github_" + data.get("login")} # for twitter if provider == "twitter": me = self.appbuilder.sm.oauth_remotes[provider].get("account/settings.json") data = me.json() - log.debug("User info from Twitter: {0}".format(data)) + log.debug("User info from Twitter: %s", data) return {"username": "twitter_" + data.get("screen_name", "")} # for linkedin if provider == "linkedin": @@ -586,7 +608,7 @@ def get_oauth_user_info(self, provider, resp): "people/~:(id,email-address,first-name,last-name)?format=json" ) data = me.json() - log.debug("User info from Linkedin: {0}".format(data)) + log.debug("User info from Linkedin: %s", data) return { "username": "linkedin_" + data.get("id", ""), "email": data.get("email-address", ""), @@ -597,7 +619,7 @@ def get_oauth_user_info(self, provider, resp): if provider == "google": me = self.appbuilder.sm.oauth_remotes[provider].get("userinfo") data = me.json() - log.debug("User info from Google: {0}".format(data)) + log.debug("User info from Google: %s", data) return { "username": "google_" + data.get("id", ""), "first_name": data.get("given_name", ""), @@ -610,11 +632,11 @@ def get_oauth_user_info(self, provider, resp): # https://docs.microsoft.com/en-us/azure/active-directory/develop/ # active-directory-protocols-oauth-code if provider == "azure": - log.debug("Azure response received : {0}".format(resp)) + log.debug("Azure response received : %s", resp) id_token = resp["id_token"] log.debug(str(id_token)) me = self._azure_jwt_token_parse(id_token) - log.debug("Parse JWT token : {0}".format(me)) + log.debug("Parse JWT token : %s", me) return { "name": me.get("name", ""), "email": me["upn"], @@ -630,7 +652,7 @@ def get_oauth_user_info(self, provider, resp): "apis/user.openshift.io/v1/users/~" ) data = me.json() - log.debug("User info from OpenShift: {0}".format(data)) + log.debug("User info from OpenShift: %s", data) return {"username": "openshift_" + data.get("metadata").get("name")} # for Okta if provider == "okta": @@ -644,6 +666,20 @@ def get_oauth_user_info(self, provider, resp): "email": data.get("email", ""), "role_keys": data.get("groups", []), } + # for Keycloak + if provider in ["keycloak", "keycloak_before_17"]: + me = self.appbuilder.sm.oauth_remotes[provider].get( + "openid-connect/userinfo" + ) + me.raise_for_status() + data = me.json() + log.debug("User info from Keycloak: %s", data) + return { + "username": data.get("preferred_username", ""), + "first_name": data.get("given_name", ""), + "last_name": data.get("family_name", ""), + "email": data.get("email", ""), + } else: return {} @@ -721,6 +757,13 @@ def register_views(self): self.appbuilder.add_view_no_menu(self.auth_view) + # this needs to be done after the view is added, otherwise the blueprint + # is not initialized + if self.is_auth_limited: + self.limiter.limit(self.auth_rate_limit, methods=["POST"])( + self.auth_view.blueprint + ) + self.user_view = self.appbuilder.add_view( self.user_view, "List Users", @@ -752,7 +795,7 @@ def register_views(self): if self.auth_user_registration: self.appbuilder.add_view( self.registerusermodelview, - "User's Statistics", + "User Registrations", icon="fa-user-plus", label=_("User Registrations"), category="Security", @@ -787,7 +830,7 @@ def register_views(self): def create_db(self): """ - Setups the DB, creates admin and public roles if they don't exist. + Setups the DB, creates admin and public roles if they don't exist. """ roles_mapping = self.appbuilder.get_app.config.get("FAB_ROLES_MAPPING", {}) for pk, name in roles_mapping.items(): @@ -806,13 +849,13 @@ def create_db(self): def reset_password(self, userid, password): """ - Change/Reset a user's password for authdb. - Password will be hashed and saved. + Change/Reset a user's password for authdb. + Password will be hashed and saved. - :param userid: - the user.id to reset the password - :param password: - The clear text password to reset and save hashed on the db + :param userid: + the user.id to reset the password + :param password: + The clear text password to reset and save hashed on the db """ user = self.get_user_by_id(userid) user.password = generate_password_hash(password) @@ -869,26 +912,27 @@ def auth_user_db(self, username, password): "c0976a03d2f18f680bfff877c9a965db9eedc51bc0be87c", "password", ) - log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(username)) + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username) # Balance failure and success - self.noop_user_update(first_user) + if first_user: + self.noop_user_update(first_user) return None elif check_password_hash(user.password, password): self.update_user_auth_stat(user, True) return user else: self.update_user_auth_stat(user, False) - log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(username)) + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username) return None def _search_ldap(self, ldap, con, username): """ - Searches LDAP for user. + Searches LDAP for user. - :param ldap: The ldap module reference - :param con: The ldap connection - :param username: username to match with AUTH_LDAP_UID_FIELD - :return: ldap object array + :param ldap: The ldap module reference + :param con: The ldap connection + :param username: username to match with AUTH_LDAP_UID_FIELD + :return: ldap object array """ # always check AUTH_LDAP_SEARCH is set before calling this method assert self.auth_ldap_search, "AUTH_LDAP_SEARCH must be set" @@ -912,14 +956,15 @@ def _search_ldap(self, ldap, con, username): # preform the LDAP search log.debug( - "LDAP search for '{0}' with fields {1} in scope '{2}'".format( - filter_str, request_fields, self.auth_ldap_search - ) + "LDAP search for '%s' with fields %s in scope '%s'", + filter_str, + request_fields, + self.auth_ldap_search, ) raw_search_result = con.search_s( self.auth_ldap_search, ldap.SCOPE_SUBTREE, filter_str, request_fields ) - log.debug("LDAP search returned: {0}".format(raw_search_result)) + log.debug("LDAP search returned: %s", raw_search_result) # Remove any search referrals from results search_result = [ @@ -931,9 +976,9 @@ def _search_ldap(self, ldap, con, username): # only continue if 0 or 1 results were returned if len(search_result) > 1: log.error( - "LDAP search for '{0}' in scope '{1}' returned multiple results".format( - filter_str, self.auth_ldap_search - ) + "LDAP search for '%s' in scope '%s' returned multiple results", + filter_str, + self.auth_ldap_search, ) return None, None @@ -969,34 +1014,29 @@ def _ldap_calculate_user_roles( user_role_objects.add(fab_role) else: log.warning( - "Can't find AUTH_USER_REGISTRATION role: {0}".format( - registration_role_name - ) + "Can't find AUTH_USER_REGISTRATION role: %s", registration_role_name ) return list(user_role_objects) def _ldap_bind_indirect(self, ldap, con) -> None: """ - Attempt to bind to LDAP using the AUTH_LDAP_BIND_USER. + Attempt to bind to LDAP using the AUTH_LDAP_BIND_USER. - :param ldap: The ldap module reference - :param con: The ldap connection + :param ldap: The ldap module reference + :param con: The ldap connection """ # always check AUTH_LDAP_BIND_USER is set before calling this method assert self.auth_ldap_bind_user, "AUTH_LDAP_BIND_USER must be set" try: log.debug( - "LDAP bind indirect TRY with username: '{0}'".format( - self.auth_ldap_bind_user - ) + "LDAP bind indirect TRY with username: '%s'", self.auth_ldap_bind_user ) con.simple_bind_s(self.auth_ldap_bind_user, self.auth_ldap_bind_password) log.debug( - "LDAP bind indirect SUCCESS with username: '{0}'".format( - self.auth_ldap_bind_user - ) + "LDAP bind indirect SUCCESS with username: '%s'", + self.auth_ldap_bind_user, ) except ldap.INVALID_CREDENTIALS as ex: log.error( @@ -1008,12 +1048,12 @@ def _ldap_bind_indirect(self, ldap, con) -> None: @staticmethod def _ldap_bind(ldap, con, dn: str, password: str) -> bool: """ - Validates/binds the provided dn/password with the LDAP sever. + Validates/binds the provided dn/password with the LDAP sever. """ try: - log.debug("LDAP bind TRY with username: '{0}'".format(dn)) + log.debug("LDAP bind TRY with username: '%s'", dn) con.simple_bind_s(dn, password) - log.debug("LDAP bind SUCCESS with username: '{0}'".format(dn)) + log.debug("LDAP bind SUCCESS with username: '%s'", dn) return True except ldap.INVALID_CREDENTIALS: return False @@ -1034,12 +1074,12 @@ def ldap_extract_list(ldap_dict: Dict[str, bytes], field_name: str) -> List[str] def auth_user_ldap(self, username, password): """ - Method for authenticating user with LDAP. + Method for authenticating user with LDAP. - NOTE: this depends on python-ldap module + NOTE: this depends on python-ldap module - :param username: the username - :param password: the password + :param username: the username + :param password: the password """ # If no username is provided, go away if (username is None) or username == "": @@ -1065,12 +1105,6 @@ def auth_user_ldap(self, username, password): try: # LDAP certificate settings - if self.auth_ldap_allow_self_signed: - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW) - ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) - elif self.auth_ldap_tls_demand: - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND) - ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) if self.auth_ldap_tls_cacertdir: ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.auth_ldap_tls_cacertdir) if self.auth_ldap_tls_cacertfile: @@ -1081,6 +1115,12 @@ def auth_user_ldap(self, username, password): ldap.set_option(ldap.OPT_X_TLS_CERTFILE, self.auth_ldap_tls_certfile) if self.auth_ldap_tls_keyfile: ldap.set_option(ldap.OPT_X_TLS_KEYFILE, self.auth_ldap_tls_keyfile) + if self.auth_ldap_allow_self_signed: + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW) + ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) + elif self.auth_ldap_tls_demand: + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND) + ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) # Initialise LDAP connection con = ldap.initialize(self.auth_ldap_server) @@ -1089,9 +1129,7 @@ def auth_user_ldap(self, username, password): try: con.start_tls_s() except Exception: - log.error( - LOGMSG_ERR_SEC_AUTH_LDAP_TLS.format(self.auth_ldap_server) - ) + log.error(LOGMSG_ERR_SEC_AUTH_LDAP_TLS, self.auth_ldap_server) return None # Define variables, so we can check if they are set in later steps @@ -1121,7 +1159,7 @@ def auth_user_ldap(self, username, password): # If search failed, go away if user_dn is None: - log.info(LOGMSG_WAR_SEC_NOLDAP_OBJ.format(username)) + log.info(LOGMSG_WAR_SEC_NOLDAP_OBJ, username) return None # Bind with user_dn/password (validates credentials) @@ -1130,7 +1168,7 @@ def auth_user_ldap(self, username, password): self.update_user_auth_stat(user, False) # Invalid credentials, go away - log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(username)) + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username) return None # Flow 2 - (Direct Search Bind): @@ -1161,7 +1199,7 @@ def auth_user_ldap(self, username, password): self.update_user_auth_stat(user, False) # Invalid credentials, go away - log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(bind_username)) + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, bind_username) return None # Search for `username` (if AUTH_LDAP_SEARCH is set) @@ -1175,16 +1213,14 @@ def auth_user_ldap(self, username, password): # If search failed, go away if user_dn is None: - log.info(LOGMSG_WAR_SEC_NOLDAP_OBJ.format(username)) + log.info(LOGMSG_WAR_SEC_NOLDAP_OBJ, username) return None # Sync the user's roles if user and user_attributes and self.auth_roles_sync_at_login: user.roles = self._ldap_calculate_user_roles(user_attributes) log.debug( - "Calculated new roles for user='{0}' as: {1}".format( - user_dn, user.roles - ) + "Calculated new roles for user='%s' as: %s", user_dn, user.roles ) # If the user is new, register them @@ -1204,11 +1240,11 @@ def auth_user_ldap(self, username, password): ), role=self._ldap_calculate_user_roles(user_attributes), ) - log.debug("New user registered: {0}".format(user)) + log.debug("New user registered: %s", user) # If user registration failed, go away if not user: - log.info(LOGMSG_ERR_SEC_ADD_REGISTER_USER.format(username)) + log.info(LOGMSG_ERR_SEC_ADD_REGISTER_USER, username) return None # LOGIN SUCCESS (only if user is now registered) @@ -1223,7 +1259,7 @@ def auth_user_ldap(self, username, password): if isinstance(e, dict): msg = getattr(e, "message", None) if (msg is not None) and ("desc" in msg): - log.error(LOGMSG_ERR_SEC_AUTH_LDAP.format(e.message["desc"])) + log.error(LOGMSG_ERR_SEC_AUTH_LDAP, e.message["desc"]) return None else: log.error(e) @@ -1231,14 +1267,14 @@ def auth_user_ldap(self, username, password): def auth_user_oid(self, email): """ - OpenID user Authentication + OpenID user Authentication - :param email: user's email to authenticate - :type self: User model + :param email: user's email to authenticate + :type self: User model """ user = self.find_user(email=email) if user is None or (not user.is_active): - log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(email)) + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, email) return None else: self.update_user_auth_stat(user) @@ -1246,10 +1282,10 @@ def auth_user_oid(self, email): def auth_user_remote_user(self, username): """ - REMOTE_USER user Authentication + REMOTE_USER user Authentication - :param username: user's username for remote auth - :type self: User model + :param username: user's username for remote auth + :type self: User model """ user = self.find_user(username=username) @@ -1268,7 +1304,7 @@ def auth_user_remote_user(self, username): # If user does not exist on the DB and not auto user registration, # or user is inactive, go away. elif user is None or (not user.is_active): - log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(username)) + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username) return None self.update_user_auth_stat(user) @@ -1301,19 +1337,17 @@ def _oauth_calculate_user_roles(self, userinfo) -> List[str]: user_role_objects.add(fab_role) else: log.warning( - "Can't find AUTH_USER_REGISTRATION role: {0}".format( - registration_role_name - ) + "Can't find AUTH_USER_REGISTRATION role: %s", registration_role_name ) return list(user_role_objects) def auth_user_oauth(self, userinfo): """ - Method for authenticating user with OAuth. + Method for authenticating user with OAuth. - :userinfo: dict with user information - (keys are the same as User model columns) + :userinfo: dict with user information + (keys are the same as User model columns) """ # extract the username from `userinfo` if "username" in userinfo: @@ -1321,9 +1355,7 @@ def auth_user_oauth(self, userinfo): elif "email" in userinfo: username = userinfo["email"] else: - log.error( - "OAUTH userinfo does not have username or email {0}".format(userinfo) - ) + log.error("OAUTH userinfo does not have username or email %s", userinfo) return None # If username is empty, go away @@ -1344,11 +1376,7 @@ def auth_user_oauth(self, userinfo): # Sync the user's roles if user and self.auth_roles_sync_at_login: user.roles = self._oauth_calculate_user_roles(userinfo) - log.debug( - "Calculated new roles for user='{0}' as: {1}".format( - username, user.roles - ) - ) + log.debug("Calculated new roles for user='%s' as: %s", username, user.roles) # If the user is new, register them if (not user) and self.auth_user_registration: @@ -1359,11 +1387,11 @@ def auth_user_oauth(self, userinfo): email=userinfo.get("email", "") or f"{username}@email.notfound", role=self._oauth_calculate_user_roles(userinfo), ) - log.debug("New user registered: {0}".format(user)) + log.debug("New user registered: %s", user) # If user registration failed, go away if not user: - log.error("Error creating a new OAuth user {0}".format(username)) + log.error("Error creating a new OAuth user %s", username) return None # LOGIN SUCCESS (only if user is now registered) @@ -1381,12 +1409,12 @@ def auth_user_oauth(self, userinfo): def is_item_public(self, permission_name, view_name): """ - Check if view has public permissions + Check if view has public permissions - :param permission_name: - the permission: can_show, can_edit... - :param view_name: - the name of the class view (child of BaseView) + :param permission_name: + the permission: can_show, can_edit... + :param view_name: + the name of the class view (child of BaseView) """ permissions = self.get_public_permissions() if permissions: @@ -1403,7 +1431,7 @@ def _has_access_builtin_roles( self, role, permission_name: str, view_name: str ) -> bool: """ - Checks permission on builtin role + Checks permission on builtin role """ builtin_pvms = self.builtin_roles.get(role.name, []) for pvm in builtin_pvms: @@ -1440,6 +1468,15 @@ def get_user_roles(self, user) -> List[object]: return [self.get_public_role()] return user.roles + def get_user_roles_permissions(self, user) -> Dict[str, List[Tuple[str, str]]]: + """ + Utility method just implemented for SQLAlchemy. + Take a look to: flask_appbuilder.security.sqla.manager + :param user: + :return: + """ + raise NotImplementedError() + def get_role_permissions(self, role) -> Set[Tuple[str, str]]: """ Get all permissions for a certain role @@ -1499,7 +1536,7 @@ def _get_user_permission_view_menus( result.update(pvms_names) return result - def has_access(self, permission_name, view_name): + def has_access(self, permission_name: str, view_name: str) -> bool: """ Check if current user or public has access to view or menu """ @@ -1524,6 +1561,24 @@ def get_user_menu_access(self, menu_names: List[str] = None) -> Set[str]: None, "menu_access", view_menus_name=menu_names ) + def add_limit_view(self, baseview): + if not baseview.limits: + return + + for limit in baseview.limits: + self.limiter.limit( + limit_value=limit.limit_value, + key_func=limit.key_func, + per_method=limit.per_method, + methods=limit.methods, + error_message=limit.error_message, + exempt_when=limit.exempt_when, + override_defaults=limit.override_defaults, + deduct_when=limit.deduct_when, + on_breach=limit.on_breach, + cost=limit.cost, + )(baseview.blueprint) + def add_permissions_view(self, base_permissions, view_menu): """ Adds a permission on a view menu to the backend @@ -1680,7 +1735,9 @@ def _update_del_transitions(state_transitions: Dict, baseviews: List) -> None: ) state_transitions["del_perms"].discard(permission) - def create_state_transitions(self, baseviews: List, menus: List) -> Dict: + def create_state_transitions( + self, baseviews: List, menus: Optional[List[Any]] + ) -> Dict: """ Creates a Dict with all the necessary vm/permission transitions @@ -1736,7 +1793,9 @@ def create_state_transitions(self, baseviews: List, menus: List) -> Dict: self._update_del_transitions(state_transitions, baseviews) return state_transitions - def security_converge(self, baseviews: List, menus: List, dry=False) -> Dict: + def security_converge( + self, baseviews: List, menus: Optional[List[Any]], dry=False + ) -> Dict: """ Converges overridden permissions on all registered views/api will compute all necessary operations from `class_permissions_name`, @@ -1754,7 +1813,7 @@ def security_converge(self, baseviews: List, menus: List, dry=False) -> Dict: if not state_transitions: log.info("No state transitions found") return dict() - log.debug(f"State transitions: {state_transitions}") + log.debug("State transitions: %s", state_transitions) roles = self.get_all_roles() for role in roles: permissions = list(role.permissions) @@ -1793,7 +1852,7 @@ def security_converge(self, baseviews: List, menus: List, dry=False) -> Dict: def find_register_user(self, registration_hash): """ - Generic function to return user registration + Generic function to return user registration """ raise NotImplementedError @@ -1801,31 +1860,31 @@ def add_register_user( self, username, first_name, last_name, email, password="", hashed_password="" ): """ - Generic function to add user registration + Generic function to add user registration """ raise NotImplementedError def del_register_user(self, register_user): """ - Generic function to delete user registration + Generic function to delete user registration """ raise NotImplementedError def get_user_by_id(self, pk): """ - Generic function to return user by it's id (pk) + Generic function to return user by it's id (pk) """ raise NotImplementedError def find_user(self, username=None, email=None): """ - Generic function find a user by it's username or email + Generic function find a user by it's username or email """ raise NotImplementedError def get_all_users(self): """ - Generic function that returns all existing users + Generic function that returns all existing users """ raise NotImplementedError @@ -1837,21 +1896,21 @@ def get_db_role_permissions(self, role_id: int) -> List[object]: def add_user(self, username, first_name, last_name, email, role, password=""): """ - Generic function to create user + Generic function to create user """ raise NotImplementedError def update_user(self, user): """ - Generic function to update user + Generic function to update user - :param user: User model to update to database + :param user: User model to update to database """ raise NotImplementedError def count_users(self): """ - Generic function to count the existing users + Generic function to count the existing users """ raise NotImplementedError @@ -1881,19 +1940,19 @@ def get_all_roles(self): def get_public_role(self): """ - returns all permissions from public role + returns all permissions from public role """ raise NotImplementedError def get_public_permissions(self): """ - returns all permissions from public role + returns all permissions from public role """ raise NotImplementedError def find_permission(self, name): """ - Finds and returns a Permission by name + Finds and returns a Permission by name """ raise NotImplementedError @@ -1906,25 +1965,25 @@ def exist_permission_on_roles( self, view_name: str, permission_name: str, role_ids: List[int] ) -> bool: """ - Finds and returns permission views for a group of roles + Finds and returns permission views for a group of roles """ raise NotImplementedError def add_permission(self, name): """ - Adds a permission to the backend, model permission + Adds a permission to the backend, model permission - :param name: - name of the permission: 'can_add','can_edit' etc... + :param name: + name of the permission: 'can_add','can_edit' etc... """ raise NotImplementedError def del_permission(self, name): """ - Deletes a permission from the backend, model permission + Deletes a permission from the backend, model permission - :param name: - name of the permission: 'can_add','can_edit' etc... + :param name: + name of the permission: 'can_add','can_edit' etc... """ raise NotImplementedError @@ -1936,7 +1995,7 @@ def del_permission(self, name): def find_view_menu(self, name): """ - Finds and returns a ViewMenu by name + Finds and returns a ViewMenu by name """ raise NotImplementedError @@ -1945,18 +2004,18 @@ def get_all_view_menu(self): def add_view_menu(self, name): """ - Adds a view or menu to the backend, model view_menu - param name: - name of the view menu to add + Adds a view or menu to the backend, model view_menu + param name: + name of the view menu to add """ raise NotImplementedError def del_view_menu(self, name): """ - Deletes a ViewMenu from the backend + Deletes a ViewMenu from the backend - :param name: - name of the ViewMenu + :param name: + name of the ViewMenu """ raise NotImplementedError @@ -1968,27 +2027,27 @@ def del_view_menu(self, name): def find_permission_view_menu(self, permission_name, view_menu_name): """ - Finds and returns a PermissionView by names + Finds and returns a PermissionView by names """ raise NotImplementedError def find_permissions_view_menu(self, view_menu): """ - Finds all permissions from ViewMenu, returns list of PermissionView + Finds all permissions from ViewMenu, returns list of PermissionView - :param view_menu: ViewMenu object - :return: list of PermissionView objects + :param view_menu: ViewMenu object + :return: list of PermissionView objects """ raise NotImplementedError def add_permission_view_menu(self, permission_name, view_menu_name): """ - Adds a permission on a view or menu to the backend + Adds a permission on a view or menu to the backend - :param permission_name: - name of the permission to add: 'can_add','can_edit' etc... - :param view_menu_name: - name of the view menu to add + :param permission_name: + name of the permission to add: 'can_add','can_edit' etc... + :param view_menu_name: + name of the view menu to add """ raise NotImplementedError @@ -2003,41 +2062,42 @@ def exist_permission_on_view(self, lst, permission, view_menu): def add_permission_role(self, role, perm_view): """ - Add permission-ViewMenu object to Role + Add permission-ViewMenu object to Role - :param role: - The role object - :param perm_view: - The PermissionViewMenu object + :param role: + The role object + :param perm_view: + The PermissionViewMenu object """ raise NotImplementedError def del_permission_role(self, role, perm_view): """ - Remove permission-ViewMenu object to Role + Remove permission-ViewMenu object to Role - :param role: - The role object - :param perm_view: - The PermissionViewMenu object + :param role: + The role object + :param perm_view: + The PermissionViewMenu object """ raise NotImplementedError def export_roles( self, path: Optional[str] = None, indent: Optional[Union[int, str]] = None ) -> None: - """ Exports roles to JSON file. """ + """Exports roles to JSON file.""" raise NotImplementedError def import_roles(self, path: str) -> None: - """ Imports roles from JSON file. """ + """Imports roles from JSON file.""" raise NotImplementedError def load_user(self, pk): return self.get_user_by_id(int(pk)) - def load_user_jwt(self, pk): - user = self.load_user(pk) + def load_user_jwt(self, _jwt_header, jwt_data): + identity = jwt_data["sub"] + user = self.load_user(identity) # Set flask g.user to JWT user, we can't do it on before request g.user = user return user diff --git a/flask_appbuilder/security/mongoengine/manager.py b/flask_appbuilder/security/mongoengine/manager.py index bf5cc9f2e0..be55fde822 100644 --- a/flask_appbuilder/security/mongoengine/manager.py +++ b/flask_appbuilder/security/mongoengine/manager.py @@ -87,14 +87,14 @@ def add_register_user( register_user.save() return register_user except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_REGISTER_USER.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_REGISTER_USER, e) return False def del_register_user(self, register_user): try: register_user.delete() except Exception as e: - log.error(c.LOGMSG_ERR_SEC_DEL_REGISTER_USER.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_DEL_REGISTER_USER, e) def find_user(self, username=None, email=None): if username: @@ -131,10 +131,10 @@ def add_user( else: user.password = generate_password_hash(password) user.save() - log.info(c.LOGMSG_INF_SEC_ADD_USER.format(username)) + log.info(c.LOGMSG_INF_SEC_ADD_USER, username) return user except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_USER.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_USER, e) return False def count_users(self): @@ -144,7 +144,7 @@ def update_user(self, user): try: user.save() except Exception as e: - log.error(c.LOGMSG_ERR_SEC_UPD_USER.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_UPD_USER, e) return False def get_user_by_id(self, pk): @@ -170,18 +170,18 @@ def add_role( try: role = self.role_model(name=name, permissions=permissions) role.save() - log.info(c.LOGMSG_INF_SEC_ADD_ROLE.format(name)) + log.info(c.LOGMSG_INF_SEC_ADD_ROLE, name) return role except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_ROLE.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_ROLE, e) return role def update_role(self, pk, name: str) -> Optional[Role]: try: role = self.role_model.objects(id=pk).update(name=name) - log.info(c.LOGMSG_INF_SEC_UPD_ROLE.format(role)) + log.info(c.LOGMSG_INF_SEC_UPD_ROLE, role) except Exception as e: - log.error(c.LOGMSG_ERR_SEC_UPD_ROLE.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_UPD_ROLE, e) return def find_role(self, name): @@ -228,7 +228,7 @@ def add_permission(self, name): perm.save() return perm except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_PERMISSION.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_PERMISSION, e) return perm def del_permission(self, name): @@ -243,7 +243,7 @@ def del_permission(self, name): try: perm.delete() except Exception as e: - log.error(c.LOGMSG_ERR_SEC_DEL_PERMISSION.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_DEL_PERMISSION, e) """ ---------------------- @@ -273,7 +273,7 @@ def add_view_menu(self, name): view_menu.save() return view_menu except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_VIEWMENU.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_VIEWMENU, e) return view_menu def del_view_menu(self, name): @@ -288,7 +288,7 @@ def del_view_menu(self, name): try: obj.delete() except Exception as e: - log.error(c.LOGMSG_ERR_SEC_DEL_PERMISSION.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_DEL_PERMISSION, e) """ ---------------------- @@ -336,10 +336,10 @@ def add_permission_view_menu(self, permission_name, view_menu_name): pv.view_menu, pv.permission = vm, perm try: pv.save() - log.info(c.LOGMSG_INF_SEC_ADD_PERMVIEW.format(str(pv))) + log.info(c.LOGMSG_INF_SEC_ADD_PERMVIEW, pv) return pv except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_PERMVIEW.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_PERMVIEW, e) def del_permission_view_menu(self, permission_name, view_menu_name, cascade=True): try: @@ -352,11 +352,9 @@ def del_permission_view_menu(self, permission_name, view_menu_name, cascade=True pv = self.permissionview_model.objects(permission=pv.permission) if not pv: self.del_permission(pv.permission.name) - log.info( - c.LOGMSG_INF_SEC_DEL_PERMVIEW.format(permission_name, view_menu_name) - ) + log.info(c.LOGMSG_INF_SEC_DEL_PERMVIEW, permission_name, view_menu_name) except Exception as e: - log.error(c.LOGMSG_ERR_SEC_DEL_PERMVIEW.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_DEL_PERMVIEW, e) def exist_permission_on_views(self, lst, item): for i in lst: @@ -383,11 +381,9 @@ def add_permission_role(self, role, perm_view): try: role.permissions.append(perm_view) role.save() - log.info( - c.LOGMSG_INF_SEC_ADD_PERMROLE.format(str(perm_view), role.name) - ) + log.info(c.LOGMSG_INF_SEC_ADD_PERMROLE, perm_view, role.name) except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_PERMROLE.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_PERMROLE, e) def del_permission_role(self, role, perm_view): """ @@ -402,11 +398,9 @@ def del_permission_role(self, role, perm_view): try: role.permissions.remove(perm_view) role.save() - log.info( - c.LOGMSG_INF_SEC_DEL_PERMROLE.format(str(perm_view), role.name) - ) + log.info(c.LOGMSG_INF_SEC_DEL_PERMROLE, perm_view, role.name) except Exception as e: - log.error(c.LOGMSG_ERR_SEC_DEL_PERMROLE.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_DEL_PERMROLE, e) def export_roles( self, path: Optional[str] = None, indent: Optional[Union[int, str]] = None diff --git a/flask_appbuilder/security/mongoengine/models.py b/flask_appbuilder/security/mongoengine/models.py index 283b3d5537..90db09812f 100644 --- a/flask_appbuilder/security/mongoengine/models.py +++ b/flask_appbuilder/security/mongoengine/models.py @@ -77,7 +77,7 @@ class User(Document): username = StringField(max_length=64, required=True, unique=True) password = StringField(max_length=256) active = BooleanField() - email = StringField(max_length=64, required=True, unique=True) + email = StringField(max_length=320, required=True, unique=True) last_login = DateTimeField() login_count = IntField() fail_login_count = IntField() @@ -104,7 +104,7 @@ def get_id(self): return as_unicode(self.id) def get_full_name(self): - return u"{0} {1}".format(self.first_name, self.last_name) + return "{0} {1}".format(self.first_name, self.last_name) def __unicode__(self): return self.get_full_name() diff --git a/flask_appbuilder/security/registerviews.py b/flask_appbuilder/security/registerviews.py index a394c217fe..9215464193 100644 --- a/flask_appbuilder/security/registerviews.py +++ b/flask_appbuilder/security/registerviews.py @@ -95,7 +95,7 @@ def send_email(self, register_user): try: mail.send(msg) except Exception as e: - log.error("Send email exception: {0}".format(str(e))) + log.error("Send email exception: %s", e) return False return True @@ -126,7 +126,7 @@ def activation(self, activation_hash): """ reg = self.appbuilder.sm.find_register_user(activation_hash) if not reg: - log.error(c.LOGMSG_ERR_SEC_NO_REGISTER_HASH.format(activation_hash)) + log.error(c.LOGMSG_ERR_SEC_NO_REGISTER_HASH, activation_hash) flash(as_unicode(self.false_error_message), "danger") return redirect(self.appbuilder.get_url_for_index) if not self.appbuilder.sm.add_user( diff --git a/flask_appbuilder/security/sqla/apis/__init__.py b/flask_appbuilder/security/sqla/apis/__init__.py new file mode 100644 index 0000000000..220edd07c4 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/__init__.py @@ -0,0 +1,7 @@ +from flask_appbuilder.security.sqla.apis.permission import PermissionApi # noqa: F401 +from flask_appbuilder.security.sqla.apis.permission_view_menu import ( # noqa: F401 + PermissionViewMenuApi, +) +from flask_appbuilder.security.sqla.apis.role import RoleApi # noqa: F401 +from flask_appbuilder.security.sqla.apis.user import UserApi # noqa: F401 +from flask_appbuilder.security.sqla.apis.view_menu import ViewMenuApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/permission/__init__.py b/flask_appbuilder/security/sqla/apis/permission/__init__.py new file mode 100644 index 0000000000..fbefe5f12b --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/permission/__init__.py @@ -0,0 +1 @@ +from .api import PermissionApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/permission/api.py b/flask_appbuilder/security/sqla/apis/permission/api.py new file mode 100644 index 0000000000..ee76721eea --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/permission/api.py @@ -0,0 +1,19 @@ +from flask_appbuilder import ModelRestApi +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.sqla.models import Permission + + +class PermissionApi(ModelRestApi): + resource_name = "security/permissions" + openapi_spec_tag = "Security Permissions" + + class_permission_name = "Permission" + datamodel = SQLAInterface(Permission) + allow_browser_login = True + include_route_methods = {"info", "get", "get_list"} + + list_columns = ["id", "name"] + show_columns = list_columns + add_columns = ["name"] + edit_columns = add_columns + search_columns = list_columns diff --git a/flask_appbuilder/security/sqla/apis/permission_view_menu/__init__.py b/flask_appbuilder/security/sqla/apis/permission_view_menu/__init__.py new file mode 100644 index 0000000000..bccf326bd0 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/permission_view_menu/__init__.py @@ -0,0 +1 @@ +from .api import PermissionViewMenuApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/permission_view_menu/api.py b/flask_appbuilder/security/sqla/apis/permission_view_menu/api.py new file mode 100644 index 0000000000..9af3d0ff36 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/permission_view_menu/api.py @@ -0,0 +1,16 @@ +from flask_appbuilder import ModelRestApi +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.sqla.models import PermissionView + + +class PermissionViewMenuApi(ModelRestApi): + resource_name = "security/permissions-resources" + openapi_spec_tag = "Security Permissions on Resources (View Menus)" + class_permission_name = "PermissionViewMenu" + datamodel = SQLAInterface(PermissionView) + allow_browser_login = True + + list_columns = ["id", "permission.name", "view_menu.name"] + show_columns = list_columns + add_columns = ["permission_id", "view_menu_id"] + edit_columns = add_columns diff --git a/flask_appbuilder/security/sqla/apis/role/__init__.py b/flask_appbuilder/security/sqla/apis/role/__init__.py new file mode 100644 index 0000000000..640bca7c27 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/role/__init__.py @@ -0,0 +1 @@ +from .api import RoleApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/role/api.py b/flask_appbuilder/security/sqla/apis/role/api.py new file mode 100644 index 0000000000..dd69824e03 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/role/api.py @@ -0,0 +1,154 @@ +from flask import current_app, request +from flask_appbuilder import ModelRestApi +from flask_appbuilder.api import expose, safe +from flask_appbuilder.const import API_RESULT_RES_KEY +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.decorators import permission_name, protect +from flask_appbuilder.security.sqla.apis.role.schema import ( + RolePermissionListSchema, + RolePermissionPostSchema, +) +from flask_appbuilder.security.sqla.models import PermissionView, Role +from marshmallow import ValidationError +from sqlalchemy.exc import IntegrityError + + +class RoleApi(ModelRestApi): + resource_name = "security/roles" + openapi_spec_tag = "Security Roles" + class_permission_name = "Role" + datamodel = SQLAInterface(Role) + allow_browser_login = True + + list_columns = ["id", "name"] + show_columns = list_columns + add_columns = ["name"] + edit_columns = ["name"] + search_columns = list_columns + + list_role_permission_schema = RolePermissionListSchema() + add_role_permission_schema = RolePermissionPostSchema() + openapi_spec_component_schemas = ( + RolePermissionListSchema, + RolePermissionPostSchema, + ) + + @expose("//permissions/", methods=["GET"]) + @protect() + @safe + @permission_name("list_role_permissions") + def list_role_permissions(self, pk): + """list role permissions + --- + get: + parameters: + - in: path + schema: + type: integer + name: pk + responses: + 200: + description: List of permissions + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/RolePermissionListSchema' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + role = self.datamodel.get(pk, select_columns=["permissions"]) + if not role: + return self.response_404() + + permissions = [ + { + "id": p.id, + "permission_name": p.permission.name, + "view_menu_name": p.view_menu.name, + } + for p in role.permissions + ] + return self.response(200, **{API_RESULT_RES_KEY: permissions}) + + @expose("//permissions", methods=["POST"]) + @protect() + @safe + @permission_name("add_role_permissions") + def add_role_permissions(self, role_id): + """add role permissions + --- + post: + parameters: + - in: path + schema: + type: integer + name: role_id + requestBody: + description: Add role permissions schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RolePermissionPostSchema' + responses: + 200: + description: Permissions added + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/RolePermissionPostSchema' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + item = self.add_role_permission_schema.load(request.json) + role = self.datamodel.get(role_id) + if not role: + return self.response_404() + permissions = [] + for id in item["permission_view_menu_ids"]: + permission = ( + current_app.appbuilder.get_session.query(PermissionView) + .filter_by(id=id) + .one_or_none() + ) + if permission: + permissions.append(permission) + + role.permissions = permissions + self.datamodel.edit(role, raise_exception=True) + return self.response( + 200, + **{ + API_RESULT_RES_KEY: self.add_role_permission_schema.dump( + item, many=False + ) + }, + ) + + except ValidationError as error: + return self.response_400(message=error.messages) + except IntegrityError as e: + return self.response_422(message=str(e.orig)) diff --git a/flask_appbuilder/security/sqla/apis/role/schema.py b/flask_appbuilder/security/sqla/apis/role/schema.py new file mode 100644 index 0000000000..bbf2c80557 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/role/schema.py @@ -0,0 +1,15 @@ +from marshmallow import fields, Schema + + +class RolePermissionPostSchema(Schema): + permission_view_menu_ids = fields.List( + fields.Integer, + required=True, + metadata={"description": "List of permission view menu id"}, + ) + + +class RolePermissionListSchema(Schema): + id = fields.Integer() + permission_name = fields.String() + view_menu_name = fields.String() diff --git a/flask_appbuilder/security/sqla/apis/user/__init__.py b/flask_appbuilder/security/sqla/apis/user/__init__.py new file mode 100644 index 0000000000..44378357a6 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/user/__init__.py @@ -0,0 +1 @@ +from .api import UserApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/user/api.py b/flask_appbuilder/security/sqla/apis/user/api.py new file mode 100644 index 0000000000..94f250123d --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/user/api.py @@ -0,0 +1,217 @@ +from datetime import datetime + +from flask import g, request +from flask_appbuilder import ModelRestApi +from flask_appbuilder.api import expose, safe +from flask_appbuilder.const import API_RESULT_RES_KEY +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.decorators import permission_name, protect +from flask_appbuilder.security.sqla.apis.user.schema import ( + UserPostSchema, + UserPutSchema, +) +from flask_appbuilder.security.sqla.models import Role, User +from marshmallow import ValidationError +from sqlalchemy.exc import IntegrityError +from werkzeug.security import generate_password_hash + + +class UserApi(ModelRestApi): + resource_name = "security/users" + openapi_spec_tag = "Security Users" + class_permission_name = "User" + datamodel = SQLAInterface(User) + allow_browser_login = True + + list_columns = [ + "id", + "roles.id", + "roles.name", + "first_name", + "last_name", + "username", + "active", + "email", + "last_login", + "login_count", + "fail_login_count", + "created_on", + "changed_on", + "created_by.id", + "changed_by.id", + ] + show_columns = list_columns + add_columns = [ + "roles", + "first_name", + "last_name", + "username", + "active", + "email", + "password", + ] + edit_columns = add_columns + search_columns = [ + "username", + "first_name", + "last_name", + "active", + "email", + "created_by", + "changed_by", + "roles", + ] + + add_model_schema = UserPostSchema() + edit_model_schema = UserPutSchema() + + def pre_update(self, item): + item.changed_on = datetime.now() + item.changed_by_fk = g.user.id + if item.password: + item.password = generate_password_hash(item.password) + + def pre_add(self, item): + item.password = generate_password_hash(item.password) + + @expose("/", methods=["POST"]) + @protect() + @safe + @permission_name("post") + def post(self): + """Create new user + --- + post: + requestBody: + description: Model schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + responses: + 201: + description: Item changed + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + item = self.add_model_schema.load(request.json) + model = User() + roles = [] + for key, value in item.items(): + if key != "roles": + setattr(model, key, value) + else: + for role_id in item[key]: + role = ( + self.datamodel.session.query(Role) + .filter(Role.id == role_id) + .one_or_none() + ) + if role: + role.user_id = model.id + role.role_id = role_id + roles.append(role) + + if "roles" in item.keys(): + model.roles = roles + + self.pre_add(model) + self.datamodel.add(model, raise_exception=True) + return self.response(201, id=model.id) + except ValidationError as error: + return self.response_400(message=error.messages) + except IntegrityError as e: + return self.response_422(message=str(e.orig)) + + @expose("/", methods=["PUT"]) + @protect() + @safe + @permission_name("put") + def put(self, pk): + """Edit user + --- + put: + parameters: + - in: path + schema: + type: integer + name: pk + requestBody: + description: Model schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + responses: + 200: + description: Item changed + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + item = self.edit_model_schema.load(request.json) + model = self.datamodel.get(pk, self._base_filters) + roles = [] + + for key, value in item.items(): + if key != "roles": + setattr(model, key, value) + else: + for role_id in item[key]: + role = ( + self.datamodel.session.query(Role) + .filter(Role.id == role_id) + .one_or_none() + ) + if role: + role.user_id = model.id + role.role_id = role_id + roles.append(role) + + if "roles" in item.keys(): + model.roles = roles + + self.pre_update(model) + self.datamodel.edit(model, raise_exception=True) + return self.response( + 200, + **{API_RESULT_RES_KEY: self.edit_model_schema.dump(item, many=False)}, + ) + + except ValidationError as e: + return self.response_400(message=e.messages) + except IntegrityError as e: + return self.response_422(message=str(e.orig)) diff --git a/flask_appbuilder/security/sqla/apis/user/schema.py b/flask_appbuilder/security/sqla/apis/user/schema.py new file mode 100644 index 0000000000..d5fab9e4db --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/user/schema.py @@ -0,0 +1,76 @@ +from flask_appbuilder.security.sqla.models import User +from marshmallow import fields, Schema +from marshmallow.validate import Length + +from .validator import PasswordComplexityValidator + +active_description = ( + "Is user active?" "It's not a good policy to remove a user, just make it inactive" +) +email_description = "The user's email" +first_name_description = "The user's first name" +last_name_description = "The user's last name" +password_description = "The user's password for authentication" +roles_description = "The user's roles" +username_description = "The user's username" + + +class UserPostSchema(Schema): + model_cls = User + active = fields.Boolean( + required=False, dump_default=True, metadata={"description": active_description} + ) + email = fields.String(required=True, metadata={"description": email_description}) + first_name = fields.String( + required=True, metadata={"description": first_name_description} + ) + last_name = fields.String( + required=True, metadata={"description": last_name_description} + ) + password = fields.String( + required=True, + validate=[PasswordComplexityValidator()], + metadata={"description": password_description}, + ) + roles = fields.List( + fields.Integer, + required=True, + validate=[Length(1)], + metadata={"description": roles_description}, + ) + username = fields.String( + required=True, + validate=[Length(1, 250)], + metadata={"description": username_description}, + ) + + +class UserPutSchema(Schema): + model_cls = User + + active = fields.Boolean( + required=False, metadata={"description": active_description} + ) + email = fields.String(required=False, metadata={"description": email_description}) + first_name = fields.String( + required=False, metadata={"description": first_name_description} + ) + last_name = fields.String( + required=False, metadata={"description": last_name_description} + ) + password = fields.String( + required=False, + validate=[PasswordComplexityValidator()], + metadata={"description": password_description}, + ) + roles = fields.List( + fields.Integer, + required=False, + validate=[Length(1)], + metadata={"description": roles_description}, + ) + username = fields.String( + required=False, + validate=[Length(1, 250)], + metadata={"description": username_description}, + ) diff --git a/flask_appbuilder/security/sqla/apis/user/validator.py b/flask_appbuilder/security/sqla/apis/user/validator.py new file mode 100644 index 0000000000..e7dd62ccb3 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/user/validator.py @@ -0,0 +1,28 @@ +from flask import current_app +from flask_appbuilder.exceptions import PasswordComplexityValidationError +from flask_appbuilder.validators import default_password_complexity +from marshmallow.exceptions import ValidationError +from marshmallow.validate import Validator + + +class PasswordComplexityValidator(Validator): + """Validator for password. + """ + + def __call__(self, value: str) -> str: + if current_app.config.get("FAB_PASSWORD_COMPLEXITY_ENABLED", False): + password_complexity_validator = current_app.config.get( + "FAB_PASSWORD_COMPLEXITY_VALIDATOR", None + ) + if password_complexity_validator is not None: + try: + password_complexity_validator(value) + except PasswordComplexityValidationError as exc: + raise ValidationError(str(exc)) + else: + try: + default_password_complexity(value) + except PasswordComplexityValidationError as exc: + raise ValidationError(str(exc)) + + return value diff --git a/flask_appbuilder/security/sqla/apis/view_menu/__init__.py b/flask_appbuilder/security/sqla/apis/view_menu/__init__.py new file mode 100644 index 0000000000..6652d77a00 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/view_menu/__init__.py @@ -0,0 +1 @@ +from .api import ViewMenuApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/view_menu/api.py b/flask_appbuilder/security/sqla/apis/view_menu/api.py new file mode 100644 index 0000000000..762b1f38ed --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/view_menu/api.py @@ -0,0 +1,18 @@ +from flask_appbuilder import ModelRestApi +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.sqla.models import ViewMenu + + +class ViewMenuApi(ModelRestApi): + resource_name = "security/resources" + openapi_spec_tag = "Security Resources (View Menus)" + + class_permission_name = "ViewMenu" + datamodel = SQLAInterface(ViewMenu) + allow_browser_login = True + + list_columns = ["id", "name"] + show_columns = list_columns + add_columns = ["name"] + edit_columns = add_columns + search_columns = list_columns diff --git a/flask_appbuilder/security/sqla/manager.py b/flask_appbuilder/security/sqla/manager.py index 0db0b2941b..1d370d48d7 100755 --- a/flask_appbuilder/security/sqla/manager.py +++ b/flask_appbuilder/security/sqla/manager.py @@ -1,7 +1,7 @@ from datetime import datetime import json import logging -from typing import List, Optional, Union +from typing import Dict, List, Optional, Tuple, Union import uuid from sqlalchemy import and_, func, literal, update @@ -10,6 +10,7 @@ from sqlalchemy.orm.exc import MultipleResultsFound from werkzeug.security import generate_password_hash +from .apis import PermissionApi, PermissionViewMenuApi, RoleApi, UserApi, ViewMenuApi from .models import ( assoc_permissionview_role, Permission, @@ -45,6 +46,13 @@ class SecurityManager(BaseSecurityManager): permissionview_model = PermissionView registeruser_model = RegisterUser + # APIs + permission_api = PermissionApi + role_api = RoleApi + user_api = UserApi + view_menu_api = ViewMenuApi + permission_view_menu_api = PermissionViewMenuApi + def __init__(self, appbuilder): """ SecurityManager contructor @@ -86,6 +94,13 @@ def get_session(self): def register_views(self): super(SecurityManager, self).register_views() + if self.appbuilder.app.config.get("FAB_ADD_SECURITY_API", False): + self.appbuilder.add_api(self.permission_api) + self.appbuilder.add_api(self.role_api) + self.appbuilder.add_api(self.user_api) + self.appbuilder.add_api(self.view_menu_api) + self.appbuilder.add_api(self.permission_view_menu_api) + def create_db(self): try: engine = self.get_session.get_bind(mapper=None, clause=None) @@ -96,7 +111,7 @@ def create_db(self): log.info(c.LOGMSG_INF_SEC_ADD_DB) super(SecurityManager, self).create_db() except Exception as e: - log.error(c.LOGMSG_ERR_SEC_CREATE_DB.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_CREATE_DB, e) exit(1) def find_register_user(self, registration_hash): @@ -129,7 +144,7 @@ def add_register_user( self.get_session.commit() return register_user except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_REGISTER_USER.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_REGISTER_USER, e) self.appbuilder.get_session.rollback() return None @@ -144,7 +159,7 @@ def del_register_user(self, register_user): self.get_session.commit() return True except Exception as e: - log.error(c.LOGMSG_ERR_SEC_DEL_REGISTER_USER.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_DEL_REGISTER_USER, e) self.get_session.rollback() return False @@ -169,7 +184,7 @@ def find_user(self, username=None, email=None): .one_or_none() ) except MultipleResultsFound: - log.error(f"Multiple results found for user {username}") + log.error("Multiple results found for user %s", username) return None elif email: try: @@ -179,7 +194,7 @@ def find_user(self, username=None, email=None): .one_or_none() ) except MultipleResultsFound: - log.error(f"Multiple results found for user with email {email}") + log.error("Multiple results found for user with email %s", email) return None def get_all_users(self): @@ -212,10 +227,10 @@ def add_user( user.password = generate_password_hash(password) self.get_session.add(user) self.get_session.commit() - log.info(c.LOGMSG_INF_SEC_ADD_USER.format(username)) + log.info(c.LOGMSG_INF_SEC_ADD_USER, username) return user except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_USER.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_USER, e) self.get_session.rollback() return False @@ -226,9 +241,9 @@ def update_user(self, user): try: self.get_session.merge(user) self.get_session.commit() - log.info(c.LOGMSG_INF_SEC_UPD_USER.format(user)) + log.info(c.LOGMSG_INF_SEC_UPD_USER, user) except Exception as e: - log.error(c.LOGMSG_ERR_SEC_UPD_USER.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_UPD_USER, e) self.get_session.rollback() return False @@ -240,7 +255,7 @@ def get_first_user(self) -> "User": def noop_user_update(self, user: "User") -> None: stmt = ( - update(User) + update(self.user_model) .where(self.user_model.id == user.id) .values(login_count=user.login_count) ) @@ -267,10 +282,10 @@ def add_role( role.permissions = permissions self.get_session.add(role) self.get_session.commit() - log.info(c.LOGMSG_INF_SEC_ADD_ROLE.format(name)) + log.info(c.LOGMSG_INF_SEC_ADD_ROLE, name) return role except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_ROLE.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_ROLE, e) self.get_session.rollback() return role @@ -282,9 +297,9 @@ def update_role(self, pk, name: str) -> Optional[Role]: role.name = name self.get_session.merge(role) self.get_session.commit() - log.info(c.LOGMSG_INF_SEC_UPD_ROLE.format(role)) + log.info(c.LOGMSG_INF_SEC_UPD_ROLE, role) except Exception as e: - log.error(c.LOGMSG_ERR_SEC_UPD_ROLE.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_UPD_ROLE, e) self.get_session.rollback() return @@ -380,6 +395,57 @@ def find_roles_permission_view_menus( ) ).all() + def get_user_roles_permissions(self, user) -> Dict[str, List[Tuple[str, str]]]: + """ + Utility method for fetching all roles and permissions for a specific user. + Example of the returned data: + ``` + { + 'Admin': [ + ('can_this_form_get', 'ResetPasswordView'), + ('can_this_form_post', 'ResetPasswordView'), + ... + ] + 'EmptyRole': [], + } + ``` + """ + if not user.roles: + raise AttributeError("User object does not have roles") + + result: Dict[str, List[Tuple[str, str]]] = {} + db_roles_ids = [] + for role in user.roles: + # Make sure all db roles are included on the result + result[role.name] = [] + if role.name in self.builtin_roles: + for permission in self.builtin_roles[role.name]: + result[role.name].append((permission[1], permission[0])) + else: + db_roles_ids.append(role.id) + + permission_views = ( + self.appbuilder.get_session.query(PermissionView) + .join(Permission) + .join(ViewMenu) + .join(PermissionView.role) + .filter(Role.id.in_(db_roles_ids)) + .options(contains_eager(PermissionView.permission)) + .options(contains_eager(PermissionView.view_menu)) + .options(contains_eager(PermissionView.role)) + ).all() + + for permission_view in permission_views: + for role_item in permission_view.role: + if role_item.name in result: + result[role_item.name].append( + ( + permission_view.permission.name, + permission_view.view_menu.name, + ) + ) + return result + def get_db_role_permissions(self, role_id: int) -> List[PermissionView]: """ Get all DB permissions from a role (one single query) @@ -411,7 +477,7 @@ def add_permission(self, name): self.get_session.commit() return perm except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_PERMISSION.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_PERMISSION, e) self.get_session.rollback() return perm @@ -424,7 +490,7 @@ def del_permission(self, name: str) -> bool: """ perm = self.find_permission(name) if not perm: - log.warning(c.LOGMSG_WAR_SEC_DEL_PERMISSION.format(name)) + log.warning(c.LOGMSG_WAR_SEC_DEL_PERMISSION, name) return False try: pvms = ( @@ -433,13 +499,13 @@ def del_permission(self, name: str) -> bool: .all() ) if pvms: - log.warning(c.LOGMSG_WAR_SEC_DEL_PERM_PVM.format(perm, pvms)) + log.warning(c.LOGMSG_WAR_SEC_DEL_PERM_PVM, perm, pvms) return False self.get_session.delete(perm) self.get_session.commit() return True except Exception as e: - log.error(c.LOGMSG_ERR_SEC_DEL_PERMISSION.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_DEL_PERMISSION, e) self.get_session.rollback() return False @@ -477,7 +543,7 @@ def add_view_menu(self, name): self.get_session.commit() return view_menu except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_VIEWMENU.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_VIEWMENU, e) self.get_session.rollback() return view_menu @@ -490,7 +556,7 @@ def del_view_menu(self, name: str) -> bool: """ view_menu = self.find_view_menu(name) if not view_menu: - log.warning(c.LOGMSG_WAR_SEC_DEL_VIEWMENU.format(name)) + log.warning(c.LOGMSG_WAR_SEC_DEL_VIEWMENU, name) return False try: pvms = ( @@ -499,13 +565,13 @@ def del_view_menu(self, name: str) -> bool: .all() ) if pvms: - log.warning(c.LOGMSG_WAR_SEC_DEL_VIEWMENU_PVM.format(view_menu, pvms)) + log.warning(c.LOGMSG_WAR_SEC_DEL_VIEWMENU_PVM, view_menu, pvms) return False self.get_session.delete(view_menu) self.get_session.commit() return True except Exception as e: - log.error(c.LOGMSG_ERR_SEC_DEL_PERMISSION.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_DEL_PERMISSION, e) self.get_session.rollback() return False @@ -558,14 +624,14 @@ def add_permission_view_menu(self, permission_name, view_menu_name): vm = self.add_view_menu(view_menu_name) perm = self.add_permission(permission_name) pv = self.permissionview_model() - pv.view_menu_id, pv.permission_id = vm.id, perm.id + pv.view_menu, pv.permission = vm, perm try: self.get_session.add(pv) self.get_session.commit() - log.info(c.LOGMSG_INF_SEC_ADD_PERMVIEW.format(str(pv))) + log.info(c.LOGMSG_INF_SEC_ADD_PERMVIEW, pv) return pv except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_PERMVIEW.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_PERMVIEW, e) self.get_session.rollback() def del_permission_view_menu(self, permission_name, view_menu_name, cascade=True): @@ -581,9 +647,10 @@ def del_permission_view_menu(self, permission_name, view_menu_name, cascade=True ) if roles_pvs: log.warning( - c.LOGMSG_WAR_SEC_DEL_PERMVIEW.format( - view_menu_name, permission_name, roles_pvs - ) + c.LOGMSG_WAR_SEC_DEL_PERMVIEW, + view_menu_name, + permission_name, + roles_pvs, ) return try: @@ -599,11 +666,9 @@ def del_permission_view_menu(self, permission_name, view_menu_name, cascade=True .all() ): self.del_permission(pv.permission.name) - log.info( - c.LOGMSG_INF_SEC_DEL_PERMVIEW.format(permission_name, view_menu_name) - ) + log.info(c.LOGMSG_INF_SEC_DEL_PERMVIEW, permission_name, view_menu_name) except Exception as e: - log.error(c.LOGMSG_ERR_SEC_DEL_PERMVIEW.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_DEL_PERMVIEW, e) self.get_session.rollback() def exist_permission_on_views(self, lst, item): @@ -632,11 +697,9 @@ def add_permission_role(self, role, perm_view): role.permissions.append(perm_view) self.get_session.merge(role) self.get_session.commit() - log.info( - c.LOGMSG_INF_SEC_ADD_PERMROLE.format(str(perm_view), role.name) - ) + log.info(c.LOGMSG_INF_SEC_ADD_PERMROLE, perm_view, role.name) except Exception as e: - log.error(c.LOGMSG_ERR_SEC_ADD_PERMROLE.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_ADD_PERMROLE, e) self.get_session.rollback() def del_permission_role(self, role, perm_view): @@ -653,11 +716,9 @@ def del_permission_role(self, role, perm_view): role.permissions.remove(perm_view) self.get_session.merge(role) self.get_session.commit() - log.info( - c.LOGMSG_INF_SEC_DEL_PERMROLE.format(str(perm_view), role.name) - ) + log.info(c.LOGMSG_INF_SEC_DEL_PERMROLE, perm_view, role.name) except Exception as e: - log.error(c.LOGMSG_ERR_SEC_DEL_PERMROLE.format(str(e))) + log.error(c.LOGMSG_ERR_SEC_DEL_PERMROLE, e) self.get_session.rollback() def export_roles( diff --git a/flask_appbuilder/security/sqla/models.py b/flask_appbuilder/security/sqla/models.py index 87ffaf5d48..be237984c1 100755 --- a/flask_appbuilder/security/sqla/models.py +++ b/flask_appbuilder/security/sqla/models.py @@ -99,13 +99,17 @@ class User(Model): username = Column(String(64), unique=True, nullable=False) password = Column(String(256)) active = Column(Boolean) - email = Column(String(64), unique=True, nullable=False) + email = Column(String(320), unique=True, nullable=False) last_login = Column(DateTime) login_count = Column(Integer) fail_login_count = Column(Integer) roles = relationship("Role", secondary=assoc_user_role, backref="user") - created_on = Column(DateTime, default=datetime.datetime.now, nullable=True) - changed_on = Column(DateTime, default=datetime.datetime.now, nullable=True) + created_on = Column( + DateTime, default=lambda: datetime.datetime.now(), nullable=True + ) + changed_on = Column( + DateTime, default=lambda: datetime.datetime.now(), nullable=True + ) @declared_attr def created_by_fk(self): @@ -157,7 +161,7 @@ def get_id(self): return as_unicode(self.id) def get_full_name(self): - return u"{0} {1}".format(self.first_name, self.last_name) + return "{0} {1}".format(self.first_name, self.last_name) def __repr__(self): return self.get_full_name() diff --git a/flask_appbuilder/security/utils.py b/flask_appbuilder/security/utils.py new file mode 100644 index 0000000000..c5ca9d0039 --- /dev/null +++ b/flask_appbuilder/security/utils.py @@ -0,0 +1,9 @@ +from random import SystemRandom +import string + +LETTERS_AND_DIGITS = string.ascii_letters + string.digits + + +def generate_random_string(length=30): + rand = SystemRandom() + return "".join(rand.choice(LETTERS_AND_DIGITS) for _ in range(length)) diff --git a/flask_appbuilder/security/views.py b/flask_appbuilder/security/views.py index a40222e982..7a73077592 100644 --- a/flask_appbuilder/security/views.py +++ b/flask_appbuilder/security/views.py @@ -9,6 +9,16 @@ from flask_appbuilder.baseviews import BaseView from flask_appbuilder.charts.views import DirectByChartView from flask_appbuilder.fieldwidgets import BS3PasswordFieldWidget +from flask_appbuilder.security.decorators import has_access +from flask_appbuilder.security.forms import ( + DynamicForm, + LoginForm_db, + LoginForm_oid, + ResetPasswordForm, + SelectDataRequired, + UserInfoEdit, +) +from flask_appbuilder.security.utils import generate_random_string from flask_appbuilder.utils.base import get_safe_redirect, lazy_formatter_gettext from flask_appbuilder.validators import PasswordComplexityValidator from flask_appbuilder.views import expose, ModelView, SimpleFormView @@ -21,15 +31,6 @@ from wtforms import PasswordField, validators from wtforms.validators import EqualTo -from .decorators import has_access -from .forms import ( - DynamicForm, - LoginForm_db, - LoginForm_oid, - ResetPasswordForm, - SelectDataRequired, - UserInfoEdit, -) log = logging.getLogger(__name__) @@ -407,7 +408,7 @@ class UserStatsChartView(DirectByChartView): "fail_login_count": lazy_gettext("Failed login count"), } - search_columns = UserModelView.search_columns + search_exclude_columns = UserModelView.search_exclude_columns definitions = [ {"label": "Login Count", "group": "username", "series": ["login_count"]}, @@ -513,15 +514,15 @@ def login(self): return redirect(self.appbuilder.get_url_for_index) form = LoginForm_db() if form.validate_on_submit(): + next_url = get_safe_redirect(request.args.get("next", "")) user = self.appbuilder.sm.auth_user_db( form.username.data, form.password.data ) if not user: flash(as_unicode(self.invalid_login_message), "warning") - return redirect(self.appbuilder.get_url_for_login) + return redirect(self.appbuilder.get_url_for_login_with(next_url)) login_user(user, remember=False) - next_url = request.args.get("next", "") - return redirect(get_safe_redirect(next_url)) + return redirect(next_url) return self.render_template( self.login_template, title=self.title, form=form, appbuilder=self.appbuilder ) @@ -536,15 +537,15 @@ def login(self): return redirect(self.appbuilder.get_url_for_index) form = LoginForm_db() if form.validate_on_submit(): + next_url = get_safe_redirect(request.args.get("next", "")) user = self.appbuilder.sm.auth_user_ldap( form.username.data, form.password.data ) if not user: flash(as_unicode(self.invalid_login_message), "warning") - return redirect(self.appbuilder.get_url_for_login) + return redirect(self.appbuilder.get_url_for_login_with(next_url)) login_user(user, remember=False) - next_url = request.args.get("next", "") - return redirect(get_safe_redirect(next_url)) + return redirect(next_url) return self.render_template( self.login_template, title=self.title, form=form, appbuilder=self.appbuilder ) @@ -605,9 +606,9 @@ class AuthOAuthView(AuthView): @expose("/login/") @expose("/login//") def login(self, provider: Optional[str] = None) -> WerkzeugResponse: - log.debug("Provider: {0}".format(provider)) + log.debug("Provider: %s", provider) if g.user is not None and g.user.is_authenticated: - log.debug("Already authenticated {0}".format(g.user)) + log.debug("Already authenticated %s", g.user) return redirect(self.appbuilder.get_url_for_index) if provider is None: @@ -618,12 +619,12 @@ def login(self, provider: Optional[str] = None) -> WerkzeugResponse: appbuilder=self.appbuilder, ) - log.debug("Going to call authorize for: {0}".format(provider)) + log.debug("Going to call authorize for: %s", provider) + random_state = generate_random_string() state = jwt.encode( - request.args.to_dict(flat=False), - self.appbuilder.app.config["SECRET_KEY"], - algorithm="HS256", + request.args.to_dict(flat=False), random_state, algorithm="HS256" ) + session["oauth_state"] = random_state try: if provider == "twitter": return self.appbuilder.sm.oauth_remotes[provider].authorize_redirect( @@ -642,7 +643,7 @@ def login(self, provider: Optional[str] = None) -> WerkzeugResponse: state=state.decode("ascii") if isinstance(state, bytes) else state, ) except Exception as e: - log.error("Error on OAuth authorize: {0}".format(e)) + log.error("Error on OAuth authorize: %s", e) flash(as_unicode(self.invalid_login_message), "warning") return redirect(self.appbuilder.get_url_for_index) @@ -650,28 +651,28 @@ def login(self, provider: Optional[str] = None) -> WerkzeugResponse: def oauth_authorized(self, provider: str) -> WerkzeugResponse: log.debug("Authorized init") if provider not in self.appbuilder.sm.oauth_remotes: - flash(u"Provider not supported.", "warning") + flash("Provider not supported.", "warning") log.warning("OAuth authorized got an unknown provider %s", provider) return redirect(self.appbuilder.get_url_for_login) try: resp = self.appbuilder.sm.oauth_remotes[provider].authorize_access_token() except Exception as e: - log.error("Error authorizing OAuth access token: {0}".format(e)) + log.error("Error authorizing OAuth access token: %s", e) flash("The request to sign in was denied.", "error") return redirect(self.appbuilder.get_url_for_login) if resp is None: flash("You denied the request to sign in.", "warning") return redirect(self.appbuilder.get_url_for_login) - log.debug("OAUTH Authorized resp: {0}".format(resp)) + log.debug("OAUTH Authorized resp: %s", resp) # Retrieves specific user info from the provider try: self.appbuilder.sm.set_oauth_session(provider, resp) userinfo = self.appbuilder.sm.oauth_user_info(provider, resp) except Exception as e: - log.error("Error returning OAuth user info: {0}".format(e)) + log.error("Error returning OAuth user info: %s", e) user = None else: - log.debug("User info retrieved from {0}: {1}".format(provider, userinfo)) + log.debug("User info retrieved from %s: %s", provider, userinfo) # User email is not whitelisted if provider in self.appbuilder.sm.oauth_whitelists: whitelist = self.appbuilder.sm.oauth_whitelists[provider] @@ -681,7 +682,7 @@ def oauth_authorized(self, provider: str) -> WerkzeugResponse: allow = True break if not allow: - flash(u"You are not authorized.", "warning") + flash("You are not authorized.", "warning") return redirect(self.appbuilder.get_url_for_login) else: log.debug("No whitelist for OAuth provider") @@ -691,16 +692,15 @@ def oauth_authorized(self, provider: str) -> WerkzeugResponse: flash(as_unicode(self.invalid_login_message), "warning") return redirect(self.appbuilder.get_url_for_login) else: - login_user(user) try: state = jwt.decode( - request.args["state"], - self.appbuilder.app.config["SECRET_KEY"], - algorithms=["HS256"], + request.args["state"], session["oauth_state"], algorithms=["HS256"] ) - except jwt.InvalidTokenError: - raise Exception("State signature is not valid!") + except (jwt.InvalidTokenError, KeyError): + flash(as_unicode("Invalid state signature"), "warning") + return redirect(self.appbuilder.get_url_for_login) + login_user(user) next_url = self.appbuilder.get_url_for_index # Check if there is a next url on state if "next" in state and len(state["next"]) > 0: @@ -715,7 +715,8 @@ class AuthRemoteUserView(AuthView): def login(self) -> WerkzeugResponse: username = request.environ.get("REMOTE_USER") if g.user is not None and g.user.is_authenticated: - return redirect(self.appbuilder.get_url_for_index) + next_url = request.args.get("next", "") + return redirect(get_safe_redirect(next_url)) if username: user = self.appbuilder.sm.auth_user_remote_user(username) if user is None: @@ -724,4 +725,5 @@ def login(self) -> WerkzeugResponse: login_user(user) else: flash(as_unicode(self.invalid_login_message), "warning") - return redirect(self.appbuilder.get_url_for_index) + next_url = request.args.get("next", "") + return redirect(get_safe_redirect(next_url)) diff --git a/flask_appbuilder/static/appbuilder/css/ab.css b/flask_appbuilder/static/appbuilder/css/ab.css index 4be76c07e5..00995d5fa1 100644 --- a/flask_appbuilder/static/appbuilder/css/ab.css +++ b/flask_appbuilder/static/appbuilder/css/ab.css @@ -30,6 +30,10 @@ th.action_checkboxes { width: 1%; } +select { + width: 100%; +} + .cursor-hand { cursor: pointer; cursor: hand; diff --git a/flask_appbuilder/static/appbuilder/css/font-awesome.min.css b/flask_appbuilder/static/appbuilder/css/font-awesome.min.css deleted file mode 100644 index 540440ce89..0000000000 --- a/flask_appbuilder/static/appbuilder/css/font-awesome.min.css +++ /dev/null @@ -1,4 +0,0 @@ -/*! - * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.7.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}.fa-gitlab:before{content:"\f296"}.fa-wpbeginner:before{content:"\f297"}.fa-wpforms:before{content:"\f298"}.fa-envira:before{content:"\f299"}.fa-universal-access:before{content:"\f29a"}.fa-wheelchair-alt:before{content:"\f29b"}.fa-question-circle-o:before{content:"\f29c"}.fa-blind:before{content:"\f29d"}.fa-audio-description:before{content:"\f29e"}.fa-volume-control-phone:before{content:"\f2a0"}.fa-braille:before{content:"\f2a1"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-signing:before,.fa-sign-language:before{content:"\f2a7"}.fa-low-vision:before{content:"\f2a8"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-pied-piper:before{content:"\f2ae"}.fa-first-order:before{content:"\f2b0"}.fa-yoast:before{content:"\f2b1"}.fa-themeisle:before{content:"\f2b2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"}.fa-fa:before,.fa-font-awesome:before{content:"\f2b4"}.fa-handshake-o:before{content:"\f2b5"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-o:before{content:"\f2b7"}.fa-linode:before{content:"\f2b8"}.fa-address-book:before{content:"\f2b9"}.fa-address-book-o:before{content:"\f2ba"}.fa-vcard:before,.fa-address-card:before{content:"\f2bb"}.fa-vcard-o:before,.fa-address-card-o:before{content:"\f2bc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-circle-o:before{content:"\f2be"}.fa-user-o:before{content:"\f2c0"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-drivers-license-o:before,.fa-id-card-o:before{content:"\f2c3"}.fa-quora:before{content:"\f2c4"}.fa-free-code-camp:before{content:"\f2c5"}.fa-telegram:before{content:"\f2c6"}.fa-thermometer-4:before,.fa-thermometer:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-shower:before{content:"\f2cc"}.fa-bathtub:before,.fa-s15:before,.fa-bath:before{content:"\f2cd"}.fa-podcast:before{content:"\f2ce"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-times-rectangle:before,.fa-window-close:before{content:"\f2d3"}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\f2d4"}.fa-bandcamp:before{content:"\f2d5"}.fa-grav:before{content:"\f2d6"}.fa-etsy:before{content:"\f2d7"}.fa-imdb:before{content:"\f2d8"}.fa-ravelry:before{content:"\f2d9"}.fa-eercast:before{content:"\f2da"}.fa-microchip:before{content:"\f2db"}.fa-snowflake-o:before{content:"\f2dc"}.fa-superpowers:before{content:"\f2dd"}.fa-wpexplorer:before{content:"\f2de"}.fa-meetup:before{content:"\f2e0"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto} diff --git a/flask_appbuilder/static/appbuilder/css/fontawesome/brands.min.css b/flask_appbuilder/static/appbuilder/css/fontawesome/brands.min.css new file mode 100644 index 0000000000..69b4fbe7cd --- /dev/null +++ b/flask_appbuilder/static/appbuilder/css/fontawesome/brands.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Free 6.2.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2022 Fonticons, Inc. + */ +:host,:root{--fa-style-family-brands:"Font Awesome 6 Brands";--fa-font-brands:normal 400 1em/1 "Font Awesome 6 Brands"}@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../../webfonts/fa-brands-400.woff2) format("woff2"),url(../../webfonts/fa-brands-400.ttf) format("truetype")}.fa-brands,.fab{font-weight:400}.fa-monero:before{content:"\f3d0"}.fa-hooli:before{content:"\f427"}.fa-yelp:before{content:"\f1e9"}.fa-cc-visa:before{content:"\f1f0"}.fa-lastfm:before{content:"\f202"}.fa-shopware:before{content:"\f5b5"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-aws:before{content:"\f375"}.fa-redhat:before{content:"\f7bc"}.fa-yoast:before{content:"\f2b1"}.fa-cloudflare:before{content:"\e07d"}.fa-ups:before{content:"\f7e0"}.fa-wpexplorer:before{content:"\f2de"}.fa-dyalog:before{content:"\f399"}.fa-bity:before{content:"\f37a"}.fa-stackpath:before{content:"\f842"}.fa-buysellads:before{content:"\f20d"}.fa-first-order:before{content:"\f2b0"}.fa-modx:before{content:"\f285"}.fa-guilded:before{content:"\e07e"}.fa-vnv:before{content:"\f40b"}.fa-js-square:before,.fa-square-js:before{content:"\f3b9"}.fa-microsoft:before{content:"\f3ca"}.fa-qq:before{content:"\f1d6"}.fa-orcid:before{content:"\f8d2"}.fa-java:before{content:"\f4e4"}.fa-invision:before{content:"\f7b0"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-centercode:before{content:"\f380"}.fa-glide-g:before{content:"\f2a6"}.fa-drupal:before{content:"\f1a9"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-unity:before{content:"\e049"}.fa-whmcs:before{content:"\f40d"}.fa-rocketchat:before{content:"\f3e8"}.fa-vk:before{content:"\f189"}.fa-untappd:before{content:"\f405"}.fa-mailchimp:before{content:"\f59e"}.fa-css3-alt:before{content:"\f38b"}.fa-reddit-square:before,.fa-square-reddit:before{content:"\f1a2"}.fa-vimeo-v:before{content:"\f27d"}.fa-contao:before{content:"\f26d"}.fa-square-font-awesome:before{content:"\e5ad"}.fa-deskpro:before{content:"\f38f"}.fa-sistrix:before{content:"\f3ee"}.fa-instagram-square:before,.fa-square-instagram:before{content:"\e055"}.fa-battle-net:before{content:"\f835"}.fa-the-red-yeti:before{content:"\f69d"}.fa-hacker-news-square:before,.fa-square-hacker-news:before{content:"\f3af"}.fa-edge:before{content:"\f282"}.fa-napster:before{content:"\f3d2"}.fa-snapchat-square:before,.fa-square-snapchat:before{content:"\f2ad"}.fa-google-plus-g:before{content:"\f0d5"}.fa-artstation:before{content:"\f77a"}.fa-markdown:before{content:"\f60f"}.fa-sourcetree:before{content:"\f7d3"}.fa-google-plus:before{content:"\f2b3"}.fa-diaspora:before{content:"\f791"}.fa-foursquare:before{content:"\f180"}.fa-stack-overflow:before{content:"\f16c"}.fa-github-alt:before{content:"\f113"}.fa-phoenix-squadron:before{content:"\f511"}.fa-pagelines:before{content:"\f18c"}.fa-algolia:before{content:"\f36c"}.fa-red-river:before{content:"\f3e3"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-safari:before{content:"\f267"}.fa-google:before{content:"\f1a0"}.fa-font-awesome-alt:before,.fa-square-font-awesome-stroke:before{content:"\f35c"}.fa-atlassian:before{content:"\f77b"}.fa-linkedin-in:before{content:"\f0e1"}.fa-digital-ocean:before{content:"\f391"}.fa-nimblr:before{content:"\f5a8"}.fa-chromecast:before{content:"\f838"}.fa-evernote:before{content:"\f839"}.fa-hacker-news:before{content:"\f1d4"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-adversal:before{content:"\f36a"}.fa-creative-commons:before{content:"\f25e"}.fa-watchman-monitoring:before{content:"\e087"}.fa-fonticons:before{content:"\f280"}.fa-weixin:before{content:"\f1d7"}.fa-shirtsinbulk:before{content:"\f214"}.fa-codepen:before{content:"\f1cb"}.fa-git-alt:before{content:"\f841"}.fa-lyft:before{content:"\f3c3"}.fa-rev:before{content:"\f5b2"}.fa-windows:before{content:"\f17a"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-square-viadeo:before,.fa-viadeo-square:before{content:"\f2aa"}.fa-meetup:before{content:"\f2e0"}.fa-centos:before{content:"\f789"}.fa-adn:before{content:"\f170"}.fa-cloudsmith:before{content:"\f384"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-dribbble-square:before,.fa-square-dribbble:before{content:"\f397"}.fa-codiepie:before{content:"\f284"}.fa-node:before{content:"\f419"}.fa-mix:before{content:"\f3cb"}.fa-steam:before{content:"\f1b6"}.fa-cc-apple-pay:before{content:"\f416"}.fa-scribd:before{content:"\f28a"}.fa-openid:before{content:"\f19b"}.fa-instalod:before{content:"\e081"}.fa-expeditedssl:before{content:"\f23e"}.fa-sellcast:before{content:"\f2da"}.fa-square-twitter:before,.fa-twitter-square:before{content:"\f081"}.fa-r-project:before{content:"\f4f7"}.fa-delicious:before{content:"\f1a5"}.fa-freebsd:before{content:"\f3a4"}.fa-vuejs:before{content:"\f41f"}.fa-accusoft:before{content:"\f369"}.fa-ioxhost:before{content:"\f208"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-app-store:before{content:"\f36f"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-itunes-note:before{content:"\f3b5"}.fa-golang:before{content:"\e40f"}.fa-kickstarter:before{content:"\f3bb"}.fa-grav:before{content:"\f2d6"}.fa-weibo:before{content:"\f18a"}.fa-uncharted:before{content:"\e084"}.fa-firstdraft:before{content:"\f3a1"}.fa-square-youtube:before,.fa-youtube-square:before{content:"\f431"}.fa-wikipedia-w:before{content:"\f266"}.fa-rendact:before,.fa-wpressr:before{content:"\f3e4"}.fa-angellist:before{content:"\f209"}.fa-galactic-republic:before{content:"\f50c"}.fa-nfc-directional:before{content:"\e530"}.fa-skype:before{content:"\f17e"}.fa-joget:before{content:"\f3b7"}.fa-fedora:before{content:"\f798"}.fa-stripe-s:before{content:"\f42a"}.fa-meta:before{content:"\e49b"}.fa-laravel:before{content:"\f3bd"}.fa-hotjar:before{content:"\f3b1"}.fa-bluetooth-b:before{content:"\f294"}.fa-sticker-mule:before{content:"\f3f7"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-hips:before{content:"\f452"}.fa-behance:before{content:"\f1b4"}.fa-reddit:before{content:"\f1a1"}.fa-discord:before{content:"\f392"}.fa-chrome:before{content:"\f268"}.fa-app-store-ios:before{content:"\f370"}.fa-cc-discover:before{content:"\f1f2"}.fa-wpbeginner:before{content:"\f297"}.fa-confluence:before{content:"\f78d"}.fa-mdb:before{content:"\f8ca"}.fa-dochub:before{content:"\f394"}.fa-accessible-icon:before{content:"\f368"}.fa-ebay:before{content:"\f4f4"}.fa-amazon:before{content:"\f270"}.fa-unsplash:before{content:"\e07c"}.fa-yarn:before{content:"\f7e3"}.fa-square-steam:before,.fa-steam-square:before{content:"\f1b7"}.fa-500px:before{content:"\f26e"}.fa-square-vimeo:before,.fa-vimeo-square:before{content:"\f194"}.fa-asymmetrik:before{content:"\f372"}.fa-font-awesome-flag:before,.fa-font-awesome-logo-full:before,.fa-font-awesome:before{content:"\f2b4"}.fa-gratipay:before{content:"\f184"}.fa-apple:before{content:"\f179"}.fa-hive:before{content:"\e07f"}.fa-gitkraken:before{content:"\f3a6"}.fa-keybase:before{content:"\f4f5"}.fa-apple-pay:before{content:"\f415"}.fa-padlet:before{content:"\e4a0"}.fa-amazon-pay:before{content:"\f42c"}.fa-github-square:before,.fa-square-github:before{content:"\f092"}.fa-stumbleupon:before{content:"\f1a4"}.fa-fedex:before{content:"\f797"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-shopify:before{content:"\e057"}.fa-neos:before{content:"\f612"}.fa-hackerrank:before{content:"\f5f7"}.fa-researchgate:before{content:"\f4f8"}.fa-swift:before{content:"\f8e1"}.fa-angular:before{content:"\f420"}.fa-speakap:before{content:"\f3f3"}.fa-angrycreative:before{content:"\f36e"}.fa-y-combinator:before{content:"\f23b"}.fa-empire:before{content:"\f1d1"}.fa-envira:before{content:"\f299"}.fa-gitlab-square:before,.fa-square-gitlab:before{content:"\e5ae"}.fa-studiovinari:before{content:"\f3f8"}.fa-pied-piper:before{content:"\f2ae"}.fa-wordpress:before{content:"\f19a"}.fa-product-hunt:before{content:"\f288"}.fa-firefox:before{content:"\f269"}.fa-linode:before{content:"\f2b8"}.fa-goodreads:before{content:"\f3a8"}.fa-odnoklassniki-square:before,.fa-square-odnoklassniki:before{content:"\f264"}.fa-jsfiddle:before{content:"\f1cc"}.fa-sith:before{content:"\f512"}.fa-themeisle:before{content:"\f2b2"}.fa-page4:before{content:"\f3d7"}.fa-hashnode:before{content:"\e499"}.fa-react:before{content:"\f41b"}.fa-cc-paypal:before{content:"\f1f4"}.fa-squarespace:before{content:"\f5be"}.fa-cc-stripe:before{content:"\f1f5"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-bitcoin:before{content:"\f379"}.fa-keycdn:before{content:"\f3ba"}.fa-opera:before{content:"\f26a"}.fa-itch-io:before{content:"\f83a"}.fa-umbraco:before{content:"\f8e8"}.fa-galactic-senate:before{content:"\f50d"}.fa-ubuntu:before{content:"\f7df"}.fa-draft2digital:before{content:"\f396"}.fa-stripe:before{content:"\f429"}.fa-houzz:before{content:"\f27c"}.fa-gg:before{content:"\f260"}.fa-dhl:before{content:"\f790"}.fa-pinterest-square:before,.fa-square-pinterest:before{content:"\f0d3"}.fa-xing:before{content:"\f168"}.fa-blackberry:before{content:"\f37b"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-playstation:before{content:"\f3df"}.fa-quinscape:before{content:"\f459"}.fa-less:before{content:"\f41d"}.fa-blogger-b:before{content:"\f37d"}.fa-opencart:before{content:"\f23d"}.fa-vine:before{content:"\f1ca"}.fa-paypal:before{content:"\f1ed"}.fa-gitlab:before{content:"\f296"}.fa-typo3:before{content:"\f42b"}.fa-reddit-alien:before{content:"\f281"}.fa-yahoo:before{content:"\f19e"}.fa-dailymotion:before{content:"\e052"}.fa-affiliatetheme:before{content:"\f36b"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-bootstrap:before{content:"\f836"}.fa-odnoklassniki:before{content:"\f263"}.fa-nfc-symbol:before{content:"\e531"}.fa-ethereum:before{content:"\f42e"}.fa-speaker-deck:before{content:"\f83c"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-patreon:before{content:"\f3d9"}.fa-avianex:before{content:"\f374"}.fa-ello:before{content:"\f5f1"}.fa-gofore:before{content:"\f3a7"}.fa-bimobject:before{content:"\f378"}.fa-facebook-f:before{content:"\f39e"}.fa-google-plus-square:before,.fa-square-google-plus:before{content:"\f0d4"}.fa-mandalorian:before{content:"\f50f"}.fa-first-order-alt:before{content:"\f50a"}.fa-osi:before{content:"\f41a"}.fa-google-wallet:before{content:"\f1ee"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-periscope:before{content:"\f3da"}.fa-fulcrum:before{content:"\f50b"}.fa-cloudscale:before{content:"\f383"}.fa-forumbee:before{content:"\f211"}.fa-mizuni:before{content:"\f3cc"}.fa-schlix:before{content:"\f3ea"}.fa-square-xing:before,.fa-xing-square:before{content:"\f169"}.fa-bandcamp:before{content:"\f2d5"}.fa-wpforms:before{content:"\f298"}.fa-cloudversify:before{content:"\f385"}.fa-usps:before{content:"\f7e1"}.fa-megaport:before{content:"\f5a3"}.fa-magento:before{content:"\f3c4"}.fa-spotify:before{content:"\f1bc"}.fa-optin-monster:before{content:"\f23c"}.fa-fly:before{content:"\f417"}.fa-aviato:before{content:"\f421"}.fa-itunes:before{content:"\f3b4"}.fa-cuttlefish:before{content:"\f38c"}.fa-blogger:before{content:"\f37c"}.fa-flickr:before{content:"\f16e"}.fa-viber:before{content:"\f409"}.fa-soundcloud:before{content:"\f1be"}.fa-digg:before{content:"\f1a6"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-symfony:before{content:"\f83d"}.fa-maxcdn:before{content:"\f136"}.fa-etsy:before{content:"\f2d7"}.fa-facebook-messenger:before{content:"\f39f"}.fa-audible:before{content:"\f373"}.fa-think-peaks:before{content:"\f731"}.fa-bilibili:before{content:"\e3d9"}.fa-erlang:before{content:"\f39d"}.fa-cotton-bureau:before{content:"\f89e"}.fa-dashcube:before{content:"\f210"}.fa-42-group:before,.fa-innosoft:before{content:"\e080"}.fa-stack-exchange:before{content:"\f18d"}.fa-elementor:before{content:"\f430"}.fa-pied-piper-square:before,.fa-square-pied-piper:before{content:"\e01e"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-palfed:before{content:"\f3d8"}.fa-superpowers:before{content:"\f2dd"}.fa-resolving:before{content:"\f3e7"}.fa-xbox:before{content:"\f412"}.fa-searchengin:before{content:"\f3eb"}.fa-tiktok:before{content:"\e07b"}.fa-facebook-square:before,.fa-square-facebook:before{content:"\f082"}.fa-renren:before{content:"\f18b"}.fa-linux:before{content:"\f17c"}.fa-glide:before{content:"\f2a5"}.fa-linkedin:before{content:"\f08c"}.fa-hubspot:before{content:"\f3b2"}.fa-deploydog:before{content:"\f38e"}.fa-twitch:before{content:"\f1e8"}.fa-ravelry:before{content:"\f2d9"}.fa-mixer:before{content:"\e056"}.fa-lastfm-square:before,.fa-square-lastfm:before{content:"\f203"}.fa-vimeo:before{content:"\f40a"}.fa-mendeley:before{content:"\f7b3"}.fa-uniregistry:before{content:"\f404"}.fa-figma:before{content:"\f799"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-dropbox:before{content:"\f16b"}.fa-instagram:before{content:"\f16d"}.fa-cmplid:before{content:"\e360"}.fa-facebook:before{content:"\f09a"}.fa-gripfire:before{content:"\f3ac"}.fa-jedi-order:before{content:"\f50e"}.fa-uikit:before{content:"\f403"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-phabricator:before{content:"\f3db"}.fa-ussunnah:before{content:"\f407"}.fa-earlybirds:before{content:"\f39a"}.fa-trade-federation:before{content:"\f513"}.fa-autoprefixer:before{content:"\f41c"}.fa-whatsapp:before{content:"\f232"}.fa-slideshare:before{content:"\f1e7"}.fa-google-play:before{content:"\f3ab"}.fa-viadeo:before{content:"\f2a9"}.fa-line:before{content:"\f3c0"}.fa-google-drive:before{content:"\f3aa"}.fa-servicestack:before{content:"\f3ec"}.fa-simplybuilt:before{content:"\f215"}.fa-bitbucket:before{content:"\f171"}.fa-imdb:before{content:"\f2d8"}.fa-deezer:before{content:"\e077"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-jira:before{content:"\f7b1"}.fa-docker:before{content:"\f395"}.fa-screenpal:before{content:"\e570"}.fa-bluetooth:before{content:"\f293"}.fa-gitter:before{content:"\f426"}.fa-d-and-d:before{content:"\f38d"}.fa-microblog:before{content:"\e01a"}.fa-cc-diners-club:before{content:"\f24c"}.fa-gg-circle:before{content:"\f261"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-yandex:before{content:"\f413"}.fa-readme:before{content:"\f4d5"}.fa-html5:before{content:"\f13b"}.fa-sellsy:before{content:"\f213"}.fa-sass:before{content:"\f41e"}.fa-wirsindhandwerk:before,.fa-wsh:before{content:"\e2d0"}.fa-buromobelexperte:before{content:"\f37f"}.fa-salesforce:before{content:"\f83b"}.fa-octopus-deploy:before{content:"\e082"}.fa-medapps:before{content:"\f3c6"}.fa-ns8:before{content:"\f3d5"}.fa-pinterest-p:before{content:"\f231"}.fa-apper:before{content:"\f371"}.fa-fort-awesome:before{content:"\f286"}.fa-waze:before{content:"\f83f"}.fa-cc-jcb:before{content:"\f24b"}.fa-snapchat-ghost:before,.fa-snapchat:before{content:"\f2ab"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-rust:before{content:"\e07a"}.fa-wix:before{content:"\f5cf"}.fa-behance-square:before,.fa-square-behance:before{content:"\f1b5"}.fa-supple:before{content:"\f3f9"}.fa-rebel:before{content:"\f1d0"}.fa-css3:before{content:"\f13c"}.fa-staylinked:before{content:"\f3f5"}.fa-kaggle:before{content:"\f5fa"}.fa-space-awesome:before{content:"\e5ac"}.fa-deviantart:before{content:"\f1bd"}.fa-cpanel:before{content:"\f388"}.fa-goodreads-g:before{content:"\f3a9"}.fa-git-square:before,.fa-square-git:before{content:"\f1d2"}.fa-square-tumblr:before,.fa-tumblr-square:before{content:"\f174"}.fa-trello:before{content:"\f181"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-get-pocket:before{content:"\f265"}.fa-perbyte:before{content:"\e083"}.fa-grunt:before{content:"\f3ad"}.fa-weebly:before{content:"\f5cc"}.fa-connectdevelop:before{content:"\f20e"}.fa-leanpub:before{content:"\f212"}.fa-black-tie:before{content:"\f27e"}.fa-themeco:before{content:"\f5c6"}.fa-python:before{content:"\f3e2"}.fa-android:before{content:"\f17b"}.fa-bots:before{content:"\e340"}.fa-free-code-camp:before{content:"\f2c5"}.fa-hornbill:before{content:"\f592"}.fa-js:before{content:"\f3b8"}.fa-ideal:before{content:"\e013"}.fa-git:before{content:"\f1d3"}.fa-dev:before{content:"\f6cc"}.fa-sketch:before{content:"\f7c6"}.fa-yandex-international:before{content:"\f414"}.fa-cc-amex:before{content:"\f1f3"}.fa-uber:before{content:"\f402"}.fa-github:before{content:"\f09b"}.fa-php:before{content:"\f457"}.fa-alipay:before{content:"\f642"}.fa-youtube:before{content:"\f167"}.fa-skyatlas:before{content:"\f216"}.fa-firefox-browser:before{content:"\e007"}.fa-replyd:before{content:"\f3e6"}.fa-suse:before{content:"\f7d6"}.fa-jenkins:before{content:"\f3b6"}.fa-twitter:before{content:"\f099"}.fa-rockrms:before{content:"\f3e9"}.fa-pinterest:before{content:"\f0d2"}.fa-buffer:before{content:"\f837"}.fa-npm:before{content:"\f3d4"}.fa-yammer:before{content:"\f840"}.fa-btc:before{content:"\f15a"}.fa-dribbble:before{content:"\f17d"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-internet-explorer:before{content:"\f26b"}.fa-telegram-plane:before,.fa-telegram:before{content:"\f2c6"}.fa-old-republic:before{content:"\f510"}.fa-square-whatsapp:before,.fa-whatsapp-square:before{content:"\f40c"}.fa-node-js:before{content:"\f3d3"}.fa-edge-legacy:before{content:"\e078"}.fa-slack-hash:before,.fa-slack:before{content:"\f198"}.fa-medrt:before{content:"\f3c8"}.fa-usb:before{content:"\f287"}.fa-tumblr:before{content:"\f173"}.fa-vaadin:before{content:"\f408"}.fa-quora:before{content:"\f2c4"}.fa-reacteurope:before{content:"\f75d"}.fa-medium-m:before,.fa-medium:before{content:"\f23a"}.fa-amilia:before{content:"\f36d"}.fa-mixcloud:before{content:"\f289"}.fa-flipboard:before{content:"\f44d"}.fa-viacoin:before{content:"\f237"}.fa-critical-role:before{content:"\f6c9"}.fa-sitrox:before{content:"\e44a"}.fa-discourse:before{content:"\f393"}.fa-joomla:before{content:"\f1aa"}.fa-mastodon:before{content:"\f4f6"}.fa-airbnb:before{content:"\f834"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-buy-n-large:before{content:"\f8a6"}.fa-gulp:before{content:"\f3ae"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-strava:before{content:"\f428"}.fa-ember:before{content:"\f423"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-teamspeak:before{content:"\f4f9"}.fa-pushed:before{content:"\f3e1"}.fa-wordpress-simple:before{content:"\f411"}.fa-nutritionix:before{content:"\f3d6"}.fa-wodu:before{content:"\e088"}.fa-google-pay:before{content:"\e079"}.fa-intercom:before{content:"\f7af"}.fa-zhihu:before{content:"\f63f"}.fa-korvue:before{content:"\f42f"}.fa-pix:before{content:"\e43a"}.fa-steam-symbol:before{content:"\f3f6"} \ No newline at end of file diff --git a/flask_appbuilder/static/appbuilder/css/fontawesome/fontawesome.min.css b/flask_appbuilder/static/appbuilder/css/fontawesome/fontawesome.min.css new file mode 100644 index 0000000000..15c5b10454 --- /dev/null +++ b/flask_appbuilder/static/appbuilder/css/fontawesome/fontawesome.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Free 6.2.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2022 Fonticons, Inc. + */ +.fa{font-family:var(--fa-style-family,"Font Awesome 6 Free");font-weight:var(--fa-style,900)}.fa,.fa-brands,.fa-classic,.fa-regular,.fa-sharp,.fa-solid,.fab,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}.fa-classic,.fa-regular,.fa-solid,.far,.fas{font-family:"Font Awesome 6 Free"}.fa-brands,.fab{font-family:"Font Awesome 6 Brands"}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{left:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-animation-delay:-1ms;animation-delay:-1ms;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;transition-delay:0s;transition-duration:0s}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,none));transform:rotate(var(--fa-rotate-angle,none))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%;z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)}.fa-0:before{content:"\30"}.fa-1:before{content:"\31"}.fa-2:before{content:"\32"}.fa-3:before{content:"\33"}.fa-4:before{content:"\34"}.fa-5:before{content:"\35"}.fa-6:before{content:"\36"}.fa-7:before{content:"\37"}.fa-8:before{content:"\38"}.fa-9:before{content:"\39"}.fa-fill-drip:before{content:"\f576"}.fa-arrows-to-circle:before{content:"\e4bd"}.fa-chevron-circle-right:before,.fa-circle-chevron-right:before{content:"\f138"}.fa-at:before{content:"\40"}.fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"}.fa-text-height:before{content:"\f034"}.fa-user-times:before,.fa-user-xmark:before{content:"\f235"}.fa-stethoscope:before{content:"\f0f1"}.fa-comment-alt:before,.fa-message:before{content:"\f27a"}.fa-info:before{content:"\f129"}.fa-compress-alt:before,.fa-down-left-and-up-right-to-center:before{content:"\f422"}.fa-explosion:before{content:"\e4e9"}.fa-file-alt:before,.fa-file-lines:before,.fa-file-text:before{content:"\f15c"}.fa-wave-square:before{content:"\f83e"}.fa-ring:before{content:"\f70b"}.fa-building-un:before{content:"\e4d9"}.fa-dice-three:before{content:"\f527"}.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}.fa-anchor-circle-check:before{content:"\e4aa"}.fa-building-circle-arrow-right:before{content:"\e4d1"}.fa-volleyball-ball:before,.fa-volleyball:before{content:"\f45f"}.fa-arrows-up-to-line:before{content:"\e4c2"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-circle-minus:before,.fa-minus-circle:before{content:"\f056"}.fa-door-open:before{content:"\f52b"}.fa-right-from-bracket:before,.fa-sign-out-alt:before{content:"\f2f5"}.fa-atom:before{content:"\f5d2"}.fa-soap:before{content:"\e06e"}.fa-heart-music-camera-bolt:before,.fa-icons:before{content:"\f86d"}.fa-microphone-alt-slash:before,.fa-microphone-lines-slash:before{content:"\f539"}.fa-bridge-circle-check:before{content:"\e4c9"}.fa-pump-medical:before{content:"\e06a"}.fa-fingerprint:before{content:"\f577"}.fa-hand-point-right:before{content:"\f0a4"}.fa-magnifying-glass-location:before,.fa-search-location:before{content:"\f689"}.fa-forward-step:before,.fa-step-forward:before{content:"\f051"}.fa-face-smile-beam:before,.fa-smile-beam:before{content:"\f5b8"}.fa-flag-checkered:before{content:"\f11e"}.fa-football-ball:before,.fa-football:before{content:"\f44e"}.fa-school-circle-exclamation:before{content:"\e56c"}.fa-crop:before{content:"\f125"}.fa-angle-double-down:before,.fa-angles-down:before{content:"\f103"}.fa-users-rectangle:before{content:"\e594"}.fa-people-roof:before{content:"\e537"}.fa-people-line:before{content:"\e534"}.fa-beer-mug-empty:before,.fa-beer:before{content:"\f0fc"}.fa-diagram-predecessor:before{content:"\e477"}.fa-arrow-up-long:before,.fa-long-arrow-up:before{content:"\f176"}.fa-burn:before,.fa-fire-flame-simple:before{content:"\f46a"}.fa-male:before,.fa-person:before{content:"\f183"}.fa-laptop:before{content:"\f109"}.fa-file-csv:before{content:"\f6dd"}.fa-menorah:before{content:"\f676"}.fa-truck-plane:before{content:"\e58f"}.fa-record-vinyl:before{content:"\f8d9"}.fa-face-grin-stars:before,.fa-grin-stars:before{content:"\f587"}.fa-bong:before{content:"\f55c"}.fa-pastafarianism:before,.fa-spaghetti-monster-flying:before{content:"\f67b"}.fa-arrow-down-up-across-line:before{content:"\e4af"}.fa-spoon:before,.fa-utensil-spoon:before{content:"\f2e5"}.fa-jar-wheat:before{content:"\e517"}.fa-envelopes-bulk:before,.fa-mail-bulk:before{content:"\f674"}.fa-file-circle-exclamation:before{content:"\e4eb"}.fa-circle-h:before,.fa-hospital-symbol:before{content:"\f47e"}.fa-pager:before{content:"\f815"}.fa-address-book:before,.fa-contact-book:before{content:"\f2b9"}.fa-strikethrough:before{content:"\f0cc"}.fa-k:before{content:"\4b"}.fa-landmark-flag:before{content:"\e51c"}.fa-pencil-alt:before,.fa-pencil:before{content:"\f303"}.fa-backward:before{content:"\f04a"}.fa-caret-right:before{content:"\f0da"}.fa-comments:before{content:"\f086"}.fa-file-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-code-pull-request:before{content:"\e13c"}.fa-clipboard-list:before{content:"\f46d"}.fa-truck-loading:before,.fa-truck-ramp-box:before{content:"\f4de"}.fa-user-check:before{content:"\f4fc"}.fa-vial-virus:before{content:"\e597"}.fa-sheet-plastic:before{content:"\e571"}.fa-blog:before{content:"\f781"}.fa-user-ninja:before{content:"\f504"}.fa-person-arrow-up-from-line:before{content:"\e539"}.fa-scroll-torah:before,.fa-torah:before{content:"\f6a0"}.fa-broom-ball:before,.fa-quidditch-broom-ball:before,.fa-quidditch:before{content:"\f458"}.fa-toggle-off:before{content:"\f204"}.fa-archive:before,.fa-box-archive:before{content:"\f187"}.fa-person-drowning:before{content:"\e545"}.fa-arrow-down-9-1:before,.fa-sort-numeric-desc:before,.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-face-grin-tongue-squint:before,.fa-grin-tongue-squint:before{content:"\f58a"}.fa-spray-can:before{content:"\f5bd"}.fa-truck-monster:before{content:"\f63b"}.fa-w:before{content:"\57"}.fa-earth-africa:before,.fa-globe-africa:before{content:"\f57c"}.fa-rainbow:before{content:"\f75b"}.fa-circle-notch:before{content:"\f1ce"}.fa-tablet-alt:before,.fa-tablet-screen-button:before{content:"\f3fa"}.fa-paw:before{content:"\f1b0"}.fa-cloud:before{content:"\f0c2"}.fa-trowel-bricks:before{content:"\e58a"}.fa-face-flushed:before,.fa-flushed:before{content:"\f579"}.fa-hospital-user:before{content:"\f80d"}.fa-tent-arrow-left-right:before{content:"\e57f"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-binoculars:before{content:"\f1e5"}.fa-microphone-slash:before{content:"\f131"}.fa-box-tissue:before{content:"\e05b"}.fa-motorcycle:before{content:"\f21c"}.fa-bell-concierge:before,.fa-concierge-bell:before{content:"\f562"}.fa-pen-ruler:before,.fa-pencil-ruler:before{content:"\f5ae"}.fa-people-arrows-left-right:before,.fa-people-arrows:before{content:"\e068"}.fa-mars-and-venus-burst:before{content:"\e523"}.fa-caret-square-right:before,.fa-square-caret-right:before{content:"\f152"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-sun-plant-wilt:before{content:"\e57a"}.fa-toilets-portable:before{content:"\e584"}.fa-hockey-puck:before{content:"\f453"}.fa-table:before{content:"\f0ce"}.fa-magnifying-glass-arrow-right:before{content:"\e521"}.fa-digital-tachograph:before,.fa-tachograph-digital:before{content:"\f566"}.fa-users-slash:before{content:"\e073"}.fa-clover:before{content:"\e139"}.fa-mail-reply:before,.fa-reply:before{content:"\f3e5"}.fa-star-and-crescent:before{content:"\f699"}.fa-house-fire:before{content:"\e50c"}.fa-minus-square:before,.fa-square-minus:before{content:"\f146"}.fa-helicopter:before{content:"\f533"}.fa-compass:before{content:"\f14e"}.fa-caret-square-down:before,.fa-square-caret-down:before{content:"\f150"}.fa-file-circle-question:before{content:"\e4ef"}.fa-laptop-code:before{content:"\f5fc"}.fa-swatchbook:before{content:"\f5c3"}.fa-prescription-bottle:before{content:"\f485"}.fa-bars:before,.fa-navicon:before{content:"\f0c9"}.fa-people-group:before{content:"\e533"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-heart-broken:before,.fa-heart-crack:before{content:"\f7a9"}.fa-external-link-square-alt:before,.fa-square-up-right:before{content:"\f360"}.fa-face-kiss-beam:before,.fa-kiss-beam:before{content:"\f597"}.fa-film:before{content:"\f008"}.fa-ruler-horizontal:before{content:"\f547"}.fa-people-robbery:before{content:"\e536"}.fa-lightbulb:before{content:"\f0eb"}.fa-caret-left:before{content:"\f0d9"}.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}.fa-school-circle-xmark:before{content:"\e56d"}.fa-arrow-right-from-bracket:before,.fa-sign-out:before{content:"\f08b"}.fa-chevron-circle-down:before,.fa-circle-chevron-down:before{content:"\f13a"}.fa-unlock-alt:before,.fa-unlock-keyhole:before{content:"\f13e"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-headphones-alt:before,.fa-headphones-simple:before{content:"\f58f"}.fa-sitemap:before{content:"\f0e8"}.fa-circle-dollar-to-slot:before,.fa-donate:before{content:"\f4b9"}.fa-memory:before{content:"\f538"}.fa-road-spikes:before{content:"\e568"}.fa-fire-burner:before{content:"\e4f1"}.fa-flag:before{content:"\f024"}.fa-hanukiah:before{content:"\f6e6"}.fa-feather:before{content:"\f52d"}.fa-volume-down:before,.fa-volume-low:before{content:"\f027"}.fa-comment-slash:before{content:"\f4b3"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-compress:before{content:"\f066"}.fa-wheat-alt:before,.fa-wheat-awn:before{content:"\e2cd"}.fa-ankh:before{content:"\f644"}.fa-hands-holding-child:before{content:"\e4fa"}.fa-asterisk:before{content:"\2a"}.fa-check-square:before,.fa-square-check:before{content:"\f14a"}.fa-peseta-sign:before{content:"\e221"}.fa-header:before,.fa-heading:before{content:"\f1dc"}.fa-ghost:before{content:"\f6e2"}.fa-list-squares:before,.fa-list:before{content:"\f03a"}.fa-phone-square-alt:before,.fa-square-phone-flip:before{content:"\f87b"}.fa-cart-plus:before{content:"\f217"}.fa-gamepad:before{content:"\f11b"}.fa-circle-dot:before,.fa-dot-circle:before{content:"\f192"}.fa-dizzy:before,.fa-face-dizzy:before{content:"\f567"}.fa-egg:before{content:"\f7fb"}.fa-house-medical-circle-xmark:before{content:"\e513"}.fa-campground:before{content:"\f6bb"}.fa-folder-plus:before{content:"\f65e"}.fa-futbol-ball:before,.fa-futbol:before,.fa-soccer-ball:before{content:"\f1e3"}.fa-paint-brush:before,.fa-paintbrush:before{content:"\f1fc"}.fa-lock:before{content:"\f023"}.fa-gas-pump:before{content:"\f52f"}.fa-hot-tub-person:before,.fa-hot-tub:before{content:"\f593"}.fa-map-location:before,.fa-map-marked:before{content:"\f59f"}.fa-house-flood-water:before{content:"\e50e"}.fa-tree:before{content:"\f1bb"}.fa-bridge-lock:before{content:"\e4cc"}.fa-sack-dollar:before{content:"\f81d"}.fa-edit:before,.fa-pen-to-square:before{content:"\f044"}.fa-car-side:before{content:"\f5e4"}.fa-share-alt:before,.fa-share-nodes:before{content:"\f1e0"}.fa-heart-circle-minus:before{content:"\e4ff"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-microscope:before{content:"\f610"}.fa-sink:before{content:"\e06d"}.fa-bag-shopping:before,.fa-shopping-bag:before{content:"\f290"}.fa-arrow-down-z-a:before,.fa-sort-alpha-desc:before,.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-mitten:before{content:"\f7b5"}.fa-person-rays:before{content:"\e54d"}.fa-users:before{content:"\f0c0"}.fa-eye-slash:before{content:"\f070"}.fa-flask-vial:before{content:"\e4f3"}.fa-hand-paper:before,.fa-hand:before{content:"\f256"}.fa-om:before{content:"\f679"}.fa-worm:before{content:"\e599"}.fa-house-circle-xmark:before{content:"\e50b"}.fa-plug:before{content:"\f1e6"}.fa-chevron-up:before{content:"\f077"}.fa-hand-spock:before{content:"\f259"}.fa-stopwatch:before{content:"\f2f2"}.fa-face-kiss:before,.fa-kiss:before{content:"\f596"}.fa-bridge-circle-xmark:before{content:"\e4cb"}.fa-face-grin-tongue:before,.fa-grin-tongue:before{content:"\f589"}.fa-chess-bishop:before{content:"\f43a"}.fa-face-grin-wink:before,.fa-grin-wink:before{content:"\f58c"}.fa-deaf:before,.fa-deafness:before,.fa-ear-deaf:before,.fa-hard-of-hearing:before{content:"\f2a4"}.fa-road-circle-check:before{content:"\e564"}.fa-dice-five:before{content:"\f523"}.fa-rss-square:before,.fa-square-rss:before{content:"\f143"}.fa-land-mine-on:before{content:"\e51b"}.fa-i-cursor:before{content:"\f246"}.fa-stamp:before{content:"\f5bf"}.fa-stairs:before{content:"\e289"}.fa-i:before{content:"\49"}.fa-hryvnia-sign:before,.fa-hryvnia:before{content:"\f6f2"}.fa-pills:before{content:"\f484"}.fa-face-grin-wide:before,.fa-grin-alt:before{content:"\f581"}.fa-tooth:before{content:"\f5c9"}.fa-v:before{content:"\56"}.fa-bangladeshi-taka-sign:before{content:"\e2e6"}.fa-bicycle:before{content:"\f206"}.fa-rod-asclepius:before,.fa-rod-snake:before,.fa-staff-aesculapius:before,.fa-staff-snake:before{content:"\e579"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-ambulance:before,.fa-truck-medical:before{content:"\f0f9"}.fa-wheat-awn-circle-exclamation:before{content:"\e598"}.fa-snowman:before{content:"\f7d0"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-road-barrier:before{content:"\e562"}.fa-school:before{content:"\f549"}.fa-igloo:before{content:"\f7ae"}.fa-joint:before{content:"\f595"}.fa-angle-right:before{content:"\f105"}.fa-horse:before{content:"\f6f0"}.fa-q:before{content:"\51"}.fa-g:before{content:"\47"}.fa-notes-medical:before{content:"\f481"}.fa-temperature-2:before,.fa-temperature-half:before,.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-dong-sign:before{content:"\e169"}.fa-capsules:before{content:"\f46b"}.fa-poo-bolt:before,.fa-poo-storm:before{content:"\f75a"}.fa-face-frown-open:before,.fa-frown-open:before{content:"\f57a"}.fa-hand-point-up:before{content:"\f0a6"}.fa-money-bill:before{content:"\f0d6"}.fa-bookmark:before{content:"\f02e"}.fa-align-justify:before{content:"\f039"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-helmet-un:before{content:"\e503"}.fa-bullseye:before{content:"\f140"}.fa-bacon:before{content:"\f7e5"}.fa-hand-point-down:before{content:"\f0a7"}.fa-arrow-up-from-bracket:before{content:"\e09a"}.fa-folder-blank:before,.fa-folder:before{content:"\f07b"}.fa-file-medical-alt:before,.fa-file-waveform:before{content:"\f478"}.fa-radiation:before{content:"\f7b9"}.fa-chart-simple:before{content:"\e473"}.fa-mars-stroke:before{content:"\f229"}.fa-vial:before{content:"\f492"}.fa-dashboard:before,.fa-gauge-med:before,.fa-gauge:before,.fa-tachometer-alt-average:before{content:"\f624"}.fa-magic-wand-sparkles:before,.fa-wand-magic-sparkles:before{content:"\e2ca"}.fa-e:before{content:"\45"}.fa-pen-alt:before,.fa-pen-clip:before{content:"\f305"}.fa-bridge-circle-exclamation:before{content:"\e4ca"}.fa-user:before{content:"\f007"}.fa-school-circle-check:before{content:"\e56b"}.fa-dumpster:before{content:"\f793"}.fa-shuttle-van:before,.fa-van-shuttle:before{content:"\f5b6"}.fa-building-user:before{content:"\e4da"}.fa-caret-square-left:before,.fa-square-caret-left:before{content:"\f191"}.fa-highlighter:before{content:"\f591"}.fa-key:before{content:"\f084"}.fa-bullhorn:before{content:"\f0a1"}.fa-globe:before{content:"\f0ac"}.fa-synagogue:before{content:"\f69b"}.fa-person-half-dress:before{content:"\e548"}.fa-road-bridge:before{content:"\e563"}.fa-location-arrow:before{content:"\f124"}.fa-c:before{content:"\43"}.fa-tablet-button:before{content:"\f10a"}.fa-building-lock:before{content:"\e4d6"}.fa-pizza-slice:before{content:"\f818"}.fa-money-bill-wave:before{content:"\f53a"}.fa-area-chart:before,.fa-chart-area:before{content:"\f1fe"}.fa-house-flag:before{content:"\e50d"}.fa-person-circle-minus:before{content:"\e540"}.fa-ban:before,.fa-cancel:before{content:"\f05e"}.fa-camera-rotate:before{content:"\e0d8"}.fa-air-freshener:before,.fa-spray-can-sparkles:before{content:"\f5d0"}.fa-star:before{content:"\f005"}.fa-repeat:before{content:"\f363"}.fa-cross:before{content:"\f654"}.fa-box:before{content:"\f466"}.fa-venus-mars:before{content:"\f228"}.fa-arrow-pointer:before,.fa-mouse-pointer:before{content:"\f245"}.fa-expand-arrows-alt:before,.fa-maximize:before{content:"\f31e"}.fa-charging-station:before{content:"\f5e7"}.fa-shapes:before,.fa-triangle-circle-square:before{content:"\f61f"}.fa-random:before,.fa-shuffle:before{content:"\f074"}.fa-person-running:before,.fa-running:before{content:"\f70c"}.fa-mobile-retro:before{content:"\e527"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-spider:before{content:"\f717"}.fa-hands-bound:before{content:"\e4f9"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-plane-circle-exclamation:before{content:"\e556"}.fa-x-ray:before{content:"\f497"}.fa-spell-check:before{content:"\f891"}.fa-slash:before{content:"\f715"}.fa-computer-mouse:before,.fa-mouse:before{content:"\f8cc"}.fa-arrow-right-to-bracket:before,.fa-sign-in:before{content:"\f090"}.fa-shop-slash:before,.fa-store-alt-slash:before{content:"\e070"}.fa-server:before{content:"\f233"}.fa-virus-covid-slash:before{content:"\e4a9"}.fa-shop-lock:before{content:"\e4a5"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-blender-phone:before{content:"\f6b6"}.fa-building-wheat:before{content:"\e4db"}.fa-person-breastfeeding:before{content:"\e53a"}.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}.fa-venus:before{content:"\f221"}.fa-passport:before{content:"\f5ab"}.fa-heart-pulse:before,.fa-heartbeat:before{content:"\f21e"}.fa-people-carry-box:before,.fa-people-carry:before{content:"\f4ce"}.fa-temperature-high:before{content:"\f769"}.fa-microchip:before{content:"\f2db"}.fa-crown:before{content:"\f521"}.fa-weight-hanging:before{content:"\f5cd"}.fa-xmarks-lines:before{content:"\e59a"}.fa-file-prescription:before{content:"\f572"}.fa-weight-scale:before,.fa-weight:before{content:"\f496"}.fa-user-friends:before,.fa-user-group:before{content:"\f500"}.fa-arrow-up-a-z:before,.fa-sort-alpha-up:before{content:"\f15e"}.fa-chess-knight:before{content:"\f441"}.fa-face-laugh-squint:before,.fa-laugh-squint:before{content:"\f59b"}.fa-wheelchair:before{content:"\f193"}.fa-arrow-circle-up:before,.fa-circle-arrow-up:before{content:"\f0aa"}.fa-toggle-on:before{content:"\f205"}.fa-person-walking:before,.fa-walking:before{content:"\f554"}.fa-l:before{content:"\4c"}.fa-fire:before{content:"\f06d"}.fa-bed-pulse:before,.fa-procedures:before{content:"\f487"}.fa-shuttle-space:before,.fa-space-shuttle:before{content:"\f197"}.fa-face-laugh:before,.fa-laugh:before{content:"\f599"}.fa-folder-open:before{content:"\f07c"}.fa-heart-circle-plus:before{content:"\e500"}.fa-code-fork:before{content:"\e13b"}.fa-city:before{content:"\f64f"}.fa-microphone-alt:before,.fa-microphone-lines:before{content:"\f3c9"}.fa-pepper-hot:before{content:"\f816"}.fa-unlock:before{content:"\f09c"}.fa-colon-sign:before{content:"\e140"}.fa-headset:before{content:"\f590"}.fa-store-slash:before{content:"\e071"}.fa-road-circle-xmark:before{content:"\e566"}.fa-user-minus:before{content:"\f503"}.fa-mars-stroke-up:before,.fa-mars-stroke-v:before{content:"\f22a"}.fa-champagne-glasses:before,.fa-glass-cheers:before{content:"\f79f"}.fa-clipboard:before{content:"\f328"}.fa-house-circle-exclamation:before{content:"\e50a"}.fa-file-arrow-up:before,.fa-file-upload:before{content:"\f574"}.fa-wifi-3:before,.fa-wifi-strong:before,.fa-wifi:before{content:"\f1eb"}.fa-bath:before,.fa-bathtub:before{content:"\f2cd"}.fa-underline:before{content:"\f0cd"}.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}.fa-signature:before{content:"\f5b7"}.fa-stroopwafel:before{content:"\f551"}.fa-bold:before{content:"\f032"}.fa-anchor-lock:before{content:"\e4ad"}.fa-building-ngo:before{content:"\e4d7"}.fa-manat-sign:before{content:"\e1d5"}.fa-not-equal:before{content:"\f53e"}.fa-border-style:before,.fa-border-top-left:before{content:"\f853"}.fa-map-location-dot:before,.fa-map-marked-alt:before{content:"\f5a0"}.fa-jedi:before{content:"\f669"}.fa-poll:before,.fa-square-poll-vertical:before{content:"\f681"}.fa-mug-hot:before{content:"\f7b6"}.fa-battery-car:before,.fa-car-battery:before{content:"\f5df"}.fa-gift:before{content:"\f06b"}.fa-dice-two:before{content:"\f528"}.fa-chess-queen:before{content:"\f445"}.fa-glasses:before{content:"\f530"}.fa-chess-board:before{content:"\f43c"}.fa-building-circle-check:before{content:"\e4d2"}.fa-person-chalkboard:before{content:"\e53d"}.fa-mars-stroke-h:before,.fa-mars-stroke-right:before{content:"\f22b"}.fa-hand-back-fist:before,.fa-hand-rock:before{content:"\f255"}.fa-caret-square-up:before,.fa-square-caret-up:before{content:"\f151"}.fa-cloud-showers-water:before{content:"\e4e4"}.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}.fa-hands-bubbles:before,.fa-hands-wash:before{content:"\e05e"}.fa-less-than-equal:before{content:"\f537"}.fa-train:before{content:"\f238"}.fa-eye-low-vision:before,.fa-low-vision:before{content:"\f2a8"}.fa-crow:before{content:"\f520"}.fa-sailboat:before{content:"\e445"}.fa-window-restore:before{content:"\f2d2"}.fa-plus-square:before,.fa-square-plus:before{content:"\f0fe"}.fa-torii-gate:before{content:"\f6a1"}.fa-frog:before{content:"\f52e"}.fa-bucket:before{content:"\e4cf"}.fa-image:before{content:"\f03e"}.fa-microphone:before{content:"\f130"}.fa-cow:before{content:"\f6c8"}.fa-caret-up:before{content:"\f0d8"}.fa-screwdriver:before{content:"\f54a"}.fa-folder-closed:before{content:"\e185"}.fa-house-tsunami:before{content:"\e515"}.fa-square-nfi:before{content:"\e576"}.fa-arrow-up-from-ground-water:before{content:"\e4b5"}.fa-glass-martini-alt:before,.fa-martini-glass:before{content:"\f57b"}.fa-rotate-back:before,.fa-rotate-backward:before,.fa-rotate-left:before,.fa-undo-alt:before{content:"\f2ea"}.fa-columns:before,.fa-table-columns:before{content:"\f0db"}.fa-lemon:before{content:"\f094"}.fa-head-side-mask:before{content:"\e063"}.fa-handshake:before{content:"\f2b5"}.fa-gem:before{content:"\f3a5"}.fa-dolly-box:before,.fa-dolly:before{content:"\f472"}.fa-smoking:before{content:"\f48d"}.fa-compress-arrows-alt:before,.fa-minimize:before{content:"\f78c"}.fa-monument:before{content:"\f5a6"}.fa-snowplow:before{content:"\f7d2"}.fa-angle-double-right:before,.fa-angles-right:before{content:"\f101"}.fa-cannabis:before{content:"\f55f"}.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}.fa-tablets:before{content:"\f490"}.fa-ethernet:before{content:"\f796"}.fa-eur:before,.fa-euro-sign:before,.fa-euro:before{content:"\f153"}.fa-chair:before{content:"\f6c0"}.fa-check-circle:before,.fa-circle-check:before{content:"\f058"}.fa-circle-stop:before,.fa-stop-circle:before{content:"\f28d"}.fa-compass-drafting:before,.fa-drafting-compass:before{content:"\f568"}.fa-plate-wheat:before{content:"\e55a"}.fa-icicles:before{content:"\f7ad"}.fa-person-shelter:before{content:"\e54f"}.fa-neuter:before{content:"\f22c"}.fa-id-badge:before{content:"\f2c1"}.fa-marker:before{content:"\f5a1"}.fa-face-laugh-beam:before,.fa-laugh-beam:before{content:"\f59a"}.fa-helicopter-symbol:before{content:"\e502"}.fa-universal-access:before{content:"\f29a"}.fa-chevron-circle-up:before,.fa-circle-chevron-up:before{content:"\f139"}.fa-lari-sign:before{content:"\e1c8"}.fa-volcano:before{content:"\f770"}.fa-person-walking-dashed-line-arrow-right:before{content:"\e553"}.fa-gbp:before,.fa-pound-sign:before,.fa-sterling-sign:before{content:"\f154"}.fa-viruses:before{content:"\e076"}.fa-square-person-confined:before{content:"\e577"}.fa-user-tie:before{content:"\f508"}.fa-arrow-down-long:before,.fa-long-arrow-down:before{content:"\f175"}.fa-tent-arrow-down-to-line:before{content:"\e57e"}.fa-certificate:before{content:"\f0a3"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-suitcase:before{content:"\f0f2"}.fa-person-skating:before,.fa-skating:before{content:"\f7c5"}.fa-filter-circle-dollar:before,.fa-funnel-dollar:before{content:"\f662"}.fa-camera-retro:before{content:"\f083"}.fa-arrow-circle-down:before,.fa-circle-arrow-down:before{content:"\f0ab"}.fa-arrow-right-to-file:before,.fa-file-import:before{content:"\f56f"}.fa-external-link-square:before,.fa-square-arrow-up-right:before{content:"\f14c"}.fa-box-open:before{content:"\f49e"}.fa-scroll:before{content:"\f70e"}.fa-spa:before{content:"\f5bb"}.fa-location-pin-lock:before{content:"\e51f"}.fa-pause:before{content:"\f04c"}.fa-hill-avalanche:before{content:"\e507"}.fa-temperature-0:before,.fa-temperature-empty:before,.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-bomb:before{content:"\f1e2"}.fa-registered:before{content:"\f25d"}.fa-address-card:before,.fa-contact-card:before,.fa-vcard:before{content:"\f2bb"}.fa-balance-scale-right:before,.fa-scale-unbalanced-flip:before{content:"\f516"}.fa-subscript:before{content:"\f12c"}.fa-diamond-turn-right:before,.fa-directions:before{content:"\f5eb"}.fa-burst:before{content:"\e4dc"}.fa-house-laptop:before,.fa-laptop-house:before{content:"\e066"}.fa-face-tired:before,.fa-tired:before{content:"\f5c8"}.fa-money-bills:before{content:"\e1f3"}.fa-smog:before{content:"\f75f"}.fa-crutch:before{content:"\f7f7"}.fa-cloud-arrow-up:before,.fa-cloud-upload-alt:before,.fa-cloud-upload:before{content:"\f0ee"}.fa-palette:before{content:"\f53f"}.fa-arrows-turn-right:before{content:"\e4c0"}.fa-vest:before{content:"\e085"}.fa-ferry:before{content:"\e4ea"}.fa-arrows-down-to-people:before{content:"\e4b9"}.fa-seedling:before,.fa-sprout:before{content:"\f4d8"}.fa-arrows-alt-h:before,.fa-left-right:before{content:"\f337"}.fa-boxes-packing:before{content:"\e4c7"}.fa-arrow-circle-left:before,.fa-circle-arrow-left:before{content:"\f0a8"}.fa-group-arrows-rotate:before{content:"\e4f6"}.fa-bowl-food:before{content:"\e4c6"}.fa-candy-cane:before{content:"\f786"}.fa-arrow-down-wide-short:before,.fa-sort-amount-asc:before,.fa-sort-amount-down:before{content:"\f160"}.fa-cloud-bolt:before,.fa-thunderstorm:before{content:"\f76c"}.fa-remove-format:before,.fa-text-slash:before{content:"\f87d"}.fa-face-smile-wink:before,.fa-smile-wink:before{content:"\f4da"}.fa-file-word:before{content:"\f1c2"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-arrows-h:before,.fa-arrows-left-right:before{content:"\f07e"}.fa-house-lock:before{content:"\e510"}.fa-cloud-arrow-down:before,.fa-cloud-download-alt:before,.fa-cloud-download:before{content:"\f0ed"}.fa-children:before{content:"\e4e1"}.fa-blackboard:before,.fa-chalkboard:before{content:"\f51b"}.fa-user-alt-slash:before,.fa-user-large-slash:before{content:"\f4fa"}.fa-envelope-open:before{content:"\f2b6"}.fa-handshake-alt-slash:before,.fa-handshake-simple-slash:before{content:"\e05f"}.fa-mattress-pillow:before{content:"\e525"}.fa-guarani-sign:before{content:"\e19a"}.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}.fa-fire-extinguisher:before{content:"\f134"}.fa-cruzeiro-sign:before{content:"\e152"}.fa-greater-than-equal:before{content:"\f532"}.fa-shield-alt:before,.fa-shield-halved:before{content:"\f3ed"}.fa-atlas:before,.fa-book-atlas:before{content:"\f558"}.fa-virus:before{content:"\e074"}.fa-envelope-circle-check:before{content:"\e4e8"}.fa-layer-group:before{content:"\f5fd"}.fa-arrows-to-dot:before{content:"\e4be"}.fa-archway:before{content:"\f557"}.fa-heart-circle-check:before{content:"\e4fd"}.fa-house-chimney-crack:before,.fa-house-damage:before{content:"\f6f1"}.fa-file-archive:before,.fa-file-zipper:before{content:"\f1c6"}.fa-square:before{content:"\f0c8"}.fa-glass-martini:before,.fa-martini-glass-empty:before{content:"\f000"}.fa-couch:before{content:"\f4b8"}.fa-cedi-sign:before{content:"\e0df"}.fa-italic:before{content:"\f033"}.fa-church:before{content:"\f51d"}.fa-comments-dollar:before{content:"\f653"}.fa-democrat:before{content:"\f747"}.fa-z:before{content:"\5a"}.fa-person-skiing:before,.fa-skiing:before{content:"\f7c9"}.fa-road-lock:before{content:"\e567"}.fa-a:before{content:"\41"}.fa-temperature-arrow-down:before,.fa-temperature-down:before{content:"\e03f"}.fa-feather-alt:before,.fa-feather-pointed:before{content:"\f56b"}.fa-p:before{content:"\50"}.fa-snowflake:before{content:"\f2dc"}.fa-newspaper:before{content:"\f1ea"}.fa-ad:before,.fa-rectangle-ad:before{content:"\f641"}.fa-arrow-circle-right:before,.fa-circle-arrow-right:before{content:"\f0a9"}.fa-filter-circle-xmark:before{content:"\e17b"}.fa-locust:before{content:"\e520"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-list-1-2:before,.fa-list-numeric:before,.fa-list-ol:before{content:"\f0cb"}.fa-person-dress-burst:before{content:"\e544"}.fa-money-check-alt:before,.fa-money-check-dollar:before{content:"\f53d"}.fa-vector-square:before{content:"\f5cb"}.fa-bread-slice:before{content:"\f7ec"}.fa-language:before{content:"\f1ab"}.fa-face-kiss-wink-heart:before,.fa-kiss-wink-heart:before{content:"\f598"}.fa-filter:before{content:"\f0b0"}.fa-question:before{content:"\3f"}.fa-file-signature:before{content:"\f573"}.fa-arrows-alt:before,.fa-up-down-left-right:before{content:"\f0b2"}.fa-house-chimney-user:before{content:"\e065"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-puzzle-piece:before{content:"\f12e"}.fa-money-check:before{content:"\f53c"}.fa-star-half-alt:before,.fa-star-half-stroke:before{content:"\f5c0"}.fa-code:before{content:"\f121"}.fa-glass-whiskey:before,.fa-whiskey-glass:before{content:"\f7a0"}.fa-building-circle-exclamation:before{content:"\e4d3"}.fa-magnifying-glass-chart:before{content:"\e522"}.fa-arrow-up-right-from-square:before,.fa-external-link:before{content:"\f08e"}.fa-cubes-stacked:before{content:"\e4e6"}.fa-krw:before,.fa-won-sign:before,.fa-won:before{content:"\f159"}.fa-virus-covid:before{content:"\e4a8"}.fa-austral-sign:before{content:"\e0a9"}.fa-f:before{content:"\46"}.fa-leaf:before{content:"\f06c"}.fa-road:before{content:"\f018"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-person-circle-plus:before{content:"\e541"}.fa-chart-pie:before,.fa-pie-chart:before{content:"\f200"}.fa-bolt-lightning:before{content:"\e0b7"}.fa-sack-xmark:before{content:"\e56a"}.fa-file-excel:before{content:"\f1c3"}.fa-file-contract:before{content:"\f56c"}.fa-fish-fins:before{content:"\e4f2"}.fa-building-flag:before{content:"\e4d5"}.fa-face-grin-beam:before,.fa-grin-beam:before{content:"\f582"}.fa-object-ungroup:before{content:"\f248"}.fa-poop:before{content:"\f619"}.fa-location-pin:before,.fa-map-marker:before{content:"\f041"}.fa-kaaba:before{content:"\f66b"}.fa-toilet-paper:before{content:"\f71e"}.fa-hard-hat:before,.fa-hat-hard:before,.fa-helmet-safety:before{content:"\f807"}.fa-eject:before{content:"\f052"}.fa-arrow-alt-circle-right:before,.fa-circle-right:before{content:"\f35a"}.fa-plane-circle-check:before{content:"\e555"}.fa-face-rolling-eyes:before,.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-object-group:before{content:"\f247"}.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}.fa-mask-ventilator:before{content:"\e524"}.fa-arrow-right:before{content:"\f061"}.fa-map-signs:before,.fa-signs-post:before{content:"\f277"}.fa-cash-register:before{content:"\f788"}.fa-person-circle-question:before{content:"\e542"}.fa-h:before{content:"\48"}.fa-tarp:before{content:"\e57b"}.fa-screwdriver-wrench:before,.fa-tools:before{content:"\f7d9"}.fa-arrows-to-eye:before{content:"\e4bf"}.fa-plug-circle-bolt:before{content:"\e55b"}.fa-heart:before{content:"\f004"}.fa-mars-and-venus:before{content:"\f224"}.fa-home-user:before,.fa-house-user:before{content:"\e1b0"}.fa-dumpster-fire:before{content:"\f794"}.fa-house-crack:before{content:"\e3b1"}.fa-cocktail:before,.fa-martini-glass-citrus:before{content:"\f561"}.fa-face-surprise:before,.fa-surprise:before{content:"\f5c2"}.fa-bottle-water:before{content:"\e4c5"}.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-apple-alt:before,.fa-apple-whole:before{content:"\f5d1"}.fa-kitchen-set:before{content:"\e51a"}.fa-r:before{content:"\52"}.fa-temperature-1:before,.fa-temperature-quarter:before,.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-cube:before{content:"\f1b2"}.fa-bitcoin-sign:before{content:"\e0b4"}.fa-shield-dog:before{content:"\e573"}.fa-solar-panel:before{content:"\f5ba"}.fa-lock-open:before{content:"\f3c1"}.fa-elevator:before{content:"\e16d"}.fa-money-bill-transfer:before{content:"\e528"}.fa-money-bill-trend-up:before{content:"\e529"}.fa-house-flood-water-circle-arrow-right:before{content:"\e50f"}.fa-poll-h:before,.fa-square-poll-horizontal:before{content:"\f682"}.fa-circle:before{content:"\f111"}.fa-backward-fast:before,.fa-fast-backward:before{content:"\f049"}.fa-recycle:before{content:"\f1b8"}.fa-user-astronaut:before{content:"\f4fb"}.fa-plane-slash:before{content:"\e069"}.fa-trademark:before{content:"\f25c"}.fa-basketball-ball:before,.fa-basketball:before{content:"\f434"}.fa-satellite-dish:before{content:"\f7c0"}.fa-arrow-alt-circle-up:before,.fa-circle-up:before{content:"\f35b"}.fa-mobile-alt:before,.fa-mobile-screen-button:before{content:"\f3cd"}.fa-volume-high:before,.fa-volume-up:before{content:"\f028"}.fa-users-rays:before{content:"\e593"}.fa-wallet:before{content:"\f555"}.fa-clipboard-check:before{content:"\f46c"}.fa-file-audio:before{content:"\f1c7"}.fa-burger:before,.fa-hamburger:before{content:"\f805"}.fa-wrench:before{content:"\f0ad"}.fa-bugs:before{content:"\e4d0"}.fa-rupee-sign:before,.fa-rupee:before{content:"\f156"}.fa-file-image:before{content:"\f1c5"}.fa-circle-question:before,.fa-question-circle:before{content:"\f059"}.fa-plane-departure:before{content:"\f5b0"}.fa-handshake-slash:before{content:"\e060"}.fa-book-bookmark:before{content:"\e0bb"}.fa-code-branch:before{content:"\f126"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-bridge:before{content:"\e4c8"}.fa-phone-alt:before,.fa-phone-flip:before{content:"\f879"}.fa-truck-front:before{content:"\e2b7"}.fa-cat:before{content:"\f6be"}.fa-anchor-circle-exclamation:before{content:"\e4ab"}.fa-truck-field:before{content:"\e58d"}.fa-route:before{content:"\f4d7"}.fa-clipboard-question:before{content:"\e4e3"}.fa-panorama:before{content:"\e209"}.fa-comment-medical:before{content:"\f7f5"}.fa-teeth-open:before{content:"\f62f"}.fa-file-circle-minus:before{content:"\e4ed"}.fa-tags:before{content:"\f02c"}.fa-wine-glass:before{content:"\f4e3"}.fa-fast-forward:before,.fa-forward-fast:before{content:"\f050"}.fa-face-meh-blank:before,.fa-meh-blank:before{content:"\f5a4"}.fa-parking:before,.fa-square-parking:before{content:"\f540"}.fa-house-signal:before{content:"\e012"}.fa-bars-progress:before,.fa-tasks-alt:before{content:"\f828"}.fa-faucet-drip:before{content:"\e006"}.fa-cart-flatbed:before,.fa-dolly-flatbed:before{content:"\f474"}.fa-ban-smoking:before,.fa-smoking-ban:before{content:"\f54d"}.fa-terminal:before{content:"\f120"}.fa-mobile-button:before{content:"\f10b"}.fa-house-medical-flag:before{content:"\e514"}.fa-basket-shopping:before,.fa-shopping-basket:before{content:"\f291"}.fa-tape:before{content:"\f4db"}.fa-bus-alt:before,.fa-bus-simple:before{content:"\f55e"}.fa-eye:before{content:"\f06e"}.fa-face-sad-cry:before,.fa-sad-cry:before{content:"\f5b3"}.fa-audio-description:before{content:"\f29e"}.fa-person-military-to-person:before{content:"\e54c"}.fa-file-shield:before{content:"\e4f0"}.fa-user-slash:before{content:"\f506"}.fa-pen:before{content:"\f304"}.fa-tower-observation:before{content:"\e586"}.fa-file-code:before{content:"\f1c9"}.fa-signal-5:before,.fa-signal-perfect:before,.fa-signal:before{content:"\f012"}.fa-bus:before{content:"\f207"}.fa-heart-circle-xmark:before{content:"\e501"}.fa-home-lg:before,.fa-house-chimney:before{content:"\e3af"}.fa-window-maximize:before{content:"\f2d0"}.fa-face-frown:before,.fa-frown:before{content:"\f119"}.fa-prescription:before{content:"\f5b1"}.fa-shop:before,.fa-store-alt:before{content:"\f54f"}.fa-floppy-disk:before,.fa-save:before{content:"\f0c7"}.fa-vihara:before{content:"\f6a7"}.fa-balance-scale-left:before,.fa-scale-unbalanced:before{content:"\f515"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-comment-dots:before,.fa-commenting:before{content:"\f4ad"}.fa-plant-wilt:before{content:"\e5aa"}.fa-diamond:before{content:"\f219"}.fa-face-grin-squint:before,.fa-grin-squint:before{content:"\f585"}.fa-hand-holding-dollar:before,.fa-hand-holding-usd:before{content:"\f4c0"}.fa-bacterium:before{content:"\e05a"}.fa-hand-pointer:before{content:"\f25a"}.fa-drum-steelpan:before{content:"\f56a"}.fa-hand-scissors:before{content:"\f257"}.fa-hands-praying:before,.fa-praying-hands:before{content:"\f684"}.fa-arrow-right-rotate:before,.fa-arrow-rotate-forward:before,.fa-arrow-rotate-right:before,.fa-redo:before{content:"\f01e"}.fa-biohazard:before{content:"\f780"}.fa-location-crosshairs:before,.fa-location:before{content:"\f601"}.fa-mars-double:before{content:"\f227"}.fa-child-dress:before{content:"\e59c"}.fa-users-between-lines:before{content:"\e591"}.fa-lungs-virus:before{content:"\e067"}.fa-face-grin-tears:before,.fa-grin-tears:before{content:"\f588"}.fa-phone:before{content:"\f095"}.fa-calendar-times:before,.fa-calendar-xmark:before{content:"\f273"}.fa-child-reaching:before{content:"\e59d"}.fa-head-side-virus:before{content:"\e064"}.fa-user-cog:before,.fa-user-gear:before{content:"\f4fe"}.fa-arrow-up-1-9:before,.fa-sort-numeric-up:before{content:"\f163"}.fa-door-closed:before{content:"\f52a"}.fa-shield-virus:before{content:"\e06c"}.fa-dice-six:before{content:"\f526"}.fa-mosquito-net:before{content:"\e52c"}.fa-bridge-water:before{content:"\e4ce"}.fa-person-booth:before{content:"\f756"}.fa-text-width:before{content:"\f035"}.fa-hat-wizard:before{content:"\f6e8"}.fa-pen-fancy:before{content:"\f5ac"}.fa-digging:before,.fa-person-digging:before{content:"\f85e"}.fa-trash:before{content:"\f1f8"}.fa-gauge-simple-med:before,.fa-gauge-simple:before,.fa-tachometer-average:before{content:"\f629"}.fa-book-medical:before{content:"\f7e6"}.fa-poo:before{content:"\f2fe"}.fa-quote-right-alt:before,.fa-quote-right:before{content:"\f10e"}.fa-shirt:before,.fa-t-shirt:before,.fa-tshirt:before{content:"\f553"}.fa-cubes:before{content:"\f1b3"}.fa-divide:before{content:"\f529"}.fa-tenge-sign:before,.fa-tenge:before{content:"\f7d7"}.fa-headphones:before{content:"\f025"}.fa-hands-holding:before{content:"\f4c2"}.fa-hands-clapping:before{content:"\e1a8"}.fa-republican:before{content:"\f75e"}.fa-arrow-left:before{content:"\f060"}.fa-person-circle-xmark:before{content:"\e543"}.fa-ruler:before{content:"\f545"}.fa-align-left:before{content:"\f036"}.fa-dice-d6:before{content:"\f6d1"}.fa-restroom:before{content:"\f7bd"}.fa-j:before{content:"\4a"}.fa-users-viewfinder:before{content:"\e595"}.fa-file-video:before{content:"\f1c8"}.fa-external-link-alt:before,.fa-up-right-from-square:before{content:"\f35d"}.fa-table-cells:before,.fa-th:before{content:"\f00a"}.fa-file-pdf:before{content:"\f1c1"}.fa-bible:before,.fa-book-bible:before{content:"\f647"}.fa-o:before{content:"\4f"}.fa-medkit:before,.fa-suitcase-medical:before{content:"\f0fa"}.fa-user-secret:before{content:"\f21b"}.fa-otter:before{content:"\f700"}.fa-female:before,.fa-person-dress:before{content:"\f182"}.fa-comment-dollar:before{content:"\f651"}.fa-briefcase-clock:before,.fa-business-time:before{content:"\f64a"}.fa-table-cells-large:before,.fa-th-large:before{content:"\f009"}.fa-book-tanakh:before,.fa-tanakh:before{content:"\f827"}.fa-phone-volume:before,.fa-volume-control-phone:before{content:"\f2a0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-clipboard-user:before{content:"\f7f3"}.fa-child:before{content:"\f1ae"}.fa-lira-sign:before{content:"\f195"}.fa-satellite:before{content:"\f7bf"}.fa-plane-lock:before{content:"\e558"}.fa-tag:before{content:"\f02b"}.fa-comment:before{content:"\f075"}.fa-birthday-cake:before,.fa-cake-candles:before,.fa-cake:before{content:"\f1fd"}.fa-envelope:before{content:"\f0e0"}.fa-angle-double-up:before,.fa-angles-up:before{content:"\f102"}.fa-paperclip:before{content:"\f0c6"}.fa-arrow-right-to-city:before{content:"\e4b3"}.fa-ribbon:before{content:"\f4d6"}.fa-lungs:before{content:"\f604"}.fa-arrow-up-9-1:before,.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-litecoin-sign:before{content:"\e1d3"}.fa-border-none:before{content:"\f850"}.fa-circle-nodes:before{content:"\e4e2"}.fa-parachute-box:before{content:"\f4cd"}.fa-indent:before{content:"\f03c"}.fa-truck-field-un:before{content:"\e58e"}.fa-hourglass-empty:before,.fa-hourglass:before{content:"\f254"}.fa-mountain:before{content:"\f6fc"}.fa-user-doctor:before,.fa-user-md:before{content:"\f0f0"}.fa-circle-info:before,.fa-info-circle:before{content:"\f05a"}.fa-cloud-meatball:before{content:"\f73b"}.fa-camera-alt:before,.fa-camera:before{content:"\f030"}.fa-square-virus:before{content:"\e578"}.fa-meteor:before{content:"\f753"}.fa-car-on:before{content:"\e4dd"}.fa-sleigh:before{content:"\f7cc"}.fa-arrow-down-1-9:before,.fa-sort-numeric-asc:before,.fa-sort-numeric-down:before{content:"\f162"}.fa-hand-holding-droplet:before,.fa-hand-holding-water:before{content:"\f4c1"}.fa-water:before{content:"\f773"}.fa-calendar-check:before{content:"\f274"}.fa-braille:before{content:"\f2a1"}.fa-prescription-bottle-alt:before,.fa-prescription-bottle-medical:before{content:"\f486"}.fa-landmark:before{content:"\f66f"}.fa-truck:before{content:"\f0d1"}.fa-crosshairs:before{content:"\f05b"}.fa-person-cane:before{content:"\e53c"}.fa-tent:before{content:"\e57d"}.fa-vest-patches:before{content:"\e086"}.fa-check-double:before{content:"\f560"}.fa-arrow-down-a-z:before,.fa-sort-alpha-asc:before,.fa-sort-alpha-down:before{content:"\f15d"}.fa-money-bill-wheat:before{content:"\e52a"}.fa-cookie:before{content:"\f563"}.fa-arrow-left-rotate:before,.fa-arrow-rotate-back:before,.fa-arrow-rotate-backward:before,.fa-arrow-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-hard-drive:before,.fa-hdd:before{content:"\f0a0"}.fa-face-grin-squint-tears:before,.fa-grin-squint-tears:before{content:"\f586"}.fa-dumbbell:before{content:"\f44b"}.fa-list-alt:before,.fa-rectangle-list:before{content:"\f022"}.fa-tarp-droplet:before{content:"\e57c"}.fa-house-medical-circle-check:before{content:"\e511"}.fa-person-skiing-nordic:before,.fa-skiing-nordic:before{content:"\f7ca"}.fa-calendar-plus:before{content:"\f271"}.fa-plane-arrival:before{content:"\f5af"}.fa-arrow-alt-circle-left:before,.fa-circle-left:before{content:"\f359"}.fa-subway:before,.fa-train-subway:before{content:"\f239"}.fa-chart-gantt:before{content:"\e0e4"}.fa-indian-rupee-sign:before,.fa-indian-rupee:before,.fa-inr:before{content:"\e1bc"}.fa-crop-alt:before,.fa-crop-simple:before{content:"\f565"}.fa-money-bill-1:before,.fa-money-bill-alt:before{content:"\f3d1"}.fa-left-long:before,.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-dna:before{content:"\f471"}.fa-virus-slash:before{content:"\e075"}.fa-minus:before,.fa-subtract:before{content:"\f068"}.fa-chess:before{content:"\f439"}.fa-arrow-left-long:before,.fa-long-arrow-left:before{content:"\f177"}.fa-plug-circle-check:before{content:"\e55c"}.fa-street-view:before{content:"\f21d"}.fa-franc-sign:before{content:"\e18f"}.fa-volume-off:before{content:"\f026"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before,.fa-hands-american-sign-language-interpreting:before,.fa-hands-asl-interpreting:before{content:"\f2a3"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-droplet-slash:before,.fa-tint-slash:before{content:"\f5c7"}.fa-mosque:before{content:"\f678"}.fa-mosquito:before{content:"\e52b"}.fa-star-of-david:before{content:"\f69a"}.fa-person-military-rifle:before{content:"\e54b"}.fa-cart-shopping:before,.fa-shopping-cart:before{content:"\f07a"}.fa-vials:before{content:"\f493"}.fa-plug-circle-plus:before{content:"\e55f"}.fa-place-of-worship:before{content:"\f67f"}.fa-grip-vertical:before{content:"\f58e"}.fa-arrow-turn-up:before,.fa-level-up:before{content:"\f148"}.fa-u:before{content:"\55"}.fa-square-root-alt:before,.fa-square-root-variable:before{content:"\f698"}.fa-clock-four:before,.fa-clock:before{content:"\f017"}.fa-backward-step:before,.fa-step-backward:before{content:"\f048"}.fa-pallet:before{content:"\f482"}.fa-faucet:before{content:"\e005"}.fa-baseball-bat-ball:before{content:"\f432"}.fa-s:before{content:"\53"}.fa-timeline:before{content:"\e29c"}.fa-keyboard:before{content:"\f11c"}.fa-caret-down:before{content:"\f0d7"}.fa-clinic-medical:before,.fa-house-chimney-medical:before{content:"\f7f2"}.fa-temperature-3:before,.fa-temperature-three-quarters:before,.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-mobile-android-alt:before,.fa-mobile-screen:before{content:"\f3cf"}.fa-plane-up:before{content:"\e22d"}.fa-piggy-bank:before{content:"\f4d3"}.fa-battery-3:before,.fa-battery-half:before{content:"\f242"}.fa-mountain-city:before{content:"\e52e"}.fa-coins:before{content:"\f51e"}.fa-khanda:before{content:"\f66d"}.fa-sliders-h:before,.fa-sliders:before{content:"\f1de"}.fa-folder-tree:before{content:"\f802"}.fa-network-wired:before{content:"\f6ff"}.fa-map-pin:before{content:"\f276"}.fa-hamsa:before{content:"\f665"}.fa-cent-sign:before{content:"\e3f5"}.fa-flask:before{content:"\f0c3"}.fa-person-pregnant:before{content:"\e31e"}.fa-wand-sparkles:before{content:"\f72b"}.fa-ellipsis-v:before,.fa-ellipsis-vertical:before{content:"\f142"}.fa-ticket:before{content:"\f145"}.fa-power-off:before{content:"\f011"}.fa-long-arrow-alt-right:before,.fa-right-long:before{content:"\f30b"}.fa-flag-usa:before{content:"\f74d"}.fa-laptop-file:before{content:"\e51d"}.fa-teletype:before,.fa-tty:before{content:"\f1e4"}.fa-diagram-next:before{content:"\e476"}.fa-person-rifle:before{content:"\e54e"}.fa-house-medical-circle-exclamation:before{content:"\e512"}.fa-closed-captioning:before{content:"\f20a"}.fa-hiking:before,.fa-person-hiking:before{content:"\f6ec"}.fa-venus-double:before{content:"\f226"}.fa-images:before{content:"\f302"}.fa-calculator:before{content:"\f1ec"}.fa-people-pulling:before{content:"\e535"}.fa-n:before{content:"\4e"}.fa-cable-car:before,.fa-tram:before{content:"\f7da"}.fa-cloud-rain:before{content:"\f73d"}.fa-building-circle-xmark:before{content:"\e4d4"}.fa-ship:before{content:"\f21a"}.fa-arrows-down-to-line:before{content:"\e4b8"}.fa-download:before{content:"\f019"}.fa-face-grin:before,.fa-grin:before{content:"\f580"}.fa-backspace:before,.fa-delete-left:before{content:"\f55a"}.fa-eye-dropper-empty:before,.fa-eye-dropper:before,.fa-eyedropper:before{content:"\f1fb"}.fa-file-circle-check:before{content:"\e5a0"}.fa-forward:before{content:"\f04e"}.fa-mobile-android:before,.fa-mobile-phone:before,.fa-mobile:before{content:"\f3ce"}.fa-face-meh:before,.fa-meh:before{content:"\f11a"}.fa-align-center:before{content:"\f037"}.fa-book-dead:before,.fa-book-skull:before{content:"\f6b7"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-heart-circle-exclamation:before{content:"\e4fe"}.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}.fa-calendar-week:before{content:"\f784"}.fa-laptop-medical:before{content:"\f812"}.fa-b:before{content:"\42"}.fa-file-medical:before{content:"\f477"}.fa-dice-one:before{content:"\f525"}.fa-kiwi-bird:before{content:"\f535"}.fa-arrow-right-arrow-left:before,.fa-exchange:before{content:"\f0ec"}.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}.fa-cutlery:before,.fa-utensils:before{content:"\f2e7"}.fa-arrow-up-wide-short:before,.fa-sort-amount-up:before{content:"\f161"}.fa-mill-sign:before{content:"\e1ed"}.fa-bowl-rice:before{content:"\e2eb"}.fa-skull:before{content:"\f54c"}.fa-broadcast-tower:before,.fa-tower-broadcast:before{content:"\f519"}.fa-truck-pickup:before{content:"\f63c"}.fa-long-arrow-alt-up:before,.fa-up-long:before{content:"\f30c"}.fa-stop:before{content:"\f04d"}.fa-code-merge:before{content:"\f387"}.fa-upload:before{content:"\f093"}.fa-hurricane:before{content:"\f751"}.fa-mound:before{content:"\e52d"}.fa-toilet-portable:before{content:"\e583"}.fa-compact-disc:before{content:"\f51f"}.fa-file-arrow-down:before,.fa-file-download:before{content:"\f56d"}.fa-caravan:before{content:"\f8ff"}.fa-shield-cat:before{content:"\e572"}.fa-bolt:before,.fa-zap:before{content:"\f0e7"}.fa-glass-water:before{content:"\e4f4"}.fa-oil-well:before{content:"\e532"}.fa-vault:before{content:"\e2c5"}.fa-mars:before{content:"\f222"}.fa-toilet:before{content:"\f7d8"}.fa-plane-circle-xmark:before{content:"\e557"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen-sign:before,.fa-yen:before{content:"\f157"}.fa-rouble:before,.fa-rub:before,.fa-ruble-sign:before,.fa-ruble:before{content:"\f158"}.fa-sun:before{content:"\f185"}.fa-guitar:before{content:"\f7a6"}.fa-face-laugh-wink:before,.fa-laugh-wink:before{content:"\f59c"}.fa-horse-head:before{content:"\f7ab"}.fa-bore-hole:before{content:"\e4c3"}.fa-industry:before{content:"\f275"}.fa-arrow-alt-circle-down:before,.fa-circle-down:before{content:"\f358"}.fa-arrows-turn-to-dots:before{content:"\e4c1"}.fa-florin-sign:before{content:"\e184"}.fa-arrow-down-short-wide:before,.fa-sort-amount-desc:before,.fa-sort-amount-down-alt:before{content:"\f884"}.fa-less-than:before{content:"\3c"}.fa-angle-down:before{content:"\f107"}.fa-car-tunnel:before{content:"\e4de"}.fa-head-side-cough:before{content:"\e061"}.fa-grip-lines:before{content:"\f7a4"}.fa-thumbs-down:before{content:"\f165"}.fa-user-lock:before{content:"\f502"}.fa-arrow-right-long:before,.fa-long-arrow-right:before{content:"\f178"}.fa-anchor-circle-xmark:before{content:"\e4ac"}.fa-ellipsis-h:before,.fa-ellipsis:before{content:"\f141"}.fa-chess-pawn:before{content:"\f443"}.fa-first-aid:before,.fa-kit-medical:before{content:"\f479"}.fa-person-through-window:before{content:"\e5a9"}.fa-toolbox:before{content:"\f552"}.fa-hands-holding-circle:before{content:"\e4fb"}.fa-bug:before{content:"\f188"}.fa-credit-card-alt:before,.fa-credit-card:before{content:"\f09d"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-hand-holding-hand:before{content:"\e4f7"}.fa-book-open-reader:before,.fa-book-reader:before{content:"\f5da"}.fa-mountain-sun:before{content:"\e52f"}.fa-arrows-left-right-to-line:before{content:"\e4ba"}.fa-dice-d20:before{content:"\f6cf"}.fa-truck-droplet:before{content:"\e58c"}.fa-file-circle-xmark:before{content:"\e5a1"}.fa-temperature-arrow-up:before,.fa-temperature-up:before{content:"\e040"}.fa-medal:before{content:"\f5a2"}.fa-bed:before{content:"\f236"}.fa-h-square:before,.fa-square-h:before{content:"\f0fd"}.fa-podcast:before{content:"\f2ce"}.fa-temperature-4:before,.fa-temperature-full:before,.fa-thermometer-4:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-bell:before{content:"\f0f3"}.fa-superscript:before{content:"\f12b"}.fa-plug-circle-xmark:before{content:"\e560"}.fa-star-of-life:before{content:"\f621"}.fa-phone-slash:before{content:"\f3dd"}.fa-paint-roller:before{content:"\f5aa"}.fa-hands-helping:before,.fa-handshake-angle:before{content:"\f4c4"}.fa-location-dot:before,.fa-map-marker-alt:before{content:"\f3c5"}.fa-file:before{content:"\f15b"}.fa-greater-than:before{content:"\3e"}.fa-person-swimming:before,.fa-swimmer:before{content:"\f5c4"}.fa-arrow-down:before{content:"\f063"}.fa-droplet:before,.fa-tint:before{content:"\f043"}.fa-eraser:before{content:"\f12d"}.fa-earth-america:before,.fa-earth-americas:before,.fa-earth:before,.fa-globe-americas:before{content:"\f57d"}.fa-person-burst:before{content:"\e53b"}.fa-dove:before{content:"\f4ba"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-socks:before{content:"\f696"}.fa-inbox:before{content:"\f01c"}.fa-section:before{content:"\e447"}.fa-gauge-high:before,.fa-tachometer-alt-fast:before,.fa-tachometer-alt:before{content:"\f625"}.fa-envelope-open-text:before{content:"\f658"}.fa-hospital-alt:before,.fa-hospital-wide:before,.fa-hospital:before{content:"\f0f8"}.fa-wine-bottle:before{content:"\f72f"}.fa-chess-rook:before{content:"\f447"}.fa-bars-staggered:before,.fa-reorder:before,.fa-stream:before{content:"\f550"}.fa-dharmachakra:before{content:"\f655"}.fa-hotdog:before{content:"\f80f"}.fa-blind:before,.fa-person-walking-with-cane:before{content:"\f29d"}.fa-drum:before{content:"\f569"}.fa-ice-cream:before{content:"\f810"}.fa-heart-circle-bolt:before{content:"\e4fc"}.fa-fax:before{content:"\f1ac"}.fa-paragraph:before{content:"\f1dd"}.fa-check-to-slot:before,.fa-vote-yea:before{content:"\f772"}.fa-star-half:before{content:"\f089"}.fa-boxes-alt:before,.fa-boxes-stacked:before,.fa-boxes:before{content:"\f468"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-assistive-listening-systems:before,.fa-ear-listen:before{content:"\f2a2"}.fa-tree-city:before{content:"\e587"}.fa-play:before{content:"\f04b"}.fa-font:before{content:"\f031"}.fa-rupiah-sign:before{content:"\e23d"}.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}.fa-ping-pong-paddle-ball:before,.fa-table-tennis-paddle-ball:before,.fa-table-tennis:before{content:"\f45d"}.fa-diagnoses:before,.fa-person-dots-from-line:before{content:"\f470"}.fa-trash-can-arrow-up:before,.fa-trash-restore-alt:before{content:"\f82a"}.fa-naira-sign:before{content:"\e1f6"}.fa-cart-arrow-down:before{content:"\f218"}.fa-walkie-talkie:before{content:"\f8ef"}.fa-file-edit:before,.fa-file-pen:before{content:"\f31c"}.fa-receipt:before{content:"\f543"}.fa-pen-square:before,.fa-pencil-square:before,.fa-square-pen:before{content:"\f14b"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-person-circle-exclamation:before{content:"\e53f"}.fa-chevron-down:before{content:"\f078"}.fa-battery-5:before,.fa-battery-full:before,.fa-battery:before{content:"\f240"}.fa-skull-crossbones:before{content:"\f714"}.fa-code-compare:before{content:"\e13a"}.fa-list-dots:before,.fa-list-ul:before{content:"\f0ca"}.fa-school-lock:before{content:"\e56f"}.fa-tower-cell:before{content:"\e585"}.fa-down-long:before,.fa-long-arrow-alt-down:before{content:"\f309"}.fa-ranking-star:before{content:"\e561"}.fa-chess-king:before{content:"\f43f"}.fa-person-harassing:before{content:"\e549"}.fa-brazilian-real-sign:before{content:"\e46c"}.fa-landmark-alt:before,.fa-landmark-dome:before{content:"\f752"}.fa-arrow-up:before{content:"\f062"}.fa-television:before,.fa-tv-alt:before,.fa-tv:before{content:"\f26c"}.fa-shrimp:before{content:"\e448"}.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}.fa-jug-detergent:before{content:"\e519"}.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}.fa-user-shield:before{content:"\f505"}.fa-wind:before{content:"\f72e"}.fa-car-burst:before,.fa-car-crash:before{content:"\f5e1"}.fa-y:before{content:"\59"}.fa-person-snowboarding:before,.fa-snowboarding:before{content:"\f7ce"}.fa-shipping-fast:before,.fa-truck-fast:before{content:"\f48b"}.fa-fish:before{content:"\f578"}.fa-user-graduate:before{content:"\f501"}.fa-adjust:before,.fa-circle-half-stroke:before{content:"\f042"}.fa-clapperboard:before{content:"\e131"}.fa-circle-radiation:before,.fa-radiation-alt:before{content:"\f7ba"}.fa-baseball-ball:before,.fa-baseball:before{content:"\f433"}.fa-jet-fighter-up:before{content:"\e518"}.fa-diagram-project:before,.fa-project-diagram:before{content:"\f542"}.fa-copy:before{content:"\f0c5"}.fa-volume-mute:before,.fa-volume-times:before,.fa-volume-xmark:before{content:"\f6a9"}.fa-hand-sparkles:before{content:"\e05d"}.fa-grip-horizontal:before,.fa-grip:before{content:"\f58d"}.fa-share-from-square:before,.fa-share-square:before{content:"\f14d"}.fa-child-combatant:before,.fa-child-rifle:before{content:"\e4e0"}.fa-gun:before{content:"\e19b"}.fa-phone-square:before,.fa-square-phone:before{content:"\f098"}.fa-add:before,.fa-plus:before{content:"\2b"}.fa-expand:before{content:"\f065"}.fa-computer:before{content:"\e4e5"}.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}.fa-arrows-up-down-left-right:before,.fa-arrows:before{content:"\f047"}.fa-chalkboard-teacher:before,.fa-chalkboard-user:before{content:"\f51c"}.fa-peso-sign:before{content:"\e222"}.fa-building-shield:before{content:"\e4d8"}.fa-baby:before{content:"\f77c"}.fa-users-line:before{content:"\e592"}.fa-quote-left-alt:before,.fa-quote-left:before{content:"\f10d"}.fa-tractor:before{content:"\f722"}.fa-trash-arrow-up:before,.fa-trash-restore:before{content:"\f829"}.fa-arrow-down-up-lock:before{content:"\e4b0"}.fa-lines-leaning:before{content:"\e51e"}.fa-ruler-combined:before{content:"\f546"}.fa-copyright:before{content:"\f1f9"}.fa-equals:before{content:"\3d"}.fa-blender:before{content:"\f517"}.fa-teeth:before{content:"\f62e"}.fa-ils:before,.fa-shekel-sign:before,.fa-shekel:before,.fa-sheqel-sign:before,.fa-sheqel:before{content:"\f20b"}.fa-map:before{content:"\f279"}.fa-rocket:before{content:"\f135"}.fa-photo-film:before,.fa-photo-video:before{content:"\f87c"}.fa-folder-minus:before{content:"\f65d"}.fa-store:before{content:"\f54e"}.fa-arrow-trend-up:before{content:"\e098"}.fa-plug-circle-minus:before{content:"\e55e"}.fa-sign-hanging:before,.fa-sign:before{content:"\f4d9"}.fa-bezier-curve:before{content:"\f55b"}.fa-bell-slash:before{content:"\f1f6"}.fa-tablet-android:before,.fa-tablet:before{content:"\f3fb"}.fa-school-flag:before{content:"\e56e"}.fa-fill:before{content:"\f575"}.fa-angle-up:before{content:"\f106"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-holly-berry:before{content:"\f7aa"}.fa-chevron-left:before{content:"\f053"}.fa-bacteria:before{content:"\e059"}.fa-hand-lizard:before{content:"\f258"}.fa-notdef:before{content:"\e1fe"}.fa-disease:before{content:"\f7fa"}.fa-briefcase-medical:before{content:"\f469"}.fa-genderless:before{content:"\f22d"}.fa-chevron-right:before{content:"\f054"}.fa-retweet:before{content:"\f079"}.fa-car-alt:before,.fa-car-rear:before{content:"\f5de"}.fa-pump-soap:before{content:"\e06b"}.fa-video-slash:before{content:"\f4e2"}.fa-battery-2:before,.fa-battery-quarter:before{content:"\f243"}.fa-radio:before{content:"\f8d7"}.fa-baby-carriage:before,.fa-carriage-baby:before{content:"\f77d"}.fa-traffic-light:before{content:"\f637"}.fa-thermometer:before{content:"\f491"}.fa-vr-cardboard:before{content:"\f729"}.fa-hand-middle-finger:before{content:"\f806"}.fa-percent:before,.fa-percentage:before{content:"\25"}.fa-truck-moving:before{content:"\f4df"}.fa-glass-water-droplet:before{content:"\e4f5"}.fa-display:before{content:"\e163"}.fa-face-smile:before,.fa-smile:before{content:"\f118"}.fa-thumb-tack:before,.fa-thumbtack:before{content:"\f08d"}.fa-trophy:before{content:"\f091"}.fa-person-praying:before,.fa-pray:before{content:"\f683"}.fa-hammer:before{content:"\f6e3"}.fa-hand-peace:before{content:"\f25b"}.fa-rotate:before,.fa-sync-alt:before{content:"\f2f1"}.fa-spinner:before{content:"\f110"}.fa-robot:before{content:"\f544"}.fa-peace:before{content:"\f67c"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-warehouse:before{content:"\f494"}.fa-arrow-up-right-dots:before{content:"\e4b7"}.fa-splotch:before{content:"\f5bc"}.fa-face-grin-hearts:before,.fa-grin-hearts:before{content:"\f584"}.fa-dice-four:before{content:"\f524"}.fa-sim-card:before{content:"\f7c4"}.fa-transgender-alt:before,.fa-transgender:before{content:"\f225"}.fa-mercury:before{content:"\f223"}.fa-arrow-turn-down:before,.fa-level-down:before{content:"\f149"}.fa-person-falling-burst:before{content:"\e547"}.fa-award:before{content:"\f559"}.fa-ticket-alt:before,.fa-ticket-simple:before{content:"\f3ff"}.fa-building:before{content:"\f1ad"}.fa-angle-double-left:before,.fa-angles-left:before{content:"\f100"}.fa-qrcode:before{content:"\f029"}.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}.fa-face-grin-beam-sweat:before,.fa-grin-beam-sweat:before{content:"\f583"}.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}.fa-shield-blank:before,.fa-shield:before{content:"\f132"}.fa-arrow-up-short-wide:before,.fa-sort-amount-up-alt:before{content:"\f885"}.fa-house-medical:before{content:"\e3b2"}.fa-golf-ball-tee:before,.fa-golf-ball:before{content:"\f450"}.fa-chevron-circle-left:before,.fa-circle-chevron-left:before{content:"\f137"}.fa-house-chimney-window:before{content:"\e00d"}.fa-pen-nib:before{content:"\f5ad"}.fa-tent-arrow-turn-left:before{content:"\e580"}.fa-tents:before{content:"\e582"}.fa-magic:before,.fa-wand-magic:before{content:"\f0d0"}.fa-dog:before{content:"\f6d3"}.fa-carrot:before{content:"\f787"}.fa-moon:before{content:"\f186"}.fa-wine-glass-alt:before,.fa-wine-glass-empty:before{content:"\f5ce"}.fa-cheese:before{content:"\f7ef"}.fa-yin-yang:before{content:"\f6ad"}.fa-music:before{content:"\f001"}.fa-code-commit:before{content:"\f386"}.fa-temperature-low:before{content:"\f76b"}.fa-biking:before,.fa-person-biking:before{content:"\f84a"}.fa-broom:before{content:"\f51a"}.fa-shield-heart:before{content:"\e574"}.fa-gopuram:before{content:"\f664"}.fa-earth-oceania:before,.fa-globe-oceania:before{content:"\e47b"}.fa-square-xmark:before,.fa-times-square:before,.fa-xmark-square:before{content:"\f2d3"}.fa-hashtag:before{content:"\23"}.fa-expand-alt:before,.fa-up-right-and-down-left-from-center:before{content:"\f424"}.fa-oil-can:before{content:"\f613"}.fa-t:before{content:"\54"}.fa-hippo:before{content:"\f6ed"}.fa-chart-column:before{content:"\e0e3"}.fa-infinity:before{content:"\f534"}.fa-vial-circle-check:before{content:"\e596"}.fa-person-arrow-down-to-line:before{content:"\e538"}.fa-voicemail:before{content:"\f897"}.fa-fan:before{content:"\f863"}.fa-person-walking-luggage:before{content:"\e554"}.fa-arrows-alt-v:before,.fa-up-down:before{content:"\f338"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-calendar:before{content:"\f133"}.fa-trailer:before{content:"\e041"}.fa-bahai:before,.fa-haykal:before{content:"\f666"}.fa-sd-card:before{content:"\f7c2"}.fa-dragon:before{content:"\f6d5"}.fa-shoe-prints:before{content:"\f54b"}.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}.fa-face-grin-tongue-wink:before,.fa-grin-tongue-wink:before{content:"\f58b"}.fa-hand-holding:before{content:"\f4bd"}.fa-plug-circle-exclamation:before{content:"\e55d"}.fa-chain-broken:before,.fa-chain-slash:before,.fa-link-slash:before,.fa-unlink:before{content:"\f127"}.fa-clone:before{content:"\f24d"}.fa-person-walking-arrow-loop-left:before{content:"\e551"}.fa-arrow-up-z-a:before,.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-fire-alt:before,.fa-fire-flame-curved:before{content:"\f7e4"}.fa-tornado:before{content:"\f76f"}.fa-file-circle-plus:before{content:"\e494"}.fa-book-quran:before,.fa-quran:before{content:"\f687"}.fa-anchor:before{content:"\f13d"}.fa-border-all:before{content:"\f84c"}.fa-angry:before,.fa-face-angry:before{content:"\f556"}.fa-cookie-bite:before{content:"\f564"}.fa-arrow-trend-down:before{content:"\e097"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-draw-polygon:before{content:"\f5ee"}.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}.fa-gauge-simple-high:before,.fa-tachometer-fast:before,.fa-tachometer:before{content:"\f62a"}.fa-shower:before{content:"\f2cc"}.fa-desktop-alt:before,.fa-desktop:before{content:"\f390"}.fa-m:before{content:"\4d"}.fa-table-list:before,.fa-th-list:before{content:"\f00b"}.fa-comment-sms:before,.fa-sms:before{content:"\f7cd"}.fa-book:before{content:"\f02d"}.fa-user-plus:before{content:"\f234"}.fa-check:before{content:"\f00c"}.fa-battery-4:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-house-circle-check:before{content:"\e509"}.fa-angle-left:before{content:"\f104"}.fa-diagram-successor:before{content:"\e47a"}.fa-truck-arrow-right:before{content:"\e58b"}.fa-arrows-split-up-and-left:before{content:"\e4bc"}.fa-fist-raised:before,.fa-hand-fist:before{content:"\f6de"}.fa-cloud-moon:before{content:"\f6c3"}.fa-briefcase:before{content:"\f0b1"}.fa-person-falling:before{content:"\e546"}.fa-image-portrait:before,.fa-portrait:before{content:"\f3e0"}.fa-user-tag:before{content:"\f507"}.fa-rug:before{content:"\e569"}.fa-earth-europe:before,.fa-globe-europe:before{content:"\f7a2"}.fa-cart-flatbed-suitcase:before,.fa-luggage-cart:before{content:"\f59d"}.fa-rectangle-times:before,.fa-rectangle-xmark:before,.fa-times-rectangle:before,.fa-window-close:before{content:"\f410"}.fa-baht-sign:before{content:"\e0ac"}.fa-book-open:before{content:"\f518"}.fa-book-journal-whills:before,.fa-journal-whills:before{content:"\f66a"}.fa-handcuffs:before{content:"\e4f8"}.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}.fa-database:before{content:"\f1c0"}.fa-arrow-turn-right:before,.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-bottle-droplet:before{content:"\e4c4"}.fa-mask-face:before{content:"\e1d7"}.fa-hill-rockslide:before{content:"\e508"}.fa-exchange-alt:before,.fa-right-left:before{content:"\f362"}.fa-paper-plane:before{content:"\f1d8"}.fa-road-circle-exclamation:before{content:"\e565"}.fa-dungeon:before{content:"\f6d9"}.fa-align-right:before{content:"\f038"}.fa-money-bill-1-wave:before,.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-life-ring:before{content:"\f1cd"}.fa-hands:before,.fa-sign-language:before,.fa-signing:before{content:"\f2a7"}.fa-calendar-day:before{content:"\f783"}.fa-ladder-water:before,.fa-swimming-pool:before,.fa-water-ladder:before{content:"\f5c5"}.fa-arrows-up-down:before,.fa-arrows-v:before{content:"\f07d"}.fa-face-grimace:before,.fa-grimace:before{content:"\f57f"}.fa-wheelchair-alt:before,.fa-wheelchair-move:before{content:"\e2ce"}.fa-level-down-alt:before,.fa-turn-down:before{content:"\f3be"}.fa-person-walking-arrow-right:before{content:"\e552"}.fa-envelope-square:before,.fa-square-envelope:before{content:"\f199"}.fa-dice:before{content:"\f522"}.fa-bowling-ball:before{content:"\f436"}.fa-brain:before{content:"\f5dc"}.fa-band-aid:before,.fa-bandage:before{content:"\f462"}.fa-calendar-minus:before{content:"\f272"}.fa-circle-xmark:before,.fa-times-circle:before,.fa-xmark-circle:before{content:"\f057"}.fa-gifts:before{content:"\f79c"}.fa-hotel:before{content:"\f594"}.fa-earth-asia:before,.fa-globe-asia:before{content:"\f57e"}.fa-id-card-alt:before,.fa-id-card-clip:before{content:"\f47f"}.fa-magnifying-glass-plus:before,.fa-search-plus:before{content:"\f00e"}.fa-thumbs-up:before{content:"\f164"}.fa-user-clock:before{content:"\f4fd"}.fa-allergies:before,.fa-hand-dots:before{content:"\f461"}.fa-file-invoice:before{content:"\f570"}.fa-window-minimize:before{content:"\f2d1"}.fa-coffee:before,.fa-mug-saucer:before{content:"\f0f4"}.fa-brush:before{content:"\f55d"}.fa-mask:before{content:"\f6fa"}.fa-magnifying-glass-minus:before,.fa-search-minus:before{content:"\f010"}.fa-ruler-vertical:before{content:"\f548"}.fa-user-alt:before,.fa-user-large:before{content:"\f406"}.fa-train-tram:before{content:"\e5b4"}.fa-user-nurse:before{content:"\f82f"}.fa-syringe:before{content:"\f48e"}.fa-cloud-sun:before{content:"\f6c4"}.fa-stopwatch-20:before{content:"\e06f"}.fa-square-full:before{content:"\f45c"}.fa-magnet:before{content:"\f076"}.fa-jar:before{content:"\e516"}.fa-note-sticky:before,.fa-sticky-note:before{content:"\f249"}.fa-bug-slash:before{content:"\e490"}.fa-arrow-up-from-water-pump:before{content:"\e4b6"}.fa-bone:before{content:"\f5d7"}.fa-user-injured:before{content:"\f728"}.fa-face-sad-tear:before,.fa-sad-tear:before{content:"\f5b4"}.fa-plane:before{content:"\f072"}.fa-tent-arrows-down:before{content:"\e581"}.fa-exclamation:before{content:"\21"}.fa-arrows-spin:before{content:"\e4bb"}.fa-print:before{content:"\f02f"}.fa-try:before,.fa-turkish-lira-sign:before,.fa-turkish-lira:before{content:"\e2bb"}.fa-dollar-sign:before,.fa-dollar:before,.fa-usd:before{content:"\24"}.fa-x:before{content:"\58"}.fa-magnifying-glass-dollar:before,.fa-search-dollar:before{content:"\f688"}.fa-users-cog:before,.fa-users-gear:before{content:"\f509"}.fa-person-military-pointing:before{content:"\e54a"}.fa-bank:before,.fa-building-columns:before,.fa-institution:before,.fa-museum:before,.fa-university:before{content:"\f19c"}.fa-umbrella:before{content:"\f0e9"}.fa-trowel:before{content:"\e589"}.fa-d:before{content:"\44"}.fa-stapler:before{content:"\e5af"}.fa-masks-theater:before,.fa-theater-masks:before{content:"\f630"}.fa-kip-sign:before{content:"\e1c4"}.fa-hand-point-left:before{content:"\f0a5"}.fa-handshake-alt:before,.fa-handshake-simple:before{content:"\f4c6"}.fa-fighter-jet:before,.fa-jet-fighter:before{content:"\f0fb"}.fa-share-alt-square:before,.fa-square-share-nodes:before{content:"\f1e1"}.fa-barcode:before{content:"\f02a"}.fa-plus-minus:before{content:"\e43c"}.fa-video-camera:before,.fa-video:before{content:"\f03d"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-person-circle-check:before{content:"\e53e"}.fa-level-up-alt:before,.fa-turn-up:before{content:"\f3bf"}.fa-sr-only,.fa-sr-only-focusable:not(:focus),.sr-only,.sr-only-focusable:not(:focus){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0} \ No newline at end of file diff --git a/flask_appbuilder/static/appbuilder/css/fontawesome/regular.min.css b/flask_appbuilder/static/appbuilder/css/fontawesome/regular.min.css new file mode 100644 index 0000000000..39353161c0 --- /dev/null +++ b/flask_appbuilder/static/appbuilder/css/fontawesome/regular.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Free 6.2.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2022 Fonticons, Inc. + */ +:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../../webfonts/fa-regular-400.woff2) format("woff2"),url(../../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400} \ No newline at end of file diff --git a/flask_appbuilder/static/appbuilder/css/fontawesome/solid.min.css b/flask_appbuilder/static/appbuilder/css/fontawesome/solid.min.css new file mode 100644 index 0000000000..c0d6e287be --- /dev/null +++ b/flask_appbuilder/static/appbuilder/css/fontawesome/solid.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Free 6.2.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2022 Fonticons, Inc. + */ +:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../../webfonts/fa-solid-900.woff2) format("woff2"),url(../../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900} \ No newline at end of file diff --git a/flask_appbuilder/static/appbuilder/css/fontawesome/v4-font-face.min.css b/flask_appbuilder/static/appbuilder/css/fontawesome/v4-font-face.min.css new file mode 100644 index 0000000000..88d02b400a --- /dev/null +++ b/flask_appbuilder/static/appbuilder/css/fontawesome/v4-font-face.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Free 6.2.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2022 Fonticons, Inc. + */ +@font-face{font-family:"FontAwesome";font-display:block;src:url(../../webfonts/fa-solid-900.woff2) format("woff2"),url(../../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../../webfonts/fa-brands-400.woff2) format("woff2"),url(../../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../../webfonts/fa-regular-400.woff2) format("woff2"),url(../../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a} \ No newline at end of file diff --git a/flask_appbuilder/static/appbuilder/css/fontawesome/v4-shims.min.css b/flask_appbuilder/static/appbuilder/css/fontawesome/v4-shims.min.css new file mode 100644 index 0000000000..a5f491b303 --- /dev/null +++ b/flask_appbuilder/static/appbuilder/css/fontawesome/v4-shims.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Free 6.2.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2022 Fonticons, Inc. + */ +.fa.fa-glass:before{content:"\f000"}.fa.fa-envelope-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-envelope-o:before{content:"\f0e0"}.fa.fa-star-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-star-o:before{content:"\f005"}.fa.fa-close:before,.fa.fa-remove:before{content:"\f00d"}.fa.fa-gear:before{content:"\f013"}.fa.fa-trash-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-trash-o:before{content:"\f2ed"}.fa.fa-home:before{content:"\f015"}.fa.fa-file-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-o:before{content:"\f15b"}.fa.fa-clock-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-clock-o:before{content:"\f017"}.fa.fa-arrow-circle-o-down{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-arrow-circle-o-down:before{content:"\f358"}.fa.fa-arrow-circle-o-up{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-arrow-circle-o-up:before{content:"\f35b"}.fa.fa-play-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-play-circle-o:before{content:"\f144"}.fa.fa-repeat:before,.fa.fa-rotate-right:before{content:"\f01e"}.fa.fa-refresh:before{content:"\f021"}.fa.fa-list-alt{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-list-alt:before{content:"\f022"}.fa.fa-dedent:before{content:"\f03b"}.fa.fa-video-camera:before{content:"\f03d"}.fa.fa-picture-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-picture-o:before{content:"\f03e"}.fa.fa-photo{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-photo:before{content:"\f03e"}.fa.fa-image{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-image:before{content:"\f03e"}.fa.fa-map-marker:before{content:"\f3c5"}.fa.fa-pencil-square-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-pencil-square-o:before{content:"\f044"}.fa.fa-edit{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-edit:before{content:"\f044"}.fa.fa-share-square-o:before{content:"\f14d"}.fa.fa-check-square-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-check-square-o:before{content:"\f14a"}.fa.fa-arrows:before{content:"\f0b2"}.fa.fa-times-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-times-circle-o:before{content:"\f057"}.fa.fa-check-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-check-circle-o:before{content:"\f058"}.fa.fa-mail-forward:before{content:"\f064"}.fa.fa-expand:before{content:"\f424"}.fa.fa-compress:before{content:"\f422"}.fa.fa-eye,.fa.fa-eye-slash{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-warning:before{content:"\f071"}.fa.fa-calendar:before{content:"\f073"}.fa.fa-arrows-v:before{content:"\f338"}.fa.fa-arrows-h:before{content:"\f337"}.fa.fa-bar-chart-o:before,.fa.fa-bar-chart:before{content:"\e0e3"}.fa.fa-twitter-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-twitter-square:before{content:"\f081"}.fa.fa-facebook-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-facebook-square:before{content:"\f082"}.fa.fa-gears:before{content:"\f085"}.fa.fa-thumbs-o-up{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-thumbs-o-up:before{content:"\f164"}.fa.fa-thumbs-o-down{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-thumbs-o-down:before{content:"\f165"}.fa.fa-heart-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-heart-o:before{content:"\f004"}.fa.fa-sign-out:before{content:"\f2f5"}.fa.fa-linkedin-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-linkedin-square:before{content:"\f08c"}.fa.fa-thumb-tack:before{content:"\f08d"}.fa.fa-external-link:before{content:"\f35d"}.fa.fa-sign-in:before{content:"\f2f6"}.fa.fa-github-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-github-square:before{content:"\f092"}.fa.fa-lemon-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-lemon-o:before{content:"\f094"}.fa.fa-square-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-square-o:before{content:"\f0c8"}.fa.fa-bookmark-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-bookmark-o:before{content:"\f02e"}.fa.fa-facebook,.fa.fa-twitter{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-facebook:before{content:"\f39e"}.fa.fa-facebook-f{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-facebook-f:before{content:"\f39e"}.fa.fa-github{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-credit-card{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-feed:before{content:"\f09e"}.fa.fa-hdd-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hdd-o:before{content:"\f0a0"}.fa.fa-hand-o-right{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-o-right:before{content:"\f0a4"}.fa.fa-hand-o-left{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-o-left:before{content:"\f0a5"}.fa.fa-hand-o-up{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-o-up:before{content:"\f0a6"}.fa.fa-hand-o-down{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-o-down:before{content:"\f0a7"}.fa.fa-globe:before{content:"\f57d"}.fa.fa-tasks:before{content:"\f828"}.fa.fa-arrows-alt:before{content:"\f31e"}.fa.fa-group:before{content:"\f0c0"}.fa.fa-chain:before{content:"\f0c1"}.fa.fa-cut:before{content:"\f0c4"}.fa.fa-files-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-files-o:before{content:"\f0c5"}.fa.fa-floppy-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-floppy-o:before{content:"\f0c7"}.fa.fa-save{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-save:before{content:"\f0c7"}.fa.fa-navicon:before,.fa.fa-reorder:before{content:"\f0c9"}.fa.fa-magic:before{content:"\e2ca"}.fa.fa-pinterest,.fa.fa-pinterest-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-pinterest-square:before{content:"\f0d3"}.fa.fa-google-plus-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-google-plus-square:before{content:"\f0d4"}.fa.fa-google-plus{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-google-plus:before{content:"\f0d5"}.fa.fa-money:before{content:"\f3d1"}.fa.fa-unsorted:before{content:"\f0dc"}.fa.fa-sort-desc:before{content:"\f0dd"}.fa.fa-sort-asc:before{content:"\f0de"}.fa.fa-linkedin{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-linkedin:before{content:"\f0e1"}.fa.fa-rotate-left:before{content:"\f0e2"}.fa.fa-legal:before{content:"\f0e3"}.fa.fa-dashboard:before,.fa.fa-tachometer:before{content:"\f625"}.fa.fa-comment-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-comment-o:before{content:"\f075"}.fa.fa-comments-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-comments-o:before{content:"\f086"}.fa.fa-flash:before{content:"\f0e7"}.fa.fa-clipboard:before{content:"\f0ea"}.fa.fa-lightbulb-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-lightbulb-o:before{content:"\f0eb"}.fa.fa-exchange:before{content:"\f362"}.fa.fa-cloud-download:before{content:"\f0ed"}.fa.fa-cloud-upload:before{content:"\f0ee"}.fa.fa-bell-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-bell-o:before{content:"\f0f3"}.fa.fa-cutlery:before{content:"\f2e7"}.fa.fa-file-text-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-text-o:before{content:"\f15c"}.fa.fa-building-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-building-o:before{content:"\f1ad"}.fa.fa-hospital-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hospital-o:before{content:"\f0f8"}.fa.fa-tablet:before{content:"\f3fa"}.fa.fa-mobile-phone:before,.fa.fa-mobile:before{content:"\f3cd"}.fa.fa-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-circle-o:before{content:"\f111"}.fa.fa-mail-reply:before{content:"\f3e5"}.fa.fa-github-alt{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-folder-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-folder-o:before{content:"\f07b"}.fa.fa-folder-open-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-folder-open-o:before{content:"\f07c"}.fa.fa-smile-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-smile-o:before{content:"\f118"}.fa.fa-frown-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-frown-o:before{content:"\f119"}.fa.fa-meh-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-meh-o:before{content:"\f11a"}.fa.fa-keyboard-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-keyboard-o:before{content:"\f11c"}.fa.fa-flag-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-flag-o:before{content:"\f024"}.fa.fa-mail-reply-all:before{content:"\f122"}.fa.fa-star-half-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-star-half-o:before{content:"\f5c0"}.fa.fa-star-half-empty{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-star-half-empty:before{content:"\f5c0"}.fa.fa-star-half-full{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-star-half-full:before{content:"\f5c0"}.fa.fa-code-fork:before{content:"\f126"}.fa.fa-chain-broken:before,.fa.fa-unlink:before{content:"\f127"}.fa.fa-calendar-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-calendar-o:before{content:"\f133"}.fa.fa-css3,.fa.fa-html5,.fa.fa-maxcdn{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-unlock-alt:before{content:"\f09c"}.fa.fa-minus-square-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-minus-square-o:before{content:"\f146"}.fa.fa-level-up:before{content:"\f3bf"}.fa.fa-level-down:before{content:"\f3be"}.fa.fa-pencil-square:before{content:"\f14b"}.fa.fa-external-link-square:before{content:"\f360"}.fa.fa-compass{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-caret-square-o-down{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-caret-square-o-down:before{content:"\f150"}.fa.fa-toggle-down{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-toggle-down:before{content:"\f150"}.fa.fa-caret-square-o-up{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-caret-square-o-up:before{content:"\f151"}.fa.fa-toggle-up{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-toggle-up:before{content:"\f151"}.fa.fa-caret-square-o-right{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-caret-square-o-right:before{content:"\f152"}.fa.fa-toggle-right{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-toggle-right:before{content:"\f152"}.fa.fa-eur:before,.fa.fa-euro:before{content:"\f153"}.fa.fa-gbp:before{content:"\f154"}.fa.fa-dollar:before,.fa.fa-usd:before{content:"\24"}.fa.fa-inr:before,.fa.fa-rupee:before{content:"\e1bc"}.fa.fa-cny:before,.fa.fa-jpy:before,.fa.fa-rmb:before,.fa.fa-yen:before{content:"\f157"}.fa.fa-rouble:before,.fa.fa-rub:before,.fa.fa-ruble:before{content:"\f158"}.fa.fa-krw:before,.fa.fa-won:before{content:"\f159"}.fa.fa-bitcoin,.fa.fa-btc{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-bitcoin:before{content:"\f15a"}.fa.fa-file-text:before{content:"\f15c"}.fa.fa-sort-alpha-asc:before{content:"\f15d"}.fa.fa-sort-alpha-desc:before{content:"\f881"}.fa.fa-sort-amount-asc:before{content:"\f884"}.fa.fa-sort-amount-desc:before{content:"\f160"}.fa.fa-sort-numeric-asc:before{content:"\f162"}.fa.fa-sort-numeric-desc:before{content:"\f886"}.fa.fa-youtube-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-youtube-square:before{content:"\f431"}.fa.fa-xing,.fa.fa-xing-square,.fa.fa-youtube{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-xing-square:before{content:"\f169"}.fa.fa-youtube-play{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-youtube-play:before{content:"\f167"}.fa.fa-adn,.fa.fa-bitbucket,.fa.fa-bitbucket-square,.fa.fa-dropbox,.fa.fa-flickr,.fa.fa-instagram,.fa.fa-stack-overflow{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-bitbucket-square:before{content:"\f171"}.fa.fa-tumblr,.fa.fa-tumblr-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-tumblr-square:before{content:"\f174"}.fa.fa-long-arrow-down:before{content:"\f309"}.fa.fa-long-arrow-up:before{content:"\f30c"}.fa.fa-long-arrow-left:before{content:"\f30a"}.fa.fa-long-arrow-right:before{content:"\f30b"}.fa.fa-android,.fa.fa-apple,.fa.fa-dribbble,.fa.fa-foursquare,.fa.fa-gittip,.fa.fa-gratipay,.fa.fa-linux,.fa.fa-skype,.fa.fa-trello,.fa.fa-windows{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-gittip:before{content:"\f184"}.fa.fa-sun-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-sun-o:before{content:"\f185"}.fa.fa-moon-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-moon-o:before{content:"\f186"}.fa.fa-pagelines,.fa.fa-renren,.fa.fa-stack-exchange,.fa.fa-vk,.fa.fa-weibo{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-arrow-circle-o-right{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-arrow-circle-o-right:before{content:"\f35a"}.fa.fa-arrow-circle-o-left{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-arrow-circle-o-left:before{content:"\f359"}.fa.fa-caret-square-o-left{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-caret-square-o-left:before{content:"\f191"}.fa.fa-toggle-left{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-toggle-left:before{content:"\f191"}.fa.fa-dot-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-dot-circle-o:before{content:"\f192"}.fa.fa-vimeo-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-vimeo-square:before{content:"\f194"}.fa.fa-try:before,.fa.fa-turkish-lira:before{content:"\e2bb"}.fa.fa-plus-square-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-plus-square-o:before{content:"\f0fe"}.fa.fa-openid,.fa.fa-slack,.fa.fa-wordpress{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-bank:before,.fa.fa-institution:before{content:"\f19c"}.fa.fa-mortar-board:before{content:"\f19d"}.fa.fa-google,.fa.fa-reddit,.fa.fa-reddit-square,.fa.fa-yahoo{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-reddit-square:before{content:"\f1a2"}.fa.fa-behance,.fa.fa-behance-square,.fa.fa-delicious,.fa.fa-digg,.fa.fa-drupal,.fa.fa-joomla,.fa.fa-pied-piper-alt,.fa.fa-pied-piper-pp,.fa.fa-stumbleupon,.fa.fa-stumbleupon-circle{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-behance-square:before{content:"\f1b5"}.fa.fa-steam,.fa.fa-steam-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-steam-square:before{content:"\f1b7"}.fa.fa-automobile:before{content:"\f1b9"}.fa.fa-cab:before{content:"\f1ba"}.fa.fa-deviantart,.fa.fa-soundcloud,.fa.fa-spotify{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-file-pdf-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-pdf-o:before{content:"\f1c1"}.fa.fa-file-word-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-word-o:before{content:"\f1c2"}.fa.fa-file-excel-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-excel-o:before{content:"\f1c3"}.fa.fa-file-powerpoint-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-powerpoint-o:before{content:"\f1c4"}.fa.fa-file-image-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-image-o:before{content:"\f1c5"}.fa.fa-file-photo-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-photo-o:before{content:"\f1c5"}.fa.fa-file-picture-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-picture-o:before{content:"\f1c5"}.fa.fa-file-archive-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-archive-o:before{content:"\f1c6"}.fa.fa-file-zip-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-zip-o:before{content:"\f1c6"}.fa.fa-file-audio-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-audio-o:before{content:"\f1c7"}.fa.fa-file-sound-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-sound-o:before{content:"\f1c7"}.fa.fa-file-video-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-video-o:before{content:"\f1c8"}.fa.fa-file-movie-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-movie-o:before{content:"\f1c8"}.fa.fa-file-code-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-code-o:before{content:"\f1c9"}.fa.fa-codepen,.fa.fa-jsfiddle,.fa.fa-vine{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-life-bouy:before,.fa.fa-life-buoy:before,.fa.fa-life-saver:before,.fa.fa-support:before{content:"\f1cd"}.fa.fa-circle-o-notch:before{content:"\f1ce"}.fa.fa-ra,.fa.fa-rebel{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-ra:before{content:"\f1d0"}.fa.fa-resistance{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-resistance:before{content:"\f1d0"}.fa.fa-empire,.fa.fa-ge{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-ge:before{content:"\f1d1"}.fa.fa-git-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-git-square:before{content:"\f1d2"}.fa.fa-git,.fa.fa-hacker-news,.fa.fa-y-combinator-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-y-combinator-square:before{content:"\f1d4"}.fa.fa-yc-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-yc-square:before{content:"\f1d4"}.fa.fa-qq,.fa.fa-tencent-weibo,.fa.fa-wechat,.fa.fa-weixin{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-wechat:before{content:"\f1d7"}.fa.fa-send:before{content:"\f1d8"}.fa.fa-paper-plane-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-paper-plane-o:before{content:"\f1d8"}.fa.fa-send-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-send-o:before{content:"\f1d8"}.fa.fa-circle-thin{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-circle-thin:before{content:"\f111"}.fa.fa-header:before{content:"\f1dc"}.fa.fa-futbol-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-futbol-o:before{content:"\f1e3"}.fa.fa-soccer-ball-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-soccer-ball-o:before{content:"\f1e3"}.fa.fa-slideshare,.fa.fa-twitch,.fa.fa-yelp{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-newspaper-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-newspaper-o:before{content:"\f1ea"}.fa.fa-cc-amex,.fa.fa-cc-discover,.fa.fa-cc-mastercard,.fa.fa-cc-paypal,.fa.fa-cc-stripe,.fa.fa-cc-visa,.fa.fa-google-wallet,.fa.fa-paypal{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-bell-slash-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-bell-slash-o:before{content:"\f1f6"}.fa.fa-trash:before{content:"\f2ed"}.fa.fa-copyright{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-eyedropper:before{content:"\f1fb"}.fa.fa-area-chart:before{content:"\f1fe"}.fa.fa-pie-chart:before{content:"\f200"}.fa.fa-line-chart:before{content:"\f201"}.fa.fa-lastfm,.fa.fa-lastfm-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-lastfm-square:before{content:"\f203"}.fa.fa-angellist,.fa.fa-ioxhost{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-cc{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-cc:before{content:"\f20a"}.fa.fa-ils:before,.fa.fa-shekel:before,.fa.fa-sheqel:before{content:"\f20b"}.fa.fa-buysellads,.fa.fa-connectdevelop,.fa.fa-dashcube,.fa.fa-forumbee,.fa.fa-leanpub,.fa.fa-sellsy,.fa.fa-shirtsinbulk,.fa.fa-simplybuilt,.fa.fa-skyatlas{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-diamond{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-diamond:before{content:"\f3a5"}.fa.fa-intersex:before,.fa.fa-transgender:before{content:"\f224"}.fa.fa-transgender-alt:before{content:"\f225"}.fa.fa-facebook-official{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-facebook-official:before{content:"\f09a"}.fa.fa-pinterest-p,.fa.fa-whatsapp{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-hotel:before{content:"\f236"}.fa.fa-medium,.fa.fa-viacoin,.fa.fa-y-combinator,.fa.fa-yc{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-yc:before{content:"\f23b"}.fa.fa-expeditedssl,.fa.fa-opencart,.fa.fa-optin-monster{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-battery-4:before,.fa.fa-battery:before{content:"\f240"}.fa.fa-battery-3:before{content:"\f241"}.fa.fa-battery-2:before{content:"\f242"}.fa.fa-battery-1:before{content:"\f243"}.fa.fa-battery-0:before{content:"\f244"}.fa.fa-object-group,.fa.fa-object-ungroup,.fa.fa-sticky-note-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-sticky-note-o:before{content:"\f249"}.fa.fa-cc-diners-club,.fa.fa-cc-jcb{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-clone{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hourglass-o:before{content:"\f254"}.fa.fa-hourglass-1:before{content:"\f251"}.fa.fa-hourglass-2:before{content:"\f252"}.fa.fa-hourglass-3:before{content:"\f253"}.fa.fa-hand-rock-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-rock-o:before{content:"\f255"}.fa.fa-hand-grab-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-grab-o:before{content:"\f255"}.fa.fa-hand-paper-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-paper-o:before{content:"\f256"}.fa.fa-hand-stop-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-stop-o:before{content:"\f256"}.fa.fa-hand-scissors-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-scissors-o:before{content:"\f257"}.fa.fa-hand-lizard-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-lizard-o:before{content:"\f258"}.fa.fa-hand-spock-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-spock-o:before{content:"\f259"}.fa.fa-hand-pointer-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-pointer-o:before{content:"\f25a"}.fa.fa-hand-peace-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-peace-o:before{content:"\f25b"}.fa.fa-registered{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-creative-commons,.fa.fa-gg,.fa.fa-gg-circle,.fa.fa-odnoklassniki,.fa.fa-odnoklassniki-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-odnoklassniki-square:before{content:"\f264"}.fa.fa-chrome,.fa.fa-firefox,.fa.fa-get-pocket,.fa.fa-internet-explorer,.fa.fa-opera,.fa.fa-safari,.fa.fa-wikipedia-w{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-television:before{content:"\f26c"}.fa.fa-500px,.fa.fa-amazon,.fa.fa-contao{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-calendar-plus-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-calendar-plus-o:before{content:"\f271"}.fa.fa-calendar-minus-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-calendar-minus-o:before{content:"\f272"}.fa.fa-calendar-times-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-calendar-times-o:before{content:"\f273"}.fa.fa-calendar-check-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-calendar-check-o:before{content:"\f274"}.fa.fa-map-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-map-o:before{content:"\f279"}.fa.fa-commenting:before{content:"\f4ad"}.fa.fa-commenting-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-commenting-o:before{content:"\f4ad"}.fa.fa-houzz,.fa.fa-vimeo{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-vimeo:before{content:"\f27d"}.fa.fa-black-tie,.fa.fa-edge,.fa.fa-fonticons,.fa.fa-reddit-alien{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-credit-card-alt:before{content:"\f09d"}.fa.fa-codiepie,.fa.fa-fort-awesome,.fa.fa-mixcloud,.fa.fa-modx,.fa.fa-product-hunt,.fa.fa-scribd,.fa.fa-usb{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-pause-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-pause-circle-o:before{content:"\f28b"}.fa.fa-stop-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-stop-circle-o:before{content:"\f28d"}.fa.fa-bluetooth,.fa.fa-bluetooth-b,.fa.fa-envira,.fa.fa-gitlab,.fa.fa-wheelchair-alt,.fa.fa-wpbeginner,.fa.fa-wpforms{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-wheelchair-alt:before{content:"\f368"}.fa.fa-question-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-question-circle-o:before{content:"\f059"}.fa.fa-volume-control-phone:before{content:"\f2a0"}.fa.fa-asl-interpreting:before{content:"\f2a3"}.fa.fa-deafness:before,.fa.fa-hard-of-hearing:before{content:"\f2a4"}.fa.fa-glide,.fa.fa-glide-g{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-signing:before{content:"\f2a7"}.fa.fa-viadeo,.fa.fa-viadeo-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-viadeo-square:before{content:"\f2aa"}.fa.fa-snapchat,.fa.fa-snapchat-ghost{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-snapchat-ghost:before{content:"\f2ab"}.fa.fa-snapchat-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-snapchat-square:before{content:"\f2ad"}.fa.fa-first-order,.fa.fa-google-plus-official,.fa.fa-pied-piper,.fa.fa-themeisle,.fa.fa-yoast{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-google-plus-official:before{content:"\f2b3"}.fa.fa-google-plus-circle{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-google-plus-circle:before{content:"\f2b3"}.fa.fa-fa,.fa.fa-font-awesome{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-fa:before{content:"\f2b4"}.fa.fa-handshake-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-handshake-o:before{content:"\f2b5"}.fa.fa-envelope-open-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-envelope-open-o:before{content:"\f2b6"}.fa.fa-linode{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-address-book-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-address-book-o:before{content:"\f2b9"}.fa.fa-vcard:before{content:"\f2bb"}.fa.fa-address-card-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-address-card-o:before{content:"\f2bb"}.fa.fa-vcard-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-vcard-o:before{content:"\f2bb"}.fa.fa-user-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-user-circle-o:before{content:"\f2bd"}.fa.fa-user-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-user-o:before{content:"\f007"}.fa.fa-id-badge{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-drivers-license:before{content:"\f2c2"}.fa.fa-id-card-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-id-card-o:before{content:"\f2c2"}.fa.fa-drivers-license-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-drivers-license-o:before{content:"\f2c2"}.fa.fa-free-code-camp,.fa.fa-quora,.fa.fa-telegram{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-thermometer-4:before,.fa.fa-thermometer:before{content:"\f2c7"}.fa.fa-thermometer-3:before{content:"\f2c8"}.fa.fa-thermometer-2:before{content:"\f2c9"}.fa.fa-thermometer-1:before{content:"\f2ca"}.fa.fa-thermometer-0:before{content:"\f2cb"}.fa.fa-bathtub:before,.fa.fa-s15:before{content:"\f2cd"}.fa.fa-window-maximize,.fa.fa-window-restore{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-times-rectangle:before{content:"\f410"}.fa.fa-window-close-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-window-close-o:before{content:"\f410"}.fa.fa-times-rectangle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-times-rectangle-o:before{content:"\f410"}.fa.fa-bandcamp,.fa.fa-eercast,.fa.fa-etsy,.fa.fa-grav,.fa.fa-imdb,.fa.fa-ravelry{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-eercast:before{content:"\f2da"}.fa.fa-snowflake-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-snowflake-o:before{content:"\f2dc"}.fa.fa-meetup,.fa.fa-superpowers,.fa.fa-wpexplorer{font-family:"Font Awesome 6 Brands";font-weight:400} \ No newline at end of file diff --git a/flask_appbuilder/static/appbuilder/fonts/FontAwesome.otf b/flask_appbuilder/static/appbuilder/fonts/FontAwesome.otf deleted file mode 100644 index 401ec0f36e..0000000000 Binary files a/flask_appbuilder/static/appbuilder/fonts/FontAwesome.otf and /dev/null differ diff --git a/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.eot b/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.eot deleted file mode 100755 index e9f60ca953..0000000000 Binary files a/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.eot and /dev/null differ diff --git a/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.svg b/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.svg deleted file mode 100755 index 855c845e53..0000000000 --- a/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.svg +++ /dev/null @@ -1,2671 +0,0 @@ - - - - -Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 - By ,,, -Copyright Dave Gandy 2016. All rights reserved. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.ttf b/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.ttf deleted file mode 100755 index 35acda2fa1..0000000000 Binary files a/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.ttf and /dev/null differ diff --git a/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.woff b/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.woff deleted file mode 100755 index 400014a4b0..0000000000 Binary files a/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.woff and /dev/null differ diff --git a/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.woff2 b/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.woff2 deleted file mode 100644 index 4d13fc6040..0000000000 Binary files a/flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.woff2 and /dev/null differ diff --git a/flask_appbuilder/static/appbuilder/js/ab.js b/flask_appbuilder/static/appbuilder/js/ab.js index 3f55266f46..41416f490c 100644 --- a/flask_appbuilder/static/appbuilder/js/ab.js +++ b/flask_appbuilder/static/appbuilder/js/ab.js @@ -17,9 +17,11 @@ function loadSelectDataSlave(elem) { elem.select2({data: {id: "",text: ""}, placeholder: "Select", allowClear: true}); } $('#' + master_id).on("change", function(e) { + var change_master_id = elem.attr('master_id'); + var change_master_val = $('#' + master_id).val(); var endpoint = elem.attr('endpoint'); - if (e.val) { - endpoint = endpoint.replace("{{ID}}", e.val); + if (change_master_val) { + endpoint = endpoint.replace("{{ID}}", change_master_val); $.get( endpoint, function( data ) { elem.select2({data: data, placeholder: "Select", allowClear: true}); }); @@ -47,10 +49,12 @@ function loadSelectData() { //--------------------------------------- $(function() { - $('.appbuilder_datetime').datetimepicker({pickTime: false}); + $('.appbuilder_datetime').datetimepicker(); $('.appbuilder_date').datetimepicker({ pickTime: false }); - $(".my_select2").select2({placeholder: "Select a State", allowClear: true}); + $(".my_select2").select2( + {placeholder: "Select a State", allowClear: true, theme: "bootstrap"} + ); $(".my_select2.readonly").select2("readonly", true); loadSelectData(); loadSelectDataSlave(); diff --git a/flask_appbuilder/static/appbuilder/js/ab_filters.js b/flask_appbuilder/static/appbuilder/js/ab_filters.js index 47fa0bd2df..c10a745b69 100644 --- a/flask_appbuilder/static/appbuilder/js/ab_filters.js +++ b/flask_appbuilder/static/appbuilder/js/ab_filters.js @@ -1,13 +1,13 @@ -var AdminFilters = function(element, labels, form, filters, active_filters) { +var AdminFilters = function (element, labels, form, filters, active_filters) { // Admin filters will deal with the adding and removing of search filters // :param labels: // {'col','label'} // :param active_filters: // [['col','filter name','value'],[...],...] - - var $root = $(element); - var $container = $('.filters', $root); - var lastCount = 0; + + let $root = $(element); + let $container = $('.filters', $root); + let lastCount = 0; function removeFilter() { $(this).closest('tr').remove(); @@ -16,9 +16,8 @@ var AdminFilters = function(element, labels, form, filters, active_filters) { return false; } - function addActiveFilters() - { - $(active_filters).each(function() { + function addActiveFilters() { + $(active_filters).each(function () { if (Array.isArray(this[2])) { // Multiple values applied for the same filter. for (var i = 0; i < this[2].length; i++) { @@ -30,24 +29,22 @@ var AdminFilters = function(element, labels, form, filters, active_filters) { }); } - function addActiveFilter(name, filter_name, value) - { - var $el = $('').appendTo($container); + function addActiveFilter(name, filter_name, value) { + let $el = $('').appendTo($container); + + addRemoveFilter($el, name, labels[name]); + let i_option = addFilterOptionsValue($el, name, filter_name, false); - addRemoveFilter($el, name, labels[name]); - var i_option = addFilterOptionsValue($el, name, filter_name); - - var $field = $(form[name]); + let $field = $(form[name]); // if form item complex like
, datetime - if ( $("input", $($field)).html() != undefined ) { + if ($("input", $($field)).html() != undefined) { $field_inner = $("input", $field) $field_inner.attr('name', '_flt_' + i_option + '_' + name); $field_inner.val(value); $field_inner.attr('class', ' filter_val ' + $field_inner.attr('class')); - } - else { - if (($field.attr( 'type')) == 'checkbox') { - $field.attr( 'checked', true ); + } else { + if (($field.attr('type')) === 'checkbox') { + $field.attr('checked', true); } $field.attr('name', '_flt_' + i_option + '_' + name); $field.val(value); @@ -55,100 +52,102 @@ var AdminFilters = function(element, labels, form, filters, active_filters) { } $el.append( $('').append($field) - ); + ); } - function addRemoveFilter($el, name, label) - { - $el.append( - $('').append( + function addRemoveFilter($el, name, label) { + $el.append( + $('').append( $('') .append($('×')) .append(' ') .append(label) .on('click', removeFilter) - ) - ); - } - - function addFilterOptionsValue($el, name, value) - { - var $select = $(''); + let cx = 0; + let i_option = -1; + $(filters[name]).each(function () { if (value == this) { $select.append($('
  • "),f=a("
  • "),g=d?e:f,h=this.id(c),i=this.getVal();j=this.opts.formatSelection(c,g.find("div"),this.opts.escapeMarkup),j!=b&&g.find("div").replaceWith(a("
    ").html(j)),k=this.opts.formatSelectionCssClass(c,g.find("div")),k!=b&&g.addClass(k),d&&g.find(".select2-search-choice-close").on("mousedown",A).on("click dblclick",this.bind(function(b){this.isInterfaceEnabled()&&(this.unselect(a(b.target)),this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"),A(b),this.close(),this.focusSearch())})).on("focus",this.bind(function(){this.isInterfaceEnabled()&&(this.container.addClass("select2-container-active"),this.dropdown.addClass("select2-drop-active"))})),g.data("select2-data",c),g.insertBefore(this.searchContainer),i.push(h),this.setVal(i)},unselect:function(b){var d,e,c=this.getVal();if(b=b.closest(".select2-search-choice"),0===b.length)throw"Invalid argument: "+b+". Must be .select2-search-choice";if(d=b.data("select2-data")){var f=a.Event("select2-removing");if(f.val=this.id(d),f.choice=d,this.opts.element.trigger(f),f.isDefaultPrevented())return!1;for(;(e=p(this.id(d),c))>=0;)c.splice(e,1),this.setVal(c),this.select&&this.postprocessResults();return b.remove(),this.opts.element.trigger({type:"select2-removed",val:this.id(d),choice:d}),this.triggerChange({removed:d}),!0}},postprocessResults:function(a,b,c){var d=this.getVal(),e=this.results.find(".select2-result"),f=this.results.find(".select2-result-with-children"),g=this;e.each2(function(a,b){var c=g.id(b.data("select2-data"));p(c,d)>=0&&(b.addClass("select2-selected"),b.find(".select2-result-selectable").addClass("select2-selected"))}),f.each2(function(a,b){b.is(".select2-result-selectable")||0!==b.find(".select2-result-selectable:not(.select2-selected)").length||b.addClass("select2-selected")}),-1==this.highlight()&&c!==!1&&this.opts.closeOnSelect===!0&&g.highlight(0),!this.opts.createSearchChoice&&!e.filter(".select2-result:not(.select2-selected)").length>0&&(!a||a&&!a.more&&0===this.results.find(".select2-no-results").length)&&J(g.opts.formatNoMatches,"formatNoMatches")&&this.results.append("
  • "+K(g.opts.formatNoMatches,g.opts.element,g.search.val())+"
  • ")},getMaxSearchWidth:function(){return this.selection.width()-t(this.search)},resizeSearch:function(){var a,b,c,d,e,f=t(this.search);a=C(this.search)+10,b=this.search.offset().left,c=this.selection.width(),d=this.selection.offset().left,e=c-(b-d)-f,a>e&&(e=c-f),40>e&&(e=c-f),0>=e&&(e=a),this.search.width(Math.floor(e))},getVal:function(){var a;return this.select?(a=this.select.val(),null===a?[]:a):(a=this.opts.element.val(),s(a,this.opts.separator,this.opts.transformVal))},setVal:function(b){var c;this.select?this.select.val(b):(c=[],a(b).each(function(){p(this,c)<0&&c.push(this)}),this.opts.element.val(0===c.length?"":c.join(this.opts.separator)))},buildChangeDetails:function(a,b){for(var b=b.slice(0),a=a.slice(0),c=0;c0&&c--,a.splice(d,1),d--);return{added:b,removed:a}},val:function(c,d){var e,f=this;if(0===arguments.length)return this.getVal();if(e=this.data(),e.length||(e=[]),!c&&0!==c)return this.opts.element.val(""),this.updateSelection([]),this.clearSearch(),d&&this.triggerChange({added:this.data(),removed:e}),void 0;if(this.setVal(c),this.select)this.opts.initSelection(this.select,this.bind(this.updateSelection)),d&&this.triggerChange(this.buildChangeDetails(e,this.data()));else{if(this.opts.initSelection===b)throw new Error("val() cannot be called if initSelection() is not defined");this.opts.initSelection(this.opts.element,function(b){var c=a.map(b,f.id);f.setVal(c),f.updateSelection(b),f.clearSearch(),d&&f.triggerChange(f.buildChangeDetails(e,f.data()))})}this.clearSearch()},onSortStart:function(){if(this.select)throw new Error("Sorting of elements is not supported when attached to instead.");this.search.width(0),this.searchContainer.hide()},onSortEnd:function(){var b=[],c=this;this.searchContainer.show(),this.searchContainer.appendTo(this.searchContainer.parent()),this.resizeSearch(),this.selection.find(".select2-search-choice").each(function(){b.push(c.opts.id(a(this).data("select2-data")))}),this.setVal(b),this.triggerChange()},data:function(b,c){var e,f,d=this;return 0===arguments.length?this.selection.children(".select2-search-choice").map(function(){return a(this).data("select2-data")}).get():(f=this.data(),b||(b=[]),e=a.map(b,function(a){return d.opts.id(a)}),this.setVal(e),this.updateSelection(b),this.clearSearch(),c&&this.triggerChange(this.buildChangeDetails(f,this.data())),void 0)}}),a.fn.select2=function(){var d,e,f,g,h,c=Array.prototype.slice.call(arguments,0),i=["val","destroy","opened","open","close","focus","isFocused","container","dropdown","onSortStart","onSortEnd","enable","disable","readonly","positionDropdown","data","search"],j=["opened","isFocused","container","dropdown"],k=["val","data"],l={search:"externalSearch"};return this.each(function(){if(0===c.length||"object"==typeof c[0])d=0===c.length?{}:a.extend({},c[0]),d.element=a(this),"select"===d.element.get(0).tagName.toLowerCase()?h=d.element.prop("multiple"):(h=d.multiple||!1,"tags"in d&&(d.multiple=h=!0)),e=h?new window.Select2["class"].multi:new window.Select2["class"].single,e.init(d);else{if("string"!=typeof c[0])throw"Invalid arguments to select2 plugin: "+c;if(p(c[0],i)<0)throw"Unknown method: "+c[0];if(g=b,e=a(this).data("select2"),e===b)return;if(f=c[0],"container"===f?g=e.container:"dropdown"===f?g=e.dropdown:(l[f]&&(f=l[f]),g=e[f].apply(e,c.slice(1))),p(c[0],j)>=0||p(c[0],k)>=0&&1==c.length)return!1}}),g===b?this:g},a.fn.select2.defaults={width:"copy",loadMorePadding:0,closeOnSelect:!0,openOnEnter:!0,containerCss:{},dropdownCss:{},containerCssClass:"",dropdownCssClass:"",formatResult:function(a,b,c,d){var e=[];return E(this.text(a),c.term,e,d),e.join("")},transformVal:function(b){return a.trim(b)},formatSelection:function(a,c,d){return a?d(this.text(a)):b},sortResults:function(a){return a},formatResultCssClass:function(a){return a.css},formatSelectionCssClass:function(){return b},minimumResultsForSearch:0,minimumInputLength:0,maximumInputLength:null,maximumSelectionSize:0,id:function(a){return a==b?null:a.id},text:function(b){return b&&this.data&&this.data.text?a.isFunction(this.data.text)?this.data.text(b):b[this.data.text]:b.text - },matcher:function(a,b){return o(""+b).toUpperCase().indexOf(o(""+a).toUpperCase())>=0},separator:",",tokenSeparators:[],tokenizer:M,escapeMarkup:F,blurOnChange:!1,selectOnBlur:!1,adaptContainerCssClass:function(a){return a},adaptDropdownCssClass:function(){return null},nextSearchTerm:function(){return b},searchInputPlaceholder:"",createSearchChoicePosition:"top",shouldFocusInput:function(a){var b="ontouchstart"in window||navigator.msMaxTouchPoints>0;return b?a.opts.minimumResultsForSearch<0?!1:!0:!0}},a.fn.select2.locales=[],a.fn.select2.locales.en={formatMatches:function(a){return 1===a?"One result is available, press enter to select it.":a+" results are available, use up and down arrow keys to navigate."},formatNoMatches:function(){return"No matches found"},formatAjaxError:function(){return"Loading failed"},formatInputTooShort:function(a,b){var c=b-a.length;return"Please enter "+c+" or more character"+(1==c?"":"s")},formatInputTooLong:function(a,b){var c=a.length-b;return"Please delete "+c+" character"+(1==c?"":"s")},formatSelectionTooBig:function(a){return"You can only select "+a+" item"+(1==a?"":"s")},formatLoadMore:function(){return"Loading more results\u2026"},formatSearching:function(){return"Searching\u2026"}},a.extend(a.fn.select2.defaults,a.fn.select2.locales.en),a.fn.select2.ajaxDefaults={transport:a.ajax,params:{type:"GET",cache:!1,dataType:"json"}},window.Select2={query:{ajax:G,local:H,tags:I},util:{debounce:w,markMatch:E,escapeMarkup:F,stripDiacritics:o},"class":{"abstract":c,single:d,multi:e}}}}(jQuery); \ No newline at end of file +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ +!function(n){"function"==typeof define&&define.amd?define(["jquery"],n):"object"==typeof module&&module.exports?module.exports=function(e,t){return void 0===t&&(t="undefined"!=typeof window?require("jquery"):require("jquery")(e)),n(t),t}:n(jQuery)}(function(d){var e=function(){if(d&&d.fn&&d.fn.select2&&d.fn.select2.amd)var e=d.fn.select2.amd;var t,n,i,h,o,s,f,g,m,v,y,_,r,a,w,l;function b(e,t){return r.call(e,t)}function c(e,t){var n,i,r,o,s,a,l,c,u,d,p,h=t&&t.split("/"),f=y.map,g=f&&f["*"]||{};if(e){for(s=(e=e.split("/")).length-1,y.nodeIdCompat&&w.test(e[s])&&(e[s]=e[s].replace(w,"")),"."===e[0].charAt(0)&&h&&(e=h.slice(0,h.length-1).concat(e)),u=0;u":">",'"':""","'":"'","/":"/"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},r.appendMany=function(e,t){if("1.7"===o.fn.jquery.substr(0,3)){var n=o();o.map(t,function(e){n=n.add(e)}),t=n}e.append(t)},r.__cache={};var n=0;return r.GetUniqueElementId=function(e){var t=e.getAttribute("data-select2-id");return null==t&&(e.id?(t=e.id,e.setAttribute("data-select2-id",t)):(e.setAttribute("data-select2-id",++n),t=n.toString())),t},r.StoreData=function(e,t,n){var i=r.GetUniqueElementId(e);r.__cache[i]||(r.__cache[i]={}),r.__cache[i][t]=n},r.GetData=function(e,t){var n=r.GetUniqueElementId(e);return t?r.__cache[n]&&null!=r.__cache[n][t]?r.__cache[n][t]:o(e).data(t):r.__cache[n]},r.RemoveData=function(e){var t=r.GetUniqueElementId(e);null!=r.__cache[t]&&delete r.__cache[t],e.removeAttribute("data-select2-id")},r}),e.define("select2/results",["jquery","./utils"],function(h,f){function i(e,t,n){this.$element=e,this.data=n,this.options=t,i.__super__.constructor.call(this)}return f.Extend(i,f.Observable),i.prototype.render=function(){var e=h('
      ');return this.options.get("multiple")&&e.attr("aria-multiselectable","true"),this.$results=e},i.prototype.clear=function(){this.$results.empty()},i.prototype.displayMessage=function(e){var t=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var n=h(''),i=this.options.get("translations").get(e.message);n.append(t(i(e.args))),n[0].className+=" select2-results__message",this.$results.append(n)},i.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},i.prototype.append=function(e){this.hideLoading();var t=[];if(null!=e.results&&0!==e.results.length){e.results=this.sort(e.results);for(var n=0;n",{class:"select2-results__options select2-results__options--nested"});p.append(l),s.append(a),s.append(p)}else this.template(e,t);return f.StoreData(t,"data",e),t},i.prototype.bind=function(t,e){var l=this,n=t.id+"-results";this.$results.attr("id",n),t.on("results:all",function(e){l.clear(),l.append(e.data),t.isOpen()&&(l.setClasses(),l.highlightFirstItem())}),t.on("results:append",function(e){l.append(e.data),t.isOpen()&&l.setClasses()}),t.on("query",function(e){l.hideMessages(),l.showLoading(e)}),t.on("select",function(){t.isOpen()&&(l.setClasses(),l.options.get("scrollAfterSelect")&&l.highlightFirstItem())}),t.on("unselect",function(){t.isOpen()&&(l.setClasses(),l.options.get("scrollAfterSelect")&&l.highlightFirstItem())}),t.on("open",function(){l.$results.attr("aria-expanded","true"),l.$results.attr("aria-hidden","false"),l.setClasses(),l.ensureHighlightVisible()}),t.on("close",function(){l.$results.attr("aria-expanded","false"),l.$results.attr("aria-hidden","true"),l.$results.removeAttr("aria-activedescendant")}),t.on("results:toggle",function(){var e=l.getHighlightedResults();0!==e.length&&e.trigger("mouseup")}),t.on("results:select",function(){var e=l.getHighlightedResults();if(0!==e.length){var t=f.GetData(e[0],"data");"true"==e.attr("aria-selected")?l.trigger("close",{}):l.trigger("select",{data:t})}}),t.on("results:previous",function(){var e=l.getHighlightedResults(),t=l.$results.find("[aria-selected]"),n=t.index(e);if(!(n<=0)){var i=n-1;0===e.length&&(i=0);var r=t.eq(i);r.trigger("mouseenter");var o=l.$results.offset().top,s=r.offset().top,a=l.$results.scrollTop()+(s-o);0===i?l.$results.scrollTop(0):s-o<0&&l.$results.scrollTop(a)}}),t.on("results:next",function(){var e=l.getHighlightedResults(),t=l.$results.find("[aria-selected]"),n=t.index(e)+1;if(!(n>=t.length)){var i=t.eq(n);i.trigger("mouseenter");var r=l.$results.offset().top+l.$results.outerHeight(!1),o=i.offset().top+i.outerHeight(!1),s=l.$results.scrollTop()+o-r;0===n?l.$results.scrollTop(0):rthis.$results.outerHeight()||o<0)&&this.$results.scrollTop(r)}},i.prototype.template=function(e,t){var n=this.options.get("templateResult"),i=this.options.get("escapeMarkup"),r=n(e,t);null==r?t.style.display="none":"string"==typeof r?t.innerHTML=i(r):h(t).append(r)},i}),e.define("select2/keys",[],function(){return{BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46}}),e.define("select2/selection/base",["jquery","../utils","../keys"],function(n,i,r){function o(e,t){this.$element=e,this.options=t,o.__super__.constructor.call(this)}return i.Extend(o,i.Observable),o.prototype.render=function(){var e=n('');return this._tabindex=0,null!=i.GetData(this.$element[0],"old-tabindex")?this._tabindex=i.GetData(this.$element[0],"old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),e.attr("title",this.$element.attr("title")),e.attr("tabindex",this._tabindex),e.attr("aria-disabled","false"),this.$selection=e},o.prototype.bind=function(e,t){var n=this,i=e.id+"-results";this.container=e,this.$selection.on("focus",function(e){n.trigger("focus",e)}),this.$selection.on("blur",function(e){n._handleBlur(e)}),this.$selection.on("keydown",function(e){n.trigger("keypress",e),e.which===r.SPACE&&e.preventDefault()}),e.on("results:focus",function(e){n.$selection.attr("aria-activedescendant",e.data._resultId)}),e.on("selection:update",function(e){n.update(e.data)}),e.on("open",function(){n.$selection.attr("aria-expanded","true"),n.$selection.attr("aria-owns",i),n._attachCloseHandler(e)}),e.on("close",function(){n.$selection.attr("aria-expanded","false"),n.$selection.removeAttr("aria-activedescendant"),n.$selection.removeAttr("aria-owns"),n.$selection.trigger("focus"),n._detachCloseHandler(e)}),e.on("enable",function(){n.$selection.attr("tabindex",n._tabindex),n.$selection.attr("aria-disabled","false")}),e.on("disable",function(){n.$selection.attr("tabindex","-1"),n.$selection.attr("aria-disabled","true")})},o.prototype._handleBlur=function(e){var t=this;window.setTimeout(function(){document.activeElement==t.$selection[0]||n.contains(t.$selection[0],document.activeElement)||t.trigger("blur",e)},1)},o.prototype._attachCloseHandler=function(e){n(document.body).on("mousedown.select2."+e.id,function(e){var t=n(e.target).closest(".select2");n(".select2.select2-container--open").each(function(){this!=t[0]&&i.GetData(this,"element").select2("close")})})},o.prototype._detachCloseHandler=function(e){n(document.body).off("mousedown.select2."+e.id)},o.prototype.position=function(e,t){t.find(".selection").append(e)},o.prototype.destroy=function(){this._detachCloseHandler(this.container)},o.prototype.update=function(e){throw new Error("The `update` method must be defined in child classes.")},o.prototype.isEnabled=function(){return!this.isDisabled()},o.prototype.isDisabled=function(){return this.options.get("disabled")},o}),e.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(e,t,n,i){function r(){r.__super__.constructor.apply(this,arguments)}return n.Extend(r,t),r.prototype.render=function(){var e=r.__super__.render.call(this);return e.addClass("select2-selection--single"),e.html(''),e},r.prototype.bind=function(t,e){var n=this;r.__super__.bind.apply(this,arguments);var i=t.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",i).attr("role","textbox").attr("aria-readonly","true"),this.$selection.attr("aria-labelledby",i),this.$selection.on("mousedown",function(e){1===e.which&&n.trigger("toggle",{originalEvent:e})}),this.$selection.on("focus",function(e){}),this.$selection.on("blur",function(e){}),t.on("focus",function(e){t.isOpen()||n.$selection.trigger("focus")})},r.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},r.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},r.prototype.selectionContainer=function(){return e("")},r.prototype.update=function(e){if(0!==e.length){var t=e[0],n=this.$selection.find(".select2-selection__rendered"),i=this.display(t,n);n.empty().append(i);var r=t.title||t.text;r?n.attr("title",r):n.removeAttr("title")}else this.clear()},r}),e.define("select2/selection/multiple",["jquery","./base","../utils"],function(r,e,l){function n(e,t){n.__super__.constructor.apply(this,arguments)}return l.Extend(n,e),n.prototype.render=function(){var e=n.__super__.render.call(this);return e.addClass("select2-selection--multiple"),e.html('
        '),e},n.prototype.bind=function(e,t){var i=this;n.__super__.bind.apply(this,arguments),this.$selection.on("click",function(e){i.trigger("toggle",{originalEvent:e})}),this.$selection.on("click",".select2-selection__choice__remove",function(e){if(!i.isDisabled()){var t=r(this).parent(),n=l.GetData(t[0],"data");i.trigger("unselect",{originalEvent:e,data:n})}})},n.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},n.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},n.prototype.selectionContainer=function(){return r('
      • ×
      • ')},n.prototype.update=function(e){if(this.clear(),0!==e.length){for(var t=[],n=0;n×');a.StoreData(i[0],"data",t),this.$selection.find(".select2-selection__rendered").prepend(i)}},e}),e.define("select2/selection/search",["jquery","../utils","../keys"],function(i,a,l){function e(e,t,n){e.call(this,t,n)}return e.prototype.render=function(e){var t=i('');this.$searchContainer=t,this.$search=t.find("input");var n=e.call(this);return this._transferTabIndex(),n},e.prototype.bind=function(e,t,n){var i=this,r=t.id+"-results";e.call(this,t,n),t.on("open",function(){i.$search.attr("aria-controls",r),i.$search.trigger("focus")}),t.on("close",function(){i.$search.val(""),i.$search.removeAttr("aria-controls"),i.$search.removeAttr("aria-activedescendant"),i.$search.trigger("focus")}),t.on("enable",function(){i.$search.prop("disabled",!1),i._transferTabIndex()}),t.on("disable",function(){i.$search.prop("disabled",!0)}),t.on("focus",function(e){i.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?i.$search.attr("aria-activedescendant",e.data._resultId):i.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){i.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){i._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){if(e.stopPropagation(),i.trigger("keypress",e),i._keyUpPrevented=e.isDefaultPrevented(),e.which===l.BACKSPACE&&""===i.$search.val()){var t=i.$searchContainer.prev(".select2-selection__choice");if(0this.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),e.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var i=this;e.call(this,t,n),t.on("select",function(){i._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var i=this;this._checkIfMaximumSelected(function(){e.call(i,t,n)})},e.prototype._checkIfMaximumSelected=function(e,n){var i=this;this.current(function(e){var t=null!=e?e.length:0;0=i.maximumSelectionLength?i.trigger("results:message",{message:"maximumSelected",args:{maximum:i.maximumSelectionLength}}):n&&n()})},e}),e.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),e.define("select2/dropdown/search",["jquery","../utils"],function(o,e){function t(){}return t.prototype.render=function(e){var t=e.call(this),n=o('');return this.$searchContainer=n,this.$search=n.find("input"),t.prepend(n),t},t.prototype.bind=function(e,t,n){var i=this,r=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){i.trigger("keypress",e),i._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){o(this).off("keyup")}),this.$search.on("keyup input",function(e){i.handleSearch(e)}),t.on("open",function(){i.$search.attr("tabindex",0),i.$search.attr("aria-controls",r),i.$search.trigger("focus"),window.setTimeout(function(){i.$search.trigger("focus")},0)}),t.on("close",function(){i.$search.attr("tabindex",-1),i.$search.removeAttr("aria-controls"),i.$search.removeAttr("aria-activedescendant"),i.$search.val(""),i.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||i.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(i.showSearch(e)?i.$searchContainer.removeClass("select2-search--hide"):i.$searchContainer.addClass("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?i.$search.attr("aria-activedescendant",e.data._resultId):i.$search.removeAttr("aria-activedescendant")})},t.prototype.handleSearch=function(e){if(!this._keyUpPrevented){var t=this.$search.val();this.trigger("query",{term:t})}this._keyUpPrevented=!1},t.prototype.showSearch=function(e,t){return!0},t}),e.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,i){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,i)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),i=t.length-1;0<=i;i--){var r=t[i];this.placeholder.id===r.id&&n.splice(i,1)}return n},e}),e.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,i){this.lastParams={},e.call(this,t,n,i),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var i=this;e.call(this,t,n),t.on("query",function(e){i.lastParams=e,i.loading=!0}),t.on("query:append",function(e){i.lastParams=e,i.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);if(!this.loading&&e){var t=this.$results.offset().top+this.$results.outerHeight(!1);this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=t+50&&this.loadMore()}},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
      • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),e.define("select2/dropdown/attachBody",["jquery","../utils"],function(f,a){function e(e,t,n){this.$dropdownParent=f(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var i=this;e.call(this,t,n),t.on("open",function(){i._showDropdown(),i._attachPositioningHandler(t),i._bindContainerResultHandlers(t)}),t.on("close",function(){i._hideDropdown(),i._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t.removeClass("select2"),t.addClass("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=f(""),n=e.call(this);return t.append(n),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){if(!this._containerResultsHandlersBound){var n=this;t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0}},e.prototype._attachPositioningHandler=function(e,t){var n=this,i="scroll.select2."+t.id,r="resize.select2."+t.id,o="orientationchange.select2."+t.id,s=this.$container.parents().filter(a.hasScroll);s.each(function(){a.StoreData(this,"select2-scroll-position",{x:f(this).scrollLeft(),y:f(this).scrollTop()})}),s.on(i,function(e){var t=a.GetData(this,"select2-scroll-position");f(this).scrollTop(t.y)}),f(window).on(i+" "+r+" "+o,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,i="resize.select2."+t.id,r="orientationchange.select2."+t.id;this.$container.parents().filter(a.hasScroll).off(n),f(window).off(n+" "+i+" "+r)},e.prototype._positionDropdown=function(){var e=f(window),t=this.$dropdown.hasClass("select2-dropdown--above"),n=this.$dropdown.hasClass("select2-dropdown--below"),i=null,r=this.$container.offset();r.bottom=r.top+this.$container.outerHeight(!1);var o={height:this.$container.outerHeight(!1)};o.top=r.top,o.bottom=r.top+o.height;var s=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ar.bottom+s,d={left:r.left,top:o.bottom},p=this.$dropdownParent;"static"===p.css("position")&&(p=p.offsetParent());var h={top:0,left:0};(f.contains(document.body,p[0])||p[0].isConnected)&&(h=p.offset()),d.top-=h.top,d.left-=h.left,t||n||(i="below"),u||!c||t?!c&&u&&t&&(i="below"):i="above",("above"==i||t&&"below"!==i)&&(d.top=o.top-h.top-s),null!=i&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+i),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+i)),this.$dropdownContainer.css(d)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),e.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,i){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,i)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,i=0;i');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container.addClass("select2-container--"+this.options.get("theme")),u.StoreData(e[0],"element",this.$element),e},d}),e.define("select2/compat/utils",["jquery"],function(s){return{syncCssClasses:function(e,t,n){var i,r,o=[];(i=s.trim(e.attr("class")))&&s((i=""+i).split(/\s+/)).each(function(){0===this.indexOf("select2-")&&o.push(this)}),(i=s.trim(t.attr("class")))&&s((i=""+i).split(/\s+/)).each(function(){0!==this.indexOf("select2-")&&null!=(r=n(this))&&o.push(r)}),e.attr("class",o.join(" "))}}}),e.define("select2/compat/containerCss",["jquery","./utils"],function(s,a){function l(e){return null}function e(){}return e.prototype.render=function(e){var t=e.call(this),n=this.options.get("containerCssClass")||"";s.isFunction(n)&&(n=n(this.$element));var i=this.options.get("adaptContainerCssClass");if(i=i||l,-1!==n.indexOf(":all:")){n=n.replace(":all:","");var r=i;i=function(e){var t=r(e);return null!=t?t+" "+e:e}}var o=this.options.get("containerCss")||{};return s.isFunction(o)&&(o=o(this.$element)),a.syncCssClasses(t,this.$element,i),t.css(o),t.addClass(n),t},e}),e.define("select2/compat/dropdownCss",["jquery","./utils"],function(s,a){function l(e){return null}function e(){}return e.prototype.render=function(e){var t=e.call(this),n=this.options.get("dropdownCssClass")||"";s.isFunction(n)&&(n=n(this.$element));var i=this.options.get("adaptDropdownCssClass");if(i=i||l,-1!==n.indexOf(":all:")){n=n.replace(":all:","");var r=i;i=function(e){var t=r(e);return null!=t?t+" "+e:e}}var o=this.options.get("dropdownCss")||{};return s.isFunction(o)&&(o=o(this.$element)),a.syncCssClasses(t,this.$element,i),t.css(o),t.addClass(n),t},e}),e.define("select2/compat/initSelection",["jquery"],function(i){function e(e,t,n){n.get("debug")&&window.console&&console.warn&&console.warn("Select2: The `initSelection` option has been deprecated in favor of a custom data adapter that overrides the `current` method. This method is now called multiple times instead of a single time when the instance is initialized. Support will be removed for the `initSelection` option in future versions of Select2"),this.initSelection=n.get("initSelection"),this._isInitialized=!1,e.call(this,t,n)}return e.prototype.current=function(e,t){var n=this;this._isInitialized?e.call(this,t):this.initSelection.call(null,this.$element,function(e){n._isInitialized=!0,i.isArray(e)||(e=[e]),t(e)})},e}),e.define("select2/compat/inputData",["jquery","../utils"],function(s,i){function e(e,t,n){this._currentData=[],this._valueSeparator=n.get("valueSeparator")||",","hidden"===t.prop("type")&&n.get("debug")&&console&&console.warn&&console.warn("Select2: Using a hidden input with Select2 is no longer supported and may stop working in the future. It is recommended to use a ` - - - {% else %} - onclick="var a = new AdminActions(); return a.execute_single('{{path}}',false);"> - {% endif %} + - {{_(action.text)}} - + {{_(action.text)}} + + + {% endif %} {% endfor %} {% endmacro %} @@ -66,7 +79,7 @@ {% for action_key in actions %} {% set action = actions.get(action_key) %}
      • - {{ _(action.text) }} @@ -74,7 +87,7 @@
      • {% endfor %} - {% endmacro %} diff --git a/flask_appbuilder/templates/appbuilder/general/model/edit.html b/flask_appbuilder/templates/appbuilder/general/model/edit.html index e1764a2e0d..e0f04fc3f0 100644 --- a/flask_appbuilder/templates/appbuilder/general/model/edit.html +++ b/flask_appbuilder/templates/appbuilder/general/model/edit.html @@ -32,5 +32,5 @@ {% endblock %} {% block add_tail_js %} - + {% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/model/edit_cascade.html b/flask_appbuilder/templates/appbuilder/general/model/edit_cascade.html index cf4607c0e0..7b7e3fec63 100644 --- a/flask_appbuilder/templates/appbuilder/general/model/edit_cascade.html +++ b/flask_appbuilder/templates/appbuilder/general/model/edit_cascade.html @@ -25,5 +25,5 @@ {% endblock %} {% block add_tail_js %} - + {% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/model/search.html b/flask_appbuilder/templates/appbuilder/general/model/search.html index 60b8fe7212..d52f89fc5c 100644 --- a/flask_appbuilder/templates/appbuilder/general/model/search.html +++ b/flask_appbuilder/templates/appbuilder/general/model/search.html @@ -1,3 +1,5 @@ +{% import 'appbuilder/baselib.html' as baselib %} + - - diff --git a/flask_appbuilder/templates/appbuilder/general/model/show.html b/flask_appbuilder/templates/appbuilder/general/model/show.html index a4cd438681..8c2510ca30 100644 --- a/flask_appbuilder/templates/appbuilder/general/model/show.html +++ b/flask_appbuilder/templates/appbuilder/general/model/show.html @@ -34,5 +34,5 @@ {% endblock content %} {% block add_tail_js %} - + {% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/model/show_cascade.html b/flask_appbuilder/templates/appbuilder/general/model/show_cascade.html index 7ba5e4b530..087e7fe2ce 100644 --- a/flask_appbuilder/templates/appbuilder/general/model/show_cascade.html +++ b/flask_appbuilder/templates/appbuilder/general/model/show_cascade.html @@ -24,5 +24,5 @@ {% endblock %} {% block add_tail_js %} - + {% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/security/login_db.html b/flask_appbuilder/templates/appbuilder/general/security/login_db.html index 8030370e5e..3546706c6d 100644 --- a/flask_appbuilder/templates/appbuilder/general/security/login_db.html +++ b/flask_appbuilder/templates/appbuilder/general/security/login_db.html @@ -17,7 +17,7 @@ {{form.hidden_tag()}}
        {{_("Enter your login and password below")}}:
        - +
        @@ -27,7 +27,7 @@ {% for error in form.errors.get('openid', []) %} [{{error}}]
        {% endfor %} - +
        @@ -38,7 +38,7 @@ {% endfor %}
        - +

        @@ -54,7 +54,7 @@
        - +
        diff --git a/flask_appbuilder/templates/appbuilder/general/security/login_ldap.html b/flask_appbuilder/templates/appbuilder/general/security/login_ldap.html index 4f22da3a17..bb32cb3aa7 100644 --- a/flask_appbuilder/templates/appbuilder/general/security/login_ldap.html +++ b/flask_appbuilder/templates/appbuilder/general/security/login_ldap.html @@ -15,7 +15,7 @@ {{form.hidden_tag()}}
        {{_("Enter your login and password below")}}:
        - +
        @@ -24,7 +24,7 @@ {% for error in form.errors.get('openid', []) %} [{{error}}]
        {% endfor %} - +
        {{ form.password(size = 80, class = "form-control") }} diff --git a/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html b/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html index 21d8a6dd56..e1b79ad846 100644 --- a/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html +++ b/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html @@ -3,16 +3,6 @@ {% block content %} - -
        -{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/security/login_oid.html b/flask_appbuilder/templates/appbuilder/general/security/login_oid.html index 61f509622d..9e7a972808 100644 --- a/flask_appbuilder/templates/appbuilder/general/security/login_oid.html +++ b/flask_appbuilder/templates/appbuilder/general/security/login_oid.html @@ -2,128 +2,149 @@ {% extends "appbuilder/base.html" %} {% block content %} + - + function hideOpenId() { + $("#openid").addClass('hidden'); + $("#label-openid").addClass('hidden'); + $("#openid").val(''); + } + function showOpenId() { + $("#openid").removeClass('hidden'); + $("#label-openid").removeClass('hidden'); + } -
        -
        -
        -
        -
        {{ title }}
        -
        -
        + function set_openid(openid, pr) { + $('.img-select').attr('class', 'img-rounded img-unselect'); + $('#' + pr).attr('class', 'img-rounded img-select'); + if (openid == '') { + hideUsername(); + showOpenId(); + } else { + u = openid.search(''); + if (u != -1) { + showUsername(); + hideOpenId(); + } else { + hideUsername(); + hideOpenId(); + } + } + form = document.forms['login']; + form.elements['openid'].value = openid; + } -
        - {{form.hidden_tag()}} -
        {{_("Click on your OpenID provider below")}}:
        -
        -
        - {% for pr in providers %} - - - - {% endfor %} -
        -
        -
        - -
        - {{ form.openid(size = 80, class = "hidden form-control") }} - {% for error in form.errors.get('openid', []) %} - {{_('Please choose a provider')}}
        - {% endfor %} - - {{ form.username(size = 80, class = "hidden form-control", autofocus = true) }} -
        -
        -
        -
        - -
        -
        - - {% if appbuilder.sm.auth_user_registration %} - - {{_('Register')}} - - {% endif %} + function beforeSubmit() { + openid = $("#openid").val(); + u = openid.search(''); + if (u != -1) { + openid = openid.substr(0, u) + $("#username").val(); + } + } - -
        -
        + //--------------------------------- + // POST FORM to Register User View + //--------------------------------- + function registerUser() { + form = document.forms['login']; + if (form.elements['openid'].value == '') { + alert('Please choose a provider first'); + } else { + form.action = "{{appbuilder.sm.get_url_for_registeruser}}"; + form.submit(); + } + } + {% for pr in providers %} + document.getElementById("btn-oid-provider-{{ pr.name }}") + .addEventListener("click", function () { + set_openid("{{ pr.url | safe }}", "{{ pr.name }}"); + }); + {% endfor %} + document.getElementById("btn-oid-before-submit") + .addEventListener("click", function () { + beforeSubmit() + }); + {% if appbuilder.sm.auth_user_registration %} + document.getElementById("btn-oid-register-user") + .addEventListener("click", function () { + registerUser() + }); + {% endif %} + {% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/security/resetpassword.html b/flask_appbuilder/templates/appbuilder/general/security/resetpassword.html deleted file mode 100644 index 6ebf459ab7..0000000000 --- a/flask_appbuilder/templates/appbuilder/general/security/resetpassword.html +++ /dev/null @@ -1,29 +0,0 @@ - -{% extends "appbuilder/base.html" %} -{% import 'appbuilder/general/lib.html' as lib %} - -{% block content %} -{{ lib.render_title(title) }} - -
        - {{form.hidden_tag()}} - - {% for item in edit_columns %} - {% set field = form | get_attr(item) %} - - {{ lib.render_field(field) }} - - {% endfor %} -
        - - - -
        -{% endblock %} - - diff --git a/flask_appbuilder/templates/appbuilder/general/widgets/base_list.html b/flask_appbuilder/templates/appbuilder/general/widgets/base_list.html index 8c4d9904b3..c438b0a495 100644 --- a/flask_appbuilder/templates/appbuilder/general/widgets/base_list.html +++ b/flask_appbuilder/templates/appbuilder/general/widgets/base_list.html @@ -1,3 +1,4 @@ +{% import 'appbuilder/baselib.html' as baselib %} {% import 'appbuilder/general/lib.html' as lib %} {% set can_add = "can_add" | is_item_visible(modelview_name) %} @@ -28,7 +29,7 @@ {{ lib.action_form(actions, modelview_name) }} - - + - + - + diff --git a/flask_appbuilder/templates/appbuilder/init.html b/flask_appbuilder/templates/appbuilder/init.html index d08f87de15..62608c0f7a 100644 --- a/flask_appbuilder/templates/appbuilder/init.html +++ b/flask_appbuilder/templates/appbuilder/init.html @@ -17,20 +17,25 @@ {% block head_css %} - + + + + + {% if appbuilder.app_theme %} {% endif %} + {% endblock %} {% block head_js %} - - - + + + {% endblock %} @@ -38,10 +43,10 @@ {% endblock %} {% block tail_js %} - - - - + + + + {% endblock %} {% block add_tail_js %} diff --git a/flask_appbuilder/templates/appbuilder/navbar_menu.html b/flask_appbuilder/templates/appbuilder/navbar_menu.html index 4f20804bee..ab6aa1cd40 100644 --- a/flask_appbuilder/templates/appbuilder/navbar_menu.html +++ b/flask_appbuilder/templates/appbuilder/navbar_menu.html @@ -11,7 +11,7 @@ {% if item1 | is_menu_visible %} {% if item1.childs %}