aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.guix-channel53
-rw-r--r--.guix/modules/gn-auth.scm4
-rw-r--r--.pylintrc13
-rw-r--r--README.md6
-rw-r--r--gn_auth/__init__.py53
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/introspection.py2
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/revocation.py4
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/utilities.py8
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py69
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py10
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py27
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py29
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2token.py2
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py13
-rw-r--r--gn_auth/auth/authentication/oauth2/server.py101
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py24
-rw-r--r--gn_auth/auth/authentication/users.py6
-rw-r--r--gn_auth/auth/authorisation/checks.py2
-rw-r--r--gn_auth/auth/authorisation/data/genotypes.py99
-rw-r--r--gn_auth/auth/authorisation/data/mrna.py102
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py245
-rw-r--r--gn_auth/auth/authorisation/data/views.py319
-rw-r--r--gn_auth/auth/authorisation/privileges/models.py2
-rw-r--r--gn_auth/auth/authorisation/resources/base.py52
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py132
-rw-r--r--gn_auth/auth/authorisation/resources/common.py4
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/models.py11
-rw-r--r--gn_auth/auth/authorisation/resources/groups/data.py12
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py250
-rw-r--r--gn_auth/auth/authorisation/resources/groups/views.py175
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/models.py51
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/views.py55
-rw-r--r--gn_auth/auth/authorisation/resources/models.py251
-rw-r--r--gn_auth/auth/authorisation/resources/mrna.py9
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/models.py9
-rw-r--r--gn_auth/auth/authorisation/resources/system/models.py32
-rw-r--r--gn_auth/auth/authorisation/resources/system/views.py36
-rw-r--r--gn_auth/auth/authorisation/resources/views.py300
-rw-r--r--gn_auth/auth/authorisation/roles/models.py23
-rw-r--r--gn_auth/auth/authorisation/roles/views.py10
-rw-r--r--gn_auth/auth/authorisation/users/admin/models.py62
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py8
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py14
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py5
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/models.py2
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/views.py16
-rw-r--r--gn_auth/auth/authorisation/users/models.py129
-rw-r--r--gn_auth/auth/authorisation/users/views.py188
-rw-r--r--gn_auth/auth/db/mariadb.py45
-rw-r--r--gn_auth/auth/db/sqlite3.py61
-rw-r--r--gn_auth/auth/errors.py6
-rw-r--r--gn_auth/auth/requests.py8
-rw-r--r--gn_auth/auth/system/__init__.py5
-rw-r--r--gn_auth/auth/system/admin/resources.py71
-rw-r--r--gn_auth/auth/system/admin/users.py232
-rw-r--r--gn_auth/auth/system/admin/views.py10
-rw-r--r--gn_auth/auth/system/views.py8
-rw-r--r--gn_auth/auth/views.py6
-rw-r--r--gn_auth/debug.py22
-rw-r--r--gn_auth/errors.py69
-rw-r--r--gn_auth/errors/__init__.py48
-rw-r--r--gn_auth/errors/authlib.py34
-rw-r--r--gn_auth/errors/common.py58
-rw-r--r--gn_auth/errors/http/__init__.py13
-rw-r--r--gn_auth/errors/http/http_4xx_errors.py23
-rw-r--r--gn_auth/errors/http/http_5xx_errors.py7
-rw-r--r--gn_auth/hooks.py2
-rw-r--r--gn_auth/jobs.py2
-rw-r--r--gn_auth/migrations/__init__.py (renamed from gn_auth/migrations.py)3
-rw-r--r--gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py (renamed from migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py)0
-rw-r--r--gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py (renamed from migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py (renamed from migrations/auth/20221108_01_CoxYh-create-the-groups-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py (renamed from migrations/auth/20221108_02_wxTr9-create-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py (renamed from migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py (renamed from migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py (renamed from migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py (renamed from migrations/auth/20221110_01_WtZ1I-create-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py (renamed from migrations/auth/20221110_05_BaNtL-create-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py (renamed from migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py (renamed from migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py (renamed from migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py (renamed from migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py (renamed from migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py (renamed from migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py (renamed from migrations/auth/20221114_03_PtWjc-create-group-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py (renamed from migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py (renamed from migrations/auth/20221114_05_hQun6-create-user-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py (renamed from migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py)0
-rw-r--r--gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py (renamed from migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py)0
-rw-r--r--gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py (renamed from migrations/auth/20221117_02_fmuZh-create-group-users-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py (renamed from migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py (renamed from migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py (renamed from migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py (renamed from migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py (renamed from migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py (renamed from migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py)0
-rw-r--r--gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py (renamed from migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py)0
-rw-r--r--gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py (renamed from migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py (renamed from migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py)0
-rw-r--r--gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py (renamed from migrations/auth/20230210_02_lDK14-create-system-admin-role.py)0
-rw-r--r--gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py (renamed from migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py)0
-rw-r--r--gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py (renamed from migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py)0
-rw-r--r--gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py (renamed from migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py (renamed from migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py (renamed from migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py (renamed from migrations/auth/20230404_02_la33P-create-genotype-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py (renamed from migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py (renamed from migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py (renamed from migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py (renamed from migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py (renamed from migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py (renamed from migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py (renamed from migrations/auth/20230912_01_BxrhE-add-system-resource.py)0
-rw-r--r--gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py (renamed from migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py (renamed from migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py)0
-rw-r--r--gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py (renamed from migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py)0
-rw-r--r--gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py (renamed from migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py)0
-rw-r--r--gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py (renamed from migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py (renamed from migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py)0
-rw-r--r--gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py (renamed from migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py)0
-rw-r--r--gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py (renamed from migrations/auth/20240606_02_ubZri-create-resource-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py (renamed from migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py (renamed from migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py (renamed from migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py)0
-rw-r--r--gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py42
-rw-r--r--gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py49
-rw-r--r--gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py19
-rw-r--r--gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py23
-rw-r--r--gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py27
-rw-r--r--gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py18
-rw-r--r--gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py36
-rw-r--r--gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py31
-rw-r--r--gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py53
-rw-r--r--gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py75
-rw-r--r--gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py70
-rw-r--r--gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py61
-rw-r--r--gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py62
-rw-r--r--gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py66
-rw-r--r--gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py49
-rw-r--r--gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py69
-rw-r--r--gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py185
-rw-r--r--gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py19
-rw-r--r--gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py62
-rw-r--r--gn_auth/migrations/auth/20260825_01_4uVPR-add-privilege-system-user-list-to-the-resource-owner-role.py40
-rw-r--r--gn_auth/migrations/auth/20260826_01_6Hzl8-add-system-user-create-user-privilege-to-system-administrator-role.py44
-rw-r--r--gn_auth/migrations/auth/20260831_01_VZp52-assign-resource-user-assign-role-to-the-system-administrator-role.py39
-rw-r--r--gn_auth/migrations/auth/20260901_01_asMmm-revoke-system-administration-role-on-non-system-resources.py79
-rw-r--r--gn_auth/migrations/auth/__init__.py (renamed from migrations/auth/__init__.py)0
-rw-r--r--gn_auth/scripts/__init__.py1
-rw-r--r--gn_auth/scripts/assign_data_to_default_admin.py (renamed from scripts/assign_data_to_default_admin.py)3
-rw-r--r--gn_auth/scripts/batch_assign_data_to_default_admin.py (renamed from scripts/batch_assign_data_to_default_admin.py)5
-rw-r--r--gn_auth/scripts/link_inbredsets.py (renamed from scripts/link_inbredsets.py)5
-rw-r--r--gn_auth/scripts/register_sys_admin.py (renamed from scripts/register_sys_admin.py)2
-rw-r--r--gn_auth/scripts/search_phenotypes.py (renamed from scripts/search_phenotypes.py)8
-rw-r--r--gn_auth/scripts/worker.py (renamed from scripts/worker.py)0
-rw-r--r--gn_auth/settings.py8
-rw-r--r--gn_auth/smtp.py4
-rw-r--r--gn_auth/static/css/autocomplete.css85
-rw-r--r--gn_auth/static/css/bootstrap-custom.css7570
-rw-r--r--gn_auth/static/css/broken_links.css5
-rw-r--r--gn_auth/static/css/colorbox.css238
-rw-r--r--gn_auth/static/css/docs.css1080
-rw-r--r--gn_auth/static/css/non-responsive.css114
-rw-r--r--gn_auth/static/css/parsley.css20
-rw-r--r--gn_auth/templates/404.html13
-rw-r--r--gn_auth/templates/base.html18
-rw-r--r--gn_auth/templates/emails/forgot-password.html2
-rw-r--r--gn_auth/templates/emails/forgot-password.txt2
-rw-r--r--gn_auth/templates/emails/verify-email.html2
-rw-r--r--gn_auth/templates/emails/verify-email.txt2
-rw-r--r--gn_auth/templates/http-error-4xx.html20
-rw-r--r--gn_auth/templates/http-error-5xx.html (renamed from gn_auth/templates/50x.html)0
-rw-r--r--gn_auth/templates/oauth2/authorise-user.html87
-rw-r--r--gn_auth/wsgi.py448
-rw-r--r--migrations/__init__.py1
-rw-r--r--mypy.ini3
-rw-r--r--pyproject.toml71
-rw-r--r--scripts/__init__.py0
-rw-r--r--setup.cfg4
-rwxr-xr-xsetup.py48
-rw-r--r--setup_commands/__init__.py3
-rw-r--r--setup_commands/run_tests.py40
-rw-r--r--tests/unit/auth/fixtures/group_fixtures.py21
-rw-r--r--tests/unit/auth/fixtures/resource_fixtures.py48
-rw-r--r--tests/unit/auth/fixtures/role_fixtures.py4
-rw-r--r--tests/unit/auth/fixtures/user_fixtures.py21
-rw-r--r--tests/unit/auth/test_admin_user_roles.py262
-rw-r--r--tests/unit/auth/test_admin_users.py190
-rw-r--r--tests/unit/auth/test_groups.py8
-rw-r--r--tests/unit/auth/test_migrations_add_data_to_table.py4
-rw-r--r--tests/unit/auth/test_migrations_add_remove_columns.py4
-rw-r--r--tests/unit/auth/test_migrations_indexes.py4
-rw-r--r--tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py2
-rw-r--r--tests/unit/auth/test_migrations_insert_data_into_empty_table.py4
-rw-r--r--tests/unit/auth/test_privileges.py13
-rw-r--r--tests/unit/auth/test_resources.py39
-rw-r--r--tests/unit/auth/test_resources_roles.py2
-rw-r--r--tests/unit/auth/test_roles.py15
-rw-r--r--tests/unit/auth/test_system_admin_resources.py94
199 files changed, 14409 insertions, 1218 deletions
diff --git a/.guix-channel b/.guix-channel
index 9476e74..bfc31db 100644
--- a/.guix-channel
+++ b/.guix-channel
@@ -3,18 +3,57 @@
(directory ".guix/modules")
(dependencies
(channel
+ (name gn-machines)
+ (url "https://git.genenetwork.org/gn-machines")
+ (branch "main"))
+ ;; Until https://issues.guix.gnu.org/68797 is resolved, we need to
+ ;; explicitly list guix-bioinformatics, guix-forge, guix-past and
+ ;; guix-rust-past-crates—the dependencies of the gn-machines channel—here.
+ (channel
+ (name guix)
+ (url "https://codeberg.org/guix/guix")
+ (branch "master")
+ (commit "0a4740705090acc4c8a10d4f53afc58c9f62e980")
+ (introduction
+ (channel-introduction
+ (version 0)
+ (commit "9edb3f66fd807b096b48283debdcddccfea34bad")
+ (signer
+ "BBB0 2DDF 2CEA F6A8 0D1D E643 A2A0 6DF2 A33A 54FA"))))
+ (channel
+ (name guix-forge)
+ (url "https://git.systemreboot.net/guix-forge/")
+ (branch "main")
+ (commit "e43fd9a4d73654d3876e2c698af7da89f3408f89")
+ (introduction
+ (channel-introduction
+ (version 0)
+ (commit "0432e37b20dd678a02efee21adf0b9525a670310")
+ (signer
+ "7F73 0343 F2F0 9F3C 77BF 79D3 2E25 EE8B 6180 2BB3"))))
+ (channel
(name guix-bioinformatics)
(url "https://git.genenetwork.org/guix-bioinformatics")
- (branch "master"))
- ;; FIXME: guix-bioinformatics depends on guix-past. So, there
- ;; should be no reason to explicitly depend on guix-past. But, the
- ;; channel does not build otherwise. This is probably a guix bug.
+ (commit "9b0955f14ec725990abb1f6af3b9f171e4943f77"))
(channel
(name guix-past)
- (url "https://gitlab.inria.fr/guix-hpc/guix-past")
+ (url "https://codeberg.org/guix-science/guix-past")
+ (branch "master")
+ (commit "473c942b509ab3ead35159d27dfbf2031a36cd4d")
+ (introduction
+ (channel-introduction
+ (version 0)
+ (commit "c3bc94ee752ec545e39c1b8a29f739405767b51c")
+ (signer
+ "3CE4 6455 8A84 FDC6 9DB4 0CFB 090B 1199 3D9A EBB5"))))
+ (channel
+ (name guix-rust-past-crates)
+ (url "https://codeberg.org/guix/guix-rust-past-crates.git")
+ (branch "trunk")
+ (commit "b8b7ffbd1cec9f56f93fae4da3a74163bbc9c570")
(introduction
(channel-introduction
(version 0)
- (commit "0c119db2ea86a389769f4d2b9c6f5c41c027e336")
+ (commit "1db24ca92c28255b28076792b93d533eabb3dc6a")
(signer
- "3CE4 6455 8A84 FDC6 9DB4 0CFB 090B 1199 3D9A EBB5"))))))
+ "F4C2 D1DF 3FDE EA63 D1D3 0776 ACC6 6D09 CA52 8292"))))))
diff --git a/.guix/modules/gn-auth.scm b/.guix/modules/gn-auth.scm
index 0dab8d9..190f695 100644
--- a/.guix/modules/gn-auth.scm
+++ b/.guix/modules/gn-auth.scm
@@ -1,5 +1,5 @@
(define-module (gn-auth)
- #:use-module ((gn packages genenetwork)
+ #:use-module ((gn-machines genenetwork)
#:select (gn-auth) #:prefix gn:)
#:use-module ((gnu packages check) #:select (python-pylint))
#:use-module ((gnu packages python-check) #:select (python-mypy))
@@ -34,7 +34,7 @@
#~(modify-phases #$phases
(add-before 'build 'pylint
(lambda _
- (invoke "pylint" "setup.py" "tests" "gn_auth" "scripts")))
+ (invoke "pylint" "tests" "gn_auth")))
(add-after 'pylint 'mypy
(lambda _
(invoke "mypy" ".")))))))
diff --git a/.pylintrc b/.pylintrc
deleted file mode 100644
index 0b11d24..0000000
--- a/.pylintrc
+++ /dev/null
@@ -1,13 +0,0 @@
-[SIMILARITIES]
-
-ignore-imports=yes
-
-[MESSAGES CONTROL]
-
-disable=
- fixme,
- duplicate-code,
- no-else-return
-
-load-plugins=
- pylint.extensions.no_self_use \ No newline at end of file
diff --git a/README.md b/README.md
index d72ed81..f6c5f04 100644
--- a/README.md
+++ b/README.md
@@ -146,7 +146,7 @@ $ yoyo new -m "<description of the migration>" ./migrations/auth/
The command will ask whether you want to save the migration configuration, e.g.
```bash
-$ yoyo new --config=yoyo.auth.ini -m "testing a new migration"
+$ yoyo new --database="sqlite:////tmp/test-auth.db" --migration-table=_yoyo_migration -m "testing a new migration" ./migrations/auth/
Error: could not open editor!
Created file ./migrations/auth/20221103_02_HBzwk-testing-a-new-migration.py
Save migration configuration to yoyo.ini?
@@ -268,7 +268,7 @@ The checks we do are
### Linting
```bash
-pylint *py tests gn_auth scripts
+pylint tests gn_auth
```
### Type-Checking
@@ -281,7 +281,7 @@ mypy --show-error-codes .
```bash
export AUTHLIB_INSECURE_TRANSPORT=true
-pytest -k unit_test
+pytest -m unit_test -n auto
```
## OAuth2
diff --git a/gn_auth/__init__.py b/gn_auth/__init__.py
index b695ebf..d03c9ef 100644
--- a/gn_auth/__init__.py
+++ b/gn_auth/__init__.py
@@ -1,6 +1,8 @@
"""Application initialisation module."""
import os
import sys
+import logging
+import warnings
from pathlib import Path
from typing import Optional, Callable
@@ -17,9 +19,16 @@ from gn_auth.auth.authentication.oauth2.server import setup_oauth2_server
from . import settings
from .errors import register_error_handlers
+## Configure warnings: ##
+# https://docs.python.org/3/library/warnings.html#the-warnings-filter
+# filters form: (action, message, category, module, lineno)
+warnings.filterwarnings(action="always", category=DeprecationWarning)
+
+
class ConfigurationError(Exception):
"""Raised in case of a configuration error."""
+
def check_mandatory_settings(app: Flask) -> None:
"""Verify that mandatory settings are defined in the application"""
undefined = tuple(
@@ -52,27 +61,57 @@ def load_secrets_conf(app: Flask) -> None:
app.config.from_pyfile(secretsfile)
-def create_app(
- config: Optional[dict] = None,
- setup_logging: Callable[[Flask], None] = lambda appl: None
-) -> Flask:
+def dev_loggers(appl: Flask) -> logging.Logger:
+ """Setup the logging handlers."""
+ stderr_handler = logging.StreamHandler(stream=sys.stderr)
+ appl.logger.addHandler(stderr_handler)
+ appl.logger.setLevel(appl.config["LOGLEVEL"])
+
+ return appl.logger
+
+
+def gunicorn_loggers(appl: Flask) -> logging.Logger:
+ """Use gunicorn logging handlers for the application."""
+ logger = logging.getLogger("gunicorn.error")
+ appl.logger.handlers = logger.handlers
+ appl.logger.setLevel(logger.level)
+ return appl.logger
+
+
+def setup_logging(appl: Flask, loggable_modules: tuple[str, ...] = tuple()) -> None:
+ """
+ Setup the loggers according to the WSGI server used to run the application.
+ """
+ # https://datatracker.ietf.org/doc/html/draft-coar-cgi-v11-03#section-4.1.17
+ # https://wsgi.readthedocs.io/en/latest/proposals-2.0.html#making-some-keys-required
+ # https://peps.python.org/pep-3333/#id4
+ software, *_version_and_comments = os.environ.get(
+ "SERVER_SOFTWARE", "").split('/')
+ logger = gunicorn_loggers(appl) if bool(software) else dev_loggers(appl)
+ for _logger in (
+ item for item in logger.manager.loggerDict.values()
+ if isinstance(item, logging.Logger)):
+ _logger.addFilter(lambda record: record.name in loggable_modules)
+
+
+def create_app(config: Optional[dict] = None) -> Flask:
"""Create and return a new flask application."""
app = Flask(__name__)
# ====== Setup configuration ======
app.config.from_object(settings) # Default settings
- # Override defaults with startup settings
- app.config.update(config or {})
# Override app settings with site-local settings
if "GN_AUTH_CONF" in os.environ:
app.config.from_envvar("GN_AUTH_CONF")
override_settings_with_envvars(app)
+ # Override defaults with startup settings
+ app.config.update(config or {})
load_secrets_conf(app)
# ====== END: Setup configuration ======
- setup_logging(app)
+ setup_logging(app, tuple(app.config.get("LOGGABLE_MODULES", [])))
check_mandatory_settings(app)
setup_oauth2_server(app)
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
index 200b25d..cebb3be 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
@@ -23,7 +23,7 @@ class IntrospectionEndpoint(_IntrospectionEndpoint):
CLIENT_AUTH_METHODS = ['client_secret_post']
def query_token(self, token_string: str, token_type_hint: str):
"""Query the token."""
- return _query_token(self, token_string, token_type_hint)
+ return _query_token(token_string, token_type_hint)
# pylint: disable=[no-self-use]
def introspect_token(self, token: OAuth2Token) -> dict:
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
index 80922f1..e647214 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
@@ -1,9 +1,9 @@
"""Handle token revocation."""
from flask import current_app
+from gn_libs import sqlite3 as db
from authlib.oauth2.rfc7009 import RevocationEndpoint as _RevocationEndpoint
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
save_token, OAuth2Token, revoke_token)
@@ -15,7 +15,7 @@ class RevocationEndpoint(_RevocationEndpoint):
CLIENT_AUTH_METHODS = ['client_secret_post']
def query_token(self, token_string: str, token_type_hint: str):
"""Query the token."""
- return _query_token(self, token_string, token_type_hint)
+ return _query_token(token_string, token_type_hint)
def revoke_token(self, token: OAuth2Token, request):
"""Revoke token `token`."""
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
index 08b2a3b..82fd8e1 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
@@ -1,16 +1,14 @@
"""endpoint utilities"""
-from typing import Any, Optional
+from typing import Optional
from flask import current_app
from pymonad.maybe import Nothing
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
OAuth2Token, token_by_access_token, token_by_refresh_token)
-def query_token(# pylint: disable=[unused-argument]
- endpoint_object: Any, token_str: str, token_type_hint) -> Optional[
- OAuth2Token]:
+def query_token(token_str: str, token_type_hint) -> Optional[OAuth2Token]:
"""Retrieve the token from the database."""
def __identity__(val):
"""Identity function."""
diff --git a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
index 27783ac..63f979c 100644
--- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
@@ -1,16 +1,25 @@
"""JWT as Authorisation Grant"""
import uuid
+import time
+import logging
+from typing import Optional
-from flask import current_app as app
-
+from authlib.jose import jwt
+from authlib.common.encoding import to_native
from authlib.common.security import generate_token
from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant
from authlib.oauth2.rfc7523.token import (
JWTBearerTokenGenerator as _JWTBearerTokenGenerator)
-from gn_auth.debug import __pk__
+from gn_libs.debug import make_peeker
+
from gn_auth.auth.db.sqlite3 import with_db_connection
-from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.users import User, user_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client
+
+
+logger = logging.getLogger(__name__)
+__pk__ = make_peeker(logger)
class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
@@ -20,12 +29,24 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
DEFAULT_EXPIRES_IN = 300
- def get_token_data(#pylint: disable=[too-many-arguments]
+ def get_token_data(#pylint: disable=[too-many-arguments, too-many-positional-arguments]
self, grant_type, client, expires_in=None, user=None, scope=None
):
"""Post process data to prevent JSON serialization problems."""
- tokendata = super().get_token_data(
- grant_type, client, expires_in, user, scope)
+ issued_at = int(time.time())
+ tokendata = {
+ "scope": self.get_allowed_scope(client, scope),
+ "grant_type": grant_type,
+ "iat": issued_at,
+ "client_id": client.get_client_id()
+ }
+ if isinstance(expires_in, int) and expires_in > 0:
+ tokendata["exp"] = issued_at + expires_in
+ if self.issuer:
+ tokendata["iss"] = self.issuer
+ if user:
+ tokendata["sub"] = self.get_sub_value(user)
+
return {
**{
key: str(value) if key.endswith("_id") else value
@@ -36,8 +57,38 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
"oauth2_client_id": str(client.client_id)
}
+ def generate(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ self,
+ grant_type: str,
+ client: OAuth2Client,
+ user: Optional[User] = None,
+ scope: Optional[str] = None,
+ expires_in: Optional[int] = None
+ ) -> dict:
+ """Generate a bearer token for OAuth 2.0 authorization token endpoint.
+
+ :param client: the client that making the request.
+ :param grant_type: current requested grant_type.
+ :param user: current authorized user.
+ :param expires_in: if provided, use this value as expires_in.
+ :param scope: current requested scope.
+ :return: Token dict
+ """
+
+ token_data = self.get_token_data(grant_type, client, expires_in, user, scope)
+ access_token = jwt.encode({"alg": self.alg}, token_data, key=self.secret_key, check=False)
+ token = {
+ "token_type": "Bearer",
+ "access_token": to_native(access_token)
+ }
+ if expires_in:
+ token["expires_in"] = expires_in
+ if scope:
+ token["scope"] = scope
+ return token
+
- def __call__(# pylint: disable=[too-many-arguments]
+ def __call__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
self, grant_type, client, user=None, scope=None, expires_in=None,
include_refresh_token=True
):
@@ -102,6 +153,6 @@ class JWTBearerGrant(_JWTBearerGrant):
include_refresh_token=self.request.client.check_grant_type(
"refresh_token")
)
- app.logger.debug('Issue token %r to %r', token, self.request.client)
+ logger.debug('Issue token %r to %r', token, self.request.client)
self.save_token(token)
return 200, token, self.TOKEN_RESPONSE_HEADER
diff --git a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
index fd6804d..f897d89 100644
--- a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
@@ -34,18 +34,18 @@ class RefreshTokenGrant(grants.RefreshTokenGrant):
else Nothing)
).maybe(None, lambda _tok: _tok)
- def authenticate_user(self, credential):
+ def authenticate_user(self, refresh_token):
"""Check that user is valid for given token."""
with connection(app.config["AUTH_DB"]) as conn:
try:
- return user_by_id(conn, credential.user.user_id)
+ return user_by_id(conn, refresh_token.user.user_id)
except NotFoundError as _nfe:
return None
return None
- def revoke_old_credential(self, credential):
+ def revoke_old_credential(self, refresh_token):
"""Revoke any old refresh token after issuing new refresh token."""
with connection(app.config["AUTH_DB"]) as conn:
- if credential.parent_of is not None:
- revoke_refresh_token(conn, credential)
+ if refresh_token.parent_of is not None:
+ revoke_refresh_token(conn, refresh_token)
diff --git a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
index cca75f4..39249ba 100644
--- a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
+++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
@@ -1,9 +1,12 @@
"""Implement model for JWTBearerToken"""
import uuid
+import time
+from typing import Optional
+from flask import current_app as app
from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken
+from gn_libs.sqlite3 import with_db_connection
-from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authentication.users import user_by_id
from gn_auth.auth.authentication.oauth2.models.oauth2client import (
client as fetch_client)
@@ -17,8 +20,10 @@ class JWTBearerToken(_JWTBearerToken):
# OAuth2Client is a dataclass
super().__init__(payload, header, options, params)
self.user = with_db_connection(
- lambda conn:user_by_id(conn, uuid.UUID(payload["sub"])))
+ app.config["AUTH_DB"],
+ lambda conn: user_by_id(conn, uuid.UUID(payload["sub"])))
self.client = with_db_connection(
+ app.config["AUTH_DB"],
lambda conn: fetch_client(
conn, uuid.UUID(payload["oauth2_client_id"])
)
@@ -28,3 +33,21 @@ class JWTBearerToken(_JWTBearerToken):
def check_client(self, client):
"""Check that the client is right."""
return self.client.get_client_id() == client.get_client_id()
+
+
+ def get_expires_in(self) -> Optional[int]:
+ """Return the number of seconds the token is valid for since issue.
+
+ If `None`, the token never expires."""
+ if "exp" in self:
+ return self['exp'] - self['iat']
+ return None
+
+
+ def is_expired(self):
+ """Check whether the token is expired.
+
+ If there is no 'exp' member, assume this token will never expire."""
+ if "exp" in self:
+ return self["exp"] < time.time()
+ return False
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
index df5d564..818399d 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -3,6 +3,7 @@ import json
import logging
import datetime
from uuid import UUID
+from urllib.parse import urlparse
from functools import cached_property
from dataclasses import asdict, dataclass
from typing import Any, Sequence, Optional
@@ -12,8 +13,9 @@ from requests.exceptions import JSONDecodeError
from authlib.jose import KeySet, JsonWebKey
from authlib.oauth2.rfc6749 import ClientMixin
from pymonad.maybe import Just, Maybe, Nothing
+from gn_libs import sqlite3 as db
+from gn_libs.debug import make_peeker
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import (User,
fetch_users,
@@ -21,6 +23,10 @@ from gn_auth.auth.authentication.users import (User,
same_password)
+logger = logging.getLogger(__name__)
+__pk__ = make_peeker(logger)
+
+
@dataclass(frozen=True)
class OAuth2Client(ClientMixin):
"""
@@ -62,23 +68,26 @@ class OAuth2Client(ClientMixin):
def jwks(self) -> KeySet:
"""Return this client's KeySet."""
jwksuri = self.client_metadata.get("public-jwks-uri")
+ __pk__(f"PUBLIC JWKs link for client {self.client_id}", jwksuri)
if not bool(jwksuri):
- logging.debug("No Public JWKs URI set for client!")
+ logger.debug("No Public JWKs URI set for client!")
return KeySet([])
try:
## IMPORTANT: This can cause a deadlock if the client is working in
## single-threaded mode, i.e. can only serve one request
## at a time.
return KeySet([JsonWebKey.import_key(key)
- for key in requests.get(jwksuri).json()["jwks"]])
+ for key in requests.get(
+ jwksuri,
+ timeout=300,
+ allow_redirects=True).json()["jwks"]])
except requests.ConnectionError as _connerr:
- logging.debug(
+ logger.debug(
"Could not connect to provided URI: %s", jwksuri, exc_info=True)
except JSONDecodeError as _jsonerr:
- logging.debug(
- "Could not convert response to JSON", exc_info=True)
+ logger.debug("Could not convert response to JSON", exc_info=True)
except Exception as _exc:# pylint: disable=[broad-except]
- logging.debug(
+ logger.debug(
"Error retrieving the JWKs for the client.", exc_info=True)
return KeySet([])
@@ -130,7 +139,9 @@ class OAuth2Client(ClientMixin):
"""
Check whether the given `redirect_uri` is one of the expected ones.
"""
- return redirect_uri in self.redirect_uris
+ uri = urlparse(redirect_uri)._replace(
+ query="")._replace(fragment="").geturl()
+ return uri in self.redirect_uris
@cached_property
def response_types(self) -> Sequence[str]:
@@ -287,6 +298,8 @@ def delete_client(
cursor.execute("DELETE FROM authorisation_code WHERE client_id=?",
params)
cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params)
+ cursor.execute("DELETE FROM jwt_refresh_tokens WHERE client_id=?",
+ params)
cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params)
return the_client
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2token.py b/gn_auth/auth/authentication/oauth2/models/oauth2token.py
index 6ec5c3d..eb13f43 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2token.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2token.py
@@ -8,8 +8,8 @@ from typing import Optional
from authlib.oauth2.rfc6749 import TokenMixin
from pymonad.tools import monad_from_none_or_value
from pymonad.maybe import Just, Maybe, Nothing
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import User, user_by_id
diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py
index 9c885e2..c09f6b6 100644
--- a/gn_auth/auth/authentication/oauth2/resource_server.py
+++ b/gn_auth/auth/authentication/oauth2/resource_server.py
@@ -1,4 +1,5 @@
"""Protect the resources endpoints"""
+import logging
from datetime import datetime, timezone, timedelta
from flask import current_app as app
@@ -8,14 +9,17 @@ from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator
from authlib.oauth2.rfc7523 import (
JWTBearerTokenValidator as _JWTBearerTokenValidator)
from authlib.integrations.flask_oauth2 import ResourceProtector
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.jwks import list_jwks, jwks_directory
from gn_auth.auth.authentication.oauth2.models.jwt_bearer_token import (
JWTBearerToken)
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
token_by_access_token)
+logger = logging.getLogger(__name__)
+
+
class BearerTokenValidator(_BearerTokenValidator):
"""Extends `authlib.oauth2.rfc6750.BearerTokenValidator`"""
def authenticate_token(self, token_string: str):
@@ -43,6 +47,11 @@ class JWTBearerTokenValidator(_JWTBearerTokenValidator):
self._last_jwks_update = datetime.now(tz=timezone.utc)
self._refresh_frequency = timedelta(hours=int(
extra_attributes.get("jwt_refresh_frequency_hours", 6)))
+ self.claims_options = {
+ 'exp': {'essential': False},
+ 'client_id': {'essential': True},
+ 'grant_type': {'essential': True},
+ }
def __refresh_jwks__(self):
now = datetime.now(tz=timezone.utc)
@@ -61,7 +70,7 @@ class JWTBearerTokenValidator(_JWTBearerTokenValidator):
claims.validate()
return claims
except JoseError as error:
- app.logger.debug('Authenticate token failed. %r', error)
+ logger.debug('Authenticate token failed. %r', error)
return None
diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py
index a8109b7..fd45b63 100644
--- a/gn_auth/auth/authentication/oauth2/server.py
+++ b/gn_auth/auth/authentication/oauth2/server.py
@@ -3,12 +3,12 @@ import uuid
from typing import Callable
from datetime import datetime
-from flask import Flask, current_app
-from authlib.jose import jwt, KeySet
+from flask import Flask, current_app, request as flask_request
+from authlib.jose import KeySet
+from authlib.oauth2.rfc6749 import OAuth2Request
from authlib.oauth2.rfc6749.errors import InvalidClientError
from authlib.integrations.flask_oauth2 import AuthorizationServer
-from authlib.oauth2.rfc6749 import OAuth2Request
-from authlib.integrations.flask_helpers import create_oauth_request
+from authlib.integrations.flask_oauth2.requests import FlaskOAuth2Request
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.jwks import (
@@ -16,13 +16,9 @@ from gn_auth.auth.jwks import (
jwks_directory,
newest_jwk_with_rotation)
+from .models.jwt_bearer_token import JWTBearerToken
from .models.oauth2client import client as fetch_client
from .models.oauth2token import OAuth2Token, save_token
-from .models.jwtrefreshtoken import (
- JWTRefreshToken,
- link_child_token,
- save_refresh_token,
- load_refresh_token)
from .grants.password_grant import PasswordGrant
from .grants.refresh_token_grant import RefreshTokenGrant
@@ -34,6 +30,8 @@ from .endpoints.introspection import IntrospectionEndpoint
from .resource_server import require_oauth, JWTBearerTokenValidator
+_TWO_HOURS_ = 2 * 60 * 60
+
def create_query_client_func() -> Callable:
"""Create the function that loads the client."""
@@ -50,54 +48,32 @@ def create_query_client_func() -> Callable:
return __query_client__
-def create_save_token_func(token_model: type, app: Flask) -> Callable:
+def create_save_token_func(token_model: type) -> Callable:
"""Create the function that saves the token."""
+ def __ignore_token__(token, request):# pylint: disable=[unused-argument]
+ """Ignore the token: i.e. Do not save it."""
+
def __save_token__(token, request):
- _jwt = jwt.decode(
- token["access_token"],
- newest_jwk_with_rotation(
- jwks_directory(app),
- int(app.config["JWKS_ROTATION_AGE_DAYS"])))
- _token = token_model(
- token_id=uuid.UUID(_jwt["jti"]),
- client=request.client,
- user=request.user,
- **{
- "refresh_token": None,
- "revoked": False,
- "issued_at": datetime.now(),
- **token
- })
with db.connection(current_app.config["AUTH_DB"]) as conn:
- save_token(conn, _token)
- old_refresh_token = load_refresh_token(
+ save_token(
conn,
- request.form.get("refresh_token", "nosuchtoken")
- )
- new_refresh_token = JWTRefreshToken(
- token=_token.refresh_token,
+ token_model(
+ **token,
+ token_id=uuid.uuid4(),
client=request.client,
user=request.user,
- issued_with=uuid.UUID(_jwt["jti"]),
- issued_at=datetime.fromtimestamp(_jwt["iat"]),
- expires=datetime.fromtimestamp(
- old_refresh_token.then(
- lambda _tok: _tok.expires.timestamp()
- ).maybe((int(_jwt["iat"]) +
- RefreshTokenGrant.DEFAULT_EXPIRES_IN),
- lambda _expires: _expires)),
- scope=_token.get_scope(),
+ issued_at=datetime.now(),
revoked=False,
- parent_of=None)
- save_refresh_token(conn, new_refresh_token)
- old_refresh_token.then(lambda _tok: link_child_token(
- conn, _tok.token, new_refresh_token.token))
+ expires_in=_TWO_HOURS_))
- return __save_token__
+ return {
+ OAuth2Token: __save_token__,
+ JWTBearerToken: __ignore_token__
+ }[token_model]
def make_jwt_token_generator(app):
"""Make token generator function."""
- def __generator__(# pylint: disable=[too-many-arguments]
+ def __generator__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
grant_type,
client,
user=None,
@@ -106,15 +82,17 @@ def make_jwt_token_generator(app):
include_refresh_token=True
):
return JWTBearerTokenGenerator(
- newest_jwk_with_rotation(
+ secret_key=newest_jwk_with_rotation(
jwks_directory(app),
- int(app.config["JWKS_ROTATION_AGE_DAYS"]))).__call__(
- grant_type,
- client,
- user,
- scope,
- JWTBearerTokenGenerator.DEFAULT_EXPIRES_IN,
- include_refresh_token)
+ int(app.config["JWKS_ROTATION_AGE_DAYS"])),
+ issuer=flask_request.host_url,
+ alg="RS256").__call__(
+ grant_type=grant_type,
+ client=client,
+ user=user,
+ scope=scope,
+ expires_in=expires_in,
+ include_refresh_token=include_refresh_token)
return __generator__
@@ -124,8 +102,16 @@ class JsonAuthorizationServer(AuthorizationServer):
def create_oauth2_request(self, request):
"""Create an OAuth2 Request from the flask request."""
- res = create_oauth_request(request, OAuth2Request, True)
- return res
+ match flask_request.headers.get("Content-Type"):
+ case "application/json":
+ req = OAuth2Request(flask_request.method,
+ flask_request.url,
+ flask_request.get_json(),
+ flask_request.headers)
+ case _:
+ req = FlaskOAuth2Request(flask_request)
+
+ return req
def setup_oauth2_server(app: Flask) -> None:
@@ -143,6 +129,7 @@ def setup_oauth2_server(app: Flask) -> None:
server.register_token_generator(
"urn:ietf:params:oauth:grant-type:jwt-bearer", jwttokengenerator)
server.register_token_generator("refresh_token", jwttokengenerator)
+ server.register_token_generator("password", jwttokengenerator)
server.register_grant(RefreshTokenGrant)
# register endpoints
@@ -153,7 +140,7 @@ def setup_oauth2_server(app: Flask) -> None:
server.init_app(
app,
query_client=create_query_client_func(),
- save_token=create_save_token_func(OAuth2Token, app))
+ save_token=create_save_token_func(JWTBearerToken))
app.config["OAUTH2_SERVER"] = server
## Set up the token validators
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
index d0b55b4..f73a712 100644
--- a/gn_auth/auth/authentication/oauth2/views.py
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -1,5 +1,6 @@
"""Endpoints for the oauth2 server"""
import uuid
+import logging
import traceback
from urllib.parse import urlparse
@@ -15,9 +16,9 @@ from flask import (
Blueprint,
render_template,
current_app as app)
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
-from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.jwks import jwks_directory, list_jwks
from gn_auth.auth.errors import NotFoundError, ForbiddenAccess
from gn_auth.auth.authentication.users import valid_login, user_by_email
@@ -27,8 +28,10 @@ from .endpoints.revocation import RevocationEndpoint
from .endpoints.introspection import IntrospectionEndpoint
+logger = logging.getLogger(__name__)
auth = Blueprint("auth", __name__)
+
@auth.route("/delete-client/<uuid:client_id>", methods=["GET", "POST"])
def delete_client(client_id: uuid.UUID):
"""Delete an OAuth2 client."""
@@ -44,7 +47,7 @@ def authorise():
or str(uuid.uuid4()))
client = server.query_client(client_id)
if not bool(client):
- flash("Invalid OAuth2 client.", "alert-danger")
+ flash("Invalid OAuth2 client.", "alert alert-danger")
if request.method == "GET":
def __forgot_password_table_exists__(conn):
@@ -65,6 +68,7 @@ def authorise():
redirect_uri=request.args["redirect_uri"],
source_uri=f"{_src.scheme}://{_src.netloc}/",
display_forgot_password=with_db_connection(
+ app.config["AUTH_DB"],
__forgot_password_table_exists__))
form = request.form
@@ -77,7 +81,7 @@ def authorise():
try:
email = validate_email(
form.get("user:email"), check_deliverability=False)
- user = user_by_email(conn, email["email"])
+ user = user_by_email(conn, email["email"]) # type: ignore
if valid_login(conn, user, form.get("user:password", "")):
if not user.verified:
return redirect(
@@ -88,18 +92,18 @@ def authorise():
email=email["email"]),
code=307)
return server.create_authorization_response(request=request, grant_user=user)
- flash(email_passwd_msg, "alert-danger")
+ flash(email_passwd_msg, "alert alert-danger")
return redirect_response # type: ignore[return-value]
except EmailNotValidError as _enve:
- app.logger.debug(traceback.format_exc())
- flash(email_passwd_msg, "alert-danger")
+ logger.debug(traceback.format_exc())
+ flash(email_passwd_msg, "alert alert-danger")
return redirect_response # type: ignore[return-value]
except NotFoundError as _nfe:
- app.logger.debug(traceback.format_exc())
- flash(email_passwd_msg, "alert-danger")
+ logger.debug(traceback.format_exc())
+ flash(email_passwd_msg, "alert alert-danger")
return redirect_response # type: ignore[return-value]
- return with_db_connection(__authorise__)
+ return with_db_connection(app.config["AUTH_DB"], __authorise__)
except InvalidClientError as ice:
return render_template(
"oauth2/oauth2_error.html", error=ice), ice.status_code
diff --git a/gn_auth/auth/authentication/users.py b/gn_auth/auth/authentication/users.py
index 140ce36..5c6a430 100644
--- a/gn_auth/auth/authentication/users.py
+++ b/gn_auth/auth/authentication/users.py
@@ -1,14 +1,14 @@
"""User-specific code and data structures."""
import datetime
-from typing import Tuple
+from typing import Tuple, Union
from uuid import UUID, uuid4
from dataclasses import dataclass
import sqlite3
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
@@ -26,7 +26,7 @@ class User:
return self.user_id
@staticmethod
- def from_sqlite3_row(row: sqlite3.Row):
+ def from_sqlite3_row(row: Union[sqlite3.Row, dict]):
"""Generate a user from a row in an SQLite3 resultset"""
return User(user_id=UUID(row["user_id"]),
email=row["email"],
diff --git a/gn_auth/auth/authorisation/checks.py b/gn_auth/auth/authorisation/checks.py
index 66bb723..15d4b99 100644
--- a/gn_auth/auth/authorisation/checks.py
+++ b/gn_auth/auth/authorisation/checks.py
@@ -2,12 +2,12 @@
from functools import wraps
from typing import Callable
+from gn_libs import sqlite3 as db
from flask import request, current_app as app
from gn_auth.auth.errors import InvalidData, AuthorisationError
from . import privileges as auth_privs
-from ..db import sqlite3 as db
from ..authentication.oauth2.resource_server import require_oauth
def __system_privileges_in_roles__(conn, user): # TODO: Remove this hack.
diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py
index bdab8fa..d5af3ae 100644
--- a/gn_auth/auth/authorisation/data/genotypes.py
+++ b/gn_auth/auth/authorisation/data/genotypes.py
@@ -1,16 +1,20 @@
"""Handle linking of Genotype data to the Auth(entic|oris)ation system."""
import uuid
-from dataclasses import asdict
+import logging
from typing import Iterable
+from functools import reduce
+from dataclasses import asdict
+from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as authdb
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import mariadb as gn3db
-from gn_auth.auth.db import sqlite3 as authdb
-
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups.models import Group
+
+logger = logging.getLogger(__name__)
+
def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]:
"""Retrieve genotype data that is linked to user groups."""
with authdb.cursor(conn) as cursor:
@@ -22,17 +26,26 @@ def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]:
"You do not have sufficient privileges to link data to (a) "
"group(s)."),
oauth2_scope="profile group resource")
-def ungrouped_genotype_data(# pylint: disable=[too-many-arguments]
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
- """Retrieve genotype data that is not linked to any user group."""
- params = tuple(
- (row["SpeciesId"], row["InbredSetId"], row["GenoFreezeId"])
- for row in linked_genotype_data(authconn)) + tuple(
- (row["SpeciesId"], row["InbredSetId"], row["GenoFreezeId"])
- for row in selected)
+ """Retrieve genotype data that is not linked to any user group.
+
+ The set of linked datasets is read from the auth database (SQLite) and the
+ exclusion happens in Python. This avoids embedding the (ever-growing) list
+ of linked datasets into the MariaDB query as a giant `NOT IN` list, which
+ MariaDB executes with an unindexed nested-loop join and which degrades
+ badly as more datasets get linked.
+ """
+ def __key__(row):
+ """Normalise a row's dataset identity to a comparable tuple."""
+ return (int(row["SpeciesId"]), int(row["InbredSetId"]),
+ int(row["GenoFreezeId"]))
+
+ excluded = {__key__(row) for row in linked_genotype_data(authconn)} | {
+ __key__(row) for row in selected}
query = (
"SELECT s.SpeciesId, iset.InbredSetId, iset.InbredSetName, "
"gf.Id AS GenoFreezeId, gf.Name AS dataset_name, "
@@ -41,27 +54,21 @@ def ungrouped_genotype_data(# pylint: disable=[too-many-arguments]
"FROM Species AS s INNER JOIN InbredSet AS iset "
"ON s.SpeciesId=iset.SpeciesId INNER JOIN GenoFreeze AS gf "
"ON iset.InbredSetId=gf.InbredSetId ")
-
- if len(params) > 0 or bool(search_query):
- query = query + "WHERE "
-
- if len(params) > 0:
- paramstr = ", ".join(["(%s, %s, %s)"] * len(params))
- query = query + (
- "(s.SpeciesId, iset.InbredSetId, gf.Id) "
- f"NOT IN ({paramstr}) "
- ) + ("AND " if bool(search_query) else "")
-
+ params: tuple[str, ...] = tuple()
if bool(search_query):
query = query + (
- "CONCAT(gf.Name, ' ', gf.FullName, ' ', gf.ShortName) LIKE %s ")
- params = params + ((f"%{search_query}%",),)# type: ignore[operator]
+ "WHERE CONCAT(gf.Name, ' ', gf.FullName, ' ', gf.ShortName) "
+ "LIKE %s ")
+ params = (f"%{search_query}%",)
- query = query + f"LIMIT {int(limit)} OFFSET {int(offset)}"
with gn3conn.cursor(DictCursor) as cursor:
- cursor.execute(
- query, tuple(item for sublist in params for item in sublist))
- return tuple(row for row in cursor.fetchall())
+ cursor.execute(query, params)
+ _rows = tuple(row for row in cursor.fetchall())
+
+ # Filter out linked/selected datasets and apply pagination in Python.
+ return tuple(
+ row for row in _rows
+ if __key__(row) not in excluded)[offset:offset + limit]
@authorised_p(
("system:data:link-to-group",),
@@ -95,3 +102,37 @@ def link_genotype_data(
"group": asdict(group),
"datasets": datasets
}
+
+
+def resources_by_datasets_and_traits(
+ authconn: authdb.DbConnection,
+ dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+ """Fetch resources by their attached datasets and traits."""
+ traits_by_datasets: dict[str, tuple[str, ...]] = reduce(
+ lambda acc, curr: {
+ **acc,
+ curr[0]: acc.get(curr[0], tuple()) + (curr[1],)
+ },
+ dsets_traits,
+ {})
+ paramstr = ", ".join(["?"] * len(dsets_traits))
+ query = (
+ "SELECT r.*, rc.*, lgd.dataset_name FROM linked_genotype_data AS lgd "
+ "INNER JOIN genotype_resources AS mr ON lgd.data_link_id=mr.data_link_id "
+ "INNER JOIN resources AS r ON mr.resource_id=r.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE lgd.dataset_name "
+ f"IN ({paramstr})")
+ logger.debug("QUERY: %s", query)
+ with authdb.cursor(authconn) as cursor:
+ params = tuple(traits_by_datasets.keys())
+ logger.debug("QUERY PARAMS: %s", params)
+ cursor.execute(query, tuple(traits_by_datasets.keys()))
+ return tuple({
+ "resource_id": row["resource_id"],
+ "resource_data": tuple(
+ f'{row["dataset_name"]}::{trait_id}'
+ for trait_id in traits_by_datasets[row["dataset_name"]])
+ } for row in cursor.fetchall())
diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py
index 60470a7..dd589e0 100644
--- a/gn_auth/auth/authorisation/data/mrna.py
+++ b/gn_auth/auth/authorisation/data/mrna.py
@@ -1,15 +1,21 @@
"""Handle linking of mRNA Assay data to the Auth(entic|oris)ation system."""
import uuid
-from dataclasses import asdict
+import logging
from typing import Iterable
-from MySQLdb.cursors import DictCursor
+from functools import reduce
+from dataclasses import asdict
-from gn_auth.auth.db import sqlite3 as authdb
-from gn_auth.auth.db import mariadb as gn3db
+from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as authdb
+from MySQLdb.cursors import DictCursor
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups.models import Group
+
+logger = logging.getLogger(__name__)
+
+
def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]:
"""Retrieve mRNA Assay data that is linked to user groups."""
with authdb.cursor(conn) as cursor:
@@ -21,19 +27,26 @@ def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]:
"You do not have sufficient privileges to link data to (a) "
"group(s)."),
oauth2_scope="profile group resource")
-def ungrouped_mrna_data(# pylint: disable=[too-many-arguments]
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
- """Retrieve mrna data that is not linked to any user group."""
- params = tuple(
- (row["SpeciesId"], row["InbredSetId"], row["ProbeFreezeId"],
- row["ProbeSetFreezeId"])
- for row in linked_mrna_data(authconn)) + tuple(
- (row["SpeciesId"], row["InbredSetId"], row["ProbeFreezeId"],
- row["ProbeSetFreezeId"])
- for row in selected)
+ """Retrieve mrna data that is not linked to any user group.
+
+ The set of linked datasets is read from the auth database (SQLite) and the
+ exclusion happens in Python. This avoids embedding the (ever-growing) list
+ of linked datasets into the MariaDB query as a giant `NOT IN` list, which
+ MariaDB executes with an unindexed nested-loop join and which degrades
+ badly as more datasets get linked.
+ """
+ def __key__(row):
+ """Normalise a row's dataset identity to a comparable tuple."""
+ return (int(row["SpeciesId"]), int(row["InbredSetId"]),
+ int(row["ProbeFreezeId"]), int(row["ProbeSetFreezeId"]))
+
+ excluded = {__key__(row) for row in linked_mrna_data(authconn)} | {
+ __key__(row) for row in selected}
query = (
"SELECT s.SpeciesId, iset.InbredSetId, iset.InbredSetName, "
"pf.ProbeFreezeId, pf.Name AS StudyName, psf.Id AS ProbeSetFreezeId, "
@@ -42,27 +55,22 @@ def ungrouped_mrna_data(# pylint: disable=[too-many-arguments]
"FROM Species AS s INNER JOIN InbredSet AS iset "
"ON s.SpeciesId=iset.SpeciesId INNER JOIN ProbeFreeze AS pf "
"ON iset.InbredSetId=pf.InbredSetId INNER JOIN ProbeSetFreeze AS psf "
- "ON pf.ProbeFreezeId=psf.ProbeFreezeId ") + (
- "WHERE " if (len(params) > 0 or bool(search_query)) else "")
-
- if len(params) > 0:
- paramstr = ", ".join(["(%s, %s, %s, %s)"] * len(params))
- query = query + (
- "(s.SpeciesId, iset.InbredSetId, pf.ProbeFreezeId, psf.Id) "
- f"NOT IN ({paramstr}) "
- ) + ("AND " if bool(search_query) else "")
-
+ "ON pf.ProbeFreezeId=psf.ProbeFreezeId ")
+ params: tuple[str, ...] = tuple()
if bool(search_query):
query = query + (
- "CONCAT(pf.Name, psf.Name, ' ', psf.FullName, ' ', psf.ShortName) "
- "LIKE %s ")
- params = params + ((f"%{search_query}%",),)# type: ignore[operator]
+ "WHERE CONCAT(pf.Name, psf.Name, ' ', psf.FullName, ' ', "
+ "psf.ShortName) LIKE %s ")
+ params = (f"%{search_query}%",)
- query = query + f"LIMIT {int(limit)} OFFSET {int(offset)}"
with gn3conn.cursor(DictCursor) as cursor:
- cursor.execute(
- query, tuple(item for sublist in params for item in sublist))
- return tuple(row for row in cursor.fetchall())
+ cursor.execute(query, params)
+ _rows = tuple(row for row in cursor.fetchall())
+
+ # Filter out linked/selected datasets and apply pagination in Python.
+ return tuple(
+ row for row in _rows
+ if __key__(row) not in excluded)[offset:offset + limit]
@authorised_p(
("system:data:link-to-group",),
@@ -99,3 +107,35 @@ def link_mrna_data(
"group": asdict(group),
"datasets": datasets
}
+
+
+def resources_by_datasets_and_traits(
+ authconn: authdb.DbConnection,
+ dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+ """Fetch resources by their attached datasets and traits."""
+ traits_by_datasets: dict[str, tuple[str, ...]] = reduce(
+ lambda acc, curr: {
+ **acc,
+ curr[0]: acc.get(curr[0], tuple()) + (curr[1],)
+ },
+ dsets_traits,
+ {})
+ paramstr = ", ".join(["?"] * len(traits_by_datasets.keys()))
+ query = (
+ "SELECT r.*, rc.*, lmd.dataset_name FROM linked_mrna_data AS lmd "
+ "INNER JOIN mrna_resources AS mr ON lmd.data_link_id=mr.data_link_id "
+ "INNER JOIN resources AS r ON mr.resource_id=r.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE lmd.dataset_name "
+ f"IN ({paramstr})")
+ logger.debug("QUERY: %s", query)
+ with authdb.cursor(authconn) as cursor:
+ cursor.execute(query, tuple(traits_by_datasets.keys()))
+ return tuple({
+ "resource_id": row["resource_id"],
+ "resource_data": tuple(
+ f'{row["dataset_name"]}::{trait_id}'
+ for trait_id in traits_by_datasets[row["dataset_name"]])
+ } for row in cursor.fetchall())
diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py
index 0a76237..bc9a6f7 100644
--- a/gn_auth/auth/authorisation/data/phenotypes.py
+++ b/gn_auth/auth/authorisation/data/phenotypes.py
@@ -1,18 +1,33 @@
"""Handle linking of Phenotype data to the Auth(entic|oris)ation system."""
import uuid
+import logging
+from functools import reduce
from dataclasses import asdict
from typing import Any, Iterable
+from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as authdb
from MySQLdb.cursors import DictCursor
+from flask import request, jsonify, Response, Blueprint, current_app as app
-from gn_auth.auth.db import sqlite3 as authdb
-from gn_auth.auth.db import mariadb as gn3db
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+
+from gn_auth.auth.errors import AuthorisationError
+from gn_auth.auth.authorisation.resources.checks import can_delete
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+from gn_auth.auth.authorisation.resources.groups.models import Group, group_resource
+
+
+from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authorisation.checks import require_json
+from gn_auth.auth.authorisation.resources.checks import authorised_for_spec
+
+logger = logging.getLogger(__name__)
+phenosbp = Blueprint("phenotypes", __name__)
-from gn_auth.auth.authorisation.checks import authorised_p
-from gn_auth.auth.authorisation.resources.groups.models import Group
def linked_phenotype_data(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
species: str = "") -> Iterable[dict[str, Any]]:
"""Retrieve phenotype data linked to user groups."""
authkeys = ("SpeciesId", "InbredSetId", "PublishFreezeId", "PublishXRefId")
@@ -47,43 +62,8 @@ def linked_phenotype_data(
gn3cursor.execute(query, params)
return (item for item in gn3cursor.fetchall())
-@authorised_p(("system:data:link-to-group",),
- error_description=(
- "You do not have sufficient privileges to link data to (a) "
- "group(s)."),
- oauth2_scope="profile group resource")
-def ungrouped_phenotype_data(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection):
- """Retrieve phenotype data that is not linked to any user group."""
- with gn3conn.cursor() as cursor:
- params = tuple(
- (row["SpeciesId"], row["InbredSetId"], row["PublishFreezeId"],
- row["PublishXRefId"])
- for row in linked_phenotype_data(authconn, gn3conn))
- paramstr = ", ".join(["(?, ?, ?, ?)"] * len(params))
- query = (
- "SELECT spc.SpeciesId, spc.SpeciesName, iset.InbredSetId, "
- "iset.InbredSetName, pf.Id AS PublishFreezeId, "
- "pf.Name AS dataset_name, pf.FullName AS dataset_fullname, "
- "pf.ShortName AS dataset_shortname, pxr.Id AS PublishXRefId "
- "FROM "
- "Species AS spc "
- "INNER JOIN InbredSet AS iset "
- "ON spc.SpeciesId=iset.SpeciesId "
- "INNER JOIN PublishFreeze AS pf "
- "ON iset.InbredSetId=pf.InbredSetId "
- "INNER JOIN PublishXRef AS pxr "
- "ON pf.InbredSetId=pxr.InbredSetId")
- if len(params) > 0:
- query = query + (
- f" WHERE (iset.InbredSetId, pf.Id, pxr.Id) NOT IN ({paramstr})")
-
- cursor.execute(query, params)
- return tuple(dict(row) for row in cursor.fetchall())
-
- return tuple()
-def __traits__(gn3conn: gn3db.DbConnection, params: tuple[dict, ...]) -> tuple[dict, ...]:
+def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]:
"""An internal utility function. Don't use outside of this module."""
if len(params) < 1:
return tuple()
@@ -110,21 +90,35 @@ def __traits__(gn3conn: gn3db.DbConnection, params: tuple[dict, ...]) -> tuple[d
for itm in sublist))
return cursor.fetchall()
-@authorised_p(("system:data:link-to-group",),
- error_description=(
- "You do not have sufficient privileges to link data to (a) "
- "group(s)."),
- oauth2_scope="profile group resource")
+
def link_phenotype_data(
- authconn:authdb.DbConnection, gn3conn: gn3db.DbConnection, group: Group,
- traits: tuple[dict, ...]) -> dict:
+ authconn: authdb.DbConnection,
+ user: User,
+ group: Group,
+ traits: tuple[dict, ...]
+) -> dict:
"""Link phenotype traits to a user group."""
+ if not (authorised_for_spec(
+ authconn,
+ user.user_id,
+ system_resource(authconn).resource_id,
+ "(AND system:data:link-to-group)")
+ or
+ authorised_for_spec(
+ authconn,
+ user.user_id,
+ group_resource(authconn, group.group_id).resource_id,
+ "(AND group:data:link-to-group)")
+ ):
+ raise AuthorisationError(
+ "You do not have sufficient privileges to link data to group "
+ f"'{group.group_name}'.")
with authdb.cursor(authconn) as cursor:
params = tuple({
"data_link_id": str(uuid.uuid4()),
"group_id": str(group.group_id),
**item
- } for item in __traits__(gn3conn, traits))
+ } for item in traits)
cursor.executemany(
"INSERT INTO linked_phenotype_data "
"VALUES ("
@@ -139,3 +133,156 @@ def link_phenotype_data(
"group": asdict(group),
"traits": params
}
+
+
+def unlink_from_resources(
+ cursor: authdb.DbCursor,
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[uuid.UUID, ...]:
+ """Unlink phenotypes from resources."""
+ # TODO: Delete in batches
+ cursor.executemany("DELETE FROM phenotype_resources "
+ "WHERE data_link_id=? RETURNING resource_id",
+ tuple((str(_id),) for _id in data_link_ids))
+ return tuple(uuid.UUID(row["resource_id"]) for row in cursor.fetchall())
+
+
+def delete_resources(
+ cursor: authdb.DbCursor,
+ resource_ids: tuple[uuid.UUID, ...]
+) -> tuple[uuid.UUID, ...]:
+ """Delete the specified phenotype resources."""
+ # TODO: Delete in batches
+ cursor.executemany("DELETE FROM resources "
+ "WHERE resource_id=? RETURNING resource_id",
+ tuple((str(_id),) for _id in resource_ids))
+ return tuple(uuid.UUID(row["resource_id"]) for row in cursor.fetchall())
+
+
+def fetch_data_link_ids(
+ cursor: authdb.DbCursor,
+ species_id: int,
+ population_id: int,
+ dataset_id: int,
+ xref_ids: tuple[int, ...]
+) -> tuple[uuid.UUID, ...]:
+ """Fetch `data_link_id` values for phenotypes."""
+ paramstr = ", ".join(["(?, ?, ?, ?)"] * len(xref_ids))
+ cursor.execute(
+ "SELECT data_link_id FROM linked_phenotype_data "
+ "WHERE (SpeciesId, InbredSetId, PublishFreezeId, PublishXRefId) IN "
+ f"({paramstr})",
+ tuple(str(field) for arow in
+ ((species_id, population_id, dataset_id, xref_id)
+ for xref_id in xref_ids)
+ for field in arow))
+ return tuple(uuid.UUID(row["data_link_id"]) for row in cursor.fetchall())
+
+
+def fetch_resource_id(cursor: authdb.DbCursor,
+ data_link_ids: tuple[uuid.UUID, ...]) -> uuid.UUID:
+ """Retrieve the ID of the resource where the data is linked to.
+
+ RAISES: InvalidResourceError in the case where more the data_link_ids belong
+ to more than one resource."""
+ _paramstr = ", ".join(["?"] * len(data_link_ids))
+ cursor.execute(
+ "SELECT DISTINCT(resource_id) FROM phenotype_resources "
+ f"WHERE data_link_id IN ({_paramstr})",
+ tuple(str(_id) for _id in data_link_ids))
+ _ids = tuple(uuid.UUID(row['resource_id']) for row in cursor.fetchall())
+ if len(_ids) != 1:
+ raise AuthorisationError(
+ f"Expected data from 1 resource, got {len(_ids)} resources.")
+ return _ids[0]
+
+
+def delete_linked_data(
+ cursor: authdb.DbCursor,
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> int:
+ """Delete the actual linked data."""
+ # TODO: Delete in batches
+ cursor.executemany("DELETE FROM linked_phenotype_data "
+ "WHERE data_link_id=?",
+ tuple((str(_id),) for _id in data_link_ids))
+ return cursor.rowcount
+
+
+@phenosbp.route("/<int:species_id>/<int:population_id>/<int:dataset_id>/delete",
+ methods=["POST"])
+@require_json
+def delete_linked_phenotypes_data(
+ species_id: int,
+ population_id: int,
+ dataset_id: int
+) -> Response:
+ """Delete the linked phenotypes data from the database."""
+ db_uri = app.config["AUTH_DB"]
+ with (require_oauth.acquire("profile group resource") as _token,
+ authdb.connection(db_uri) as auth_conn,
+ authdb.cursor(auth_conn) as cursor):
+ _deleted = 0
+ xref_ids = tuple(request.json.get("xref_ids", []))#type: ignore[union-attr]
+ if len(xref_ids) > 0:
+ # TODO: Use background job, for huge number of xref_ids
+ data_link_ids = fetch_data_link_ids(
+ cursor, species_id, population_id, dataset_id, xref_ids)
+ resource_id = fetch_resource_id(cursor, data_link_ids)
+ # - Does user have DELETE privilege on the data
+ if not can_delete(auth_conn, _token.user.user_id, resource_id):
+ # - No: Raise `AuthorisationError` and bail!
+ raise AuthorisationError(
+ "You are not allowed to delete this resource's data.")
+ # - YES: go ahead and delete data as below.
+ _resources_ids = unlink_from_resources(cursor, data_link_ids)
+ delete_resources(cursor, _resources_ids)
+ _deleted = delete_linked_data(cursor, data_link_ids)
+
+ return jsonify({
+ # TODO: "status": "sent-to-background"/"completed"/"failed"
+ # TODO: "status-url": <status-check-uri>
+ "requested": len(xref_ids),
+ "deleted": _deleted
+ })
+
+
+def __organise_resources_data__(acc, curr) -> dict:
+ logger.debug("ORGANISING... %s", dict(curr))
+ resource_row = acc.get(curr["resource_id"], {
+ "resource_id": curr["resource_id"],
+ "resource_data": tuple(),
+ })
+ return {
+ **acc,
+ curr["resource_id"]: {
+ **resource_row,
+ "resource_data": resource_row["resource_data"] + (
+ f'{curr["dataset_name"]}::{curr["trait_id"]}',)
+ }
+ }
+
+
+def resources_by_datasets_and_traits(
+ authconn: authdb.DbConnection,
+ dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+ """Fetch resources by their attached datasets and traits."""
+ paramstr = ", ".join(["(?, ?)"] * len(dsets_traits))
+ query = (
+ "SELECT r.*, rc.*, lpd.dataset_name, lpd.PublishXRefId AS trait_id "
+ "FROM linked_phenotype_data AS lpd "
+ "INNER JOIN phenotype_resources AS pr "
+ "ON lpd.data_link_id=pr.data_link_id "
+ "INNER JOIN resources AS r ON pr.resource_id=r.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE (lpd.dataset_name, lpd.PublishXRefId) "
+ f"IN ({paramstr})")
+ with authdb.cursor(authconn) as cursor:
+ cursor.execute(
+ query, tuple(item for row in dsets_traits for item in row))
+ return tuple(reduce(
+ __organise_resources_data__,
+ cursor.fetchall(),
+ {}).values())
diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py
index 7ed69e3..0ffc08e 100644
--- a/gn_auth/auth/authorisation/data/views.py
+++ b/gn_auth/auth/authorisation/data/views.py
@@ -2,15 +2,20 @@
import sys
import uuid
import json
-from dataclasses import asdict
+import logging
from typing import Any
-from functools import partial
+from functools import reduce, partial
import redis
from MySQLdb.cursors import DictCursor
from authlib.integrations.flask_oauth2.errors import _HTTPException
from flask import request, jsonify, Response, Blueprint, current_app as app
+
+from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
+
from gn_auth import jobs
from gn_auth.commands import run_async_cmd
@@ -18,54 +23,30 @@ from gn_auth.auth.requests import request_json
from gn_auth.auth.errors import InvalidData, NotFoundError
from gn_auth.auth.authorisation.resources.groups.models import group_by_id
-from ...db import sqlite3 as db
-from ...db import mariadb as gn3db
-from ...db.sqlite3 import with_db_connection
-
from ..checks import require_json
-from ..users.models import user_resource_roles
-
-from ..resources.checks import authorised_for
-from ..resources.models import (
- user_resources, public_resources, attach_resources_data)
-
from ...authentication.users import User
from ...authentication.oauth2.resource_server import require_oauth
-from ..data.phenotypes import link_phenotype_data
-from ..data.mrna import link_mrna_data, ungrouped_mrna_data
-from ..data.genotypes import link_genotype_data, ungrouped_genotype_data
-
+from .mrna import (
+ link_mrna_data,
+ ungrouped_mrna_data,
+ resources_by_datasets_and_traits as mrna_resources_by_datasets_and_traits)
+from .genotypes import (
+ link_genotype_data,
+ ungrouped_genotype_data,
+ resources_by_datasets_and_traits as geno_resources_by_datasets_and_traits)
+from .phenotypes import (
+ phenosbp,
+ link_phenotype_data,
+ pheno_traits_from_db,
+ resources_by_datasets_and_traits as pheno_resources_by_datasets_and_traits)
+
+
+logger = logging.getLogger(__name__)
data = Blueprint("data", __name__)
+data.register_blueprint(phenosbp, url_prefix="/phenotypes")
-def build_trait_name(trait_fullname):
- """
- Initialises the trait's name, and other values from the search data provided
-
- This is a copy of `gn3.db.traits.build_trait_name` function.
- """
- def dataset_type(dset_name):
- if dset_name.find('Temp') >= 0:
- return "Temp"
- if dset_name.find('Geno') >= 0:
- return "Geno"
- if dset_name.find('Publish') >= 0:
- return "Publish"
- return "ProbeSet"
-
- name_parts = trait_fullname.split("::")
- assert len(name_parts) >= 2, f"Name format error: '{trait_fullname}'"
- dataset_name = name_parts[0]
- dataset_type = dataset_type(dataset_name)
- return {
- "db": {
- "dataset_name": dataset_name,
- "dataset_type": dataset_type},
- "trait_fullname": trait_fullname,
- "trait_name": name_parts[1],
- "cellid": name_parts[2] if len(name_parts) == 3 else ""
- }
@data.route("species")
def list_species() -> Response:
@@ -77,101 +58,144 @@ def list_species() -> Response:
@data.route("/authorisation", methods=["POST"])
@require_json
-def authorisation() -> Response:
+def authorisation() -> Response:# pylint: disable=[too-many-locals]
"""Retrieve the authorisation level for datasets/traits for the user."""
# Access endpoint with something like:
- # curl -X POST http://127.0.0.1:8080/api/oauth2/data/authorisation \
+ # curl -X POST http://127.0.0.1:8081/auth/data/authorisation \
# -H "Content-Type: application/json" \
# -d '{"traits": ["HC_M2_0606_P::1442370_at", "BXDGeno::01.001.695",
# "BXDPublish::10001"]}'
+ def __organise_traits__(acc, curr):
+ dset, _trt = curr
+ key = "ProbeSet"
+ if dset.endswith("Publish"):
+ key = "Publish"
+ elif dset.endswith("Geno"):
+ key="Geno"
+ elif dset.endswith("Temp"):
+ key = "Temp"
+ else:
+ key = "ProbeSet"
+
+ return {
+ **acc,
+ key: acc.get(key, tuple()) + (curr,)
+ }
+ _dset_traits: dict[str, tuple[tuple[str, str], ...]] = reduce(
+ __organise_traits__,
+ (
+ (dset.strip(), trt.strip()) for dset, trt in
+ (trtstr.split("::") for trtstr in
+ request_json().get("traits", []))),
+ {key: tuple() for key in ("Publish", "ProbeSet", "Geno", "Temp")})
+
db_uri = app.config["AUTH_DB"]
- privileges = {}
user = User(uuid.uuid4(), "anon@ymous.user", "Anonymous User")
- with db.connection(db_uri) as auth_conn:
- try:
- with require_oauth.acquire("profile group resource") as _token:
- user = _token.user
- resources = attach_resources_data(
- auth_conn, user_resources(auth_conn, _token.user))
- resources_roles = user_resource_roles(auth_conn, _token.user)
- privileges = {
- resource_id: tuple(
- privilege.privilege_id
- for roles in resources_roles[resource_id]
- for privilege in roles.privileges)#("group:resource:view-resource",)
- for resource_id, is_authorised
- in authorised_for(
- auth_conn, _token.user,
- ("group:resource:view-resource",), tuple(
- resource.resource_id for resource in resources)).items()
- if is_authorised
- }
- except _HTTPException as exc:
- err_msg = json.loads(exc.body)
- if err_msg["error"] == "missing_authorization":
- resources = attach_resources_data(
- auth_conn, public_resources(auth_conn))
- else:
- raise exc from None
-
- def __gen_key__(resource, data_item):
- if resource.resource_category.resource_category_key.lower() == "phenotype":
- return (
- f"{resource.resource_category.resource_category_key.lower()}::"
- f"{data_item['dataset_name']}::{data_item['PublishXRefId']}")
- return (
- f"{resource.resource_category.resource_category_key.lower()}::"
- f"{data_item['dataset_name']}")
-
- data_to_resource_map = {
- __gen_key__(resource, data_item): resource.resource_id
- for resource in resources
- for data_item in resource.resource_data
+ with (db.connection(db_uri) as authconn, db.cursor(authconn) as cursor):
+ _all_resources = {
+ _rrow["resource_id"]: _rrow
+ for _rtypes in (
+ pheno_resources_by_datasets_and_traits(
+ authconn, _dset_traits["Publish"]),
+ geno_resources_by_datasets_and_traits(
+ authconn, _dset_traits["Geno"]),
+ mrna_resources_by_datasets_and_traits(
+ authconn, _dset_traits["ProbeSet"]))
+ for _rrow in _rtypes
}
- privileges = {
- **{
- resource.resource_id: ("system:resource:public-read",)
- for resource in resources if resource.public
- },
- **privileges}
-
- args = request.get_json()
- traits_names = args["traits"] # type: ignore[index]
- def __translate__(val):
+ if (len(_all_resources.keys()) == 0 and
+ len(_dset_traits.get("Temp", tuple())) == 0):
+ raise NotFoundError(
+ "No resource(s) found for specified trait(s). Do(es) the "
+ "trait(s) actually exist?")
+
+ # Handle Temp traits specially - they should be public/anonymous resources
+ if len(_dset_traits.get("Temp", tuple())) > 0:
+ # Create a synthetic public resource for Temp traits
+ # Use a predictable ID to identify synthetic temp resources
+ temp_resource_id = "gn-auth-temp-traits"
+ _all_resources[temp_resource_id] = {
+ "resource_id": temp_resource_id,
+ "resource_data": tuple(f"{dset}::{trait}" for dset, trait in _dset_traits["Temp"])
+ }
+
+ _resource_ids = tuple(_all_resources.keys())
+
+
+ def __explode_resource_data__(trait_fullname):
+ _dset, _trt = trait_fullname.split("::")
return {
- "Temp": "Temp",
- "ProbeSet": "mRNA",
- "Geno": "Genotype",
- "Publish": "Phenotype"
- }[val]
-
- def __trait_key__(trait):
- dataset_type = __translate__(trait['db']['dataset_type']).lower()
- dataset_name = trait["db"]["dataset_name"]
- if dataset_type == "phenotype":
- return f"{dataset_type}::{dataset_name}::{trait['trait_name']}"
- return f"{dataset_type}::{dataset_name}"
-
- return jsonify(tuple(
- {
- "user": asdict(user),
- **{key:trait[key] for key in ("trait_fullname", "trait_name")},
- "dataset_name": trait["db"]["dataset_name"],
- "dataset_type": __translate__(trait["db"]["dataset_type"]),
- "resource_id": data_to_resource_map.get(__trait_key__(trait)),
- "privileges": privileges.get(
- data_to_resource_map.get(
- __trait_key__(trait),
- uuid.UUID("4afa415e-94cb-4189-b2c6-f9ce2b6a878d")),
- tuple()) + (
- # Temporary traits do not exist in db: Set them
- # as public-read
- ("system:resource:public-read",)
- if trait["db"]["dataset_type"] == "Temp"
- else tuple())
- } for trait in
- (build_trait_name(trait_fullname)
- for trait_fullname in traits_names)))
+ "dataset_name": _dset,
+ "dataset_type": (
+ "Phenotype" if _dset.endswith("Publish")
+ else ("Genotype" if _dset.endswith("Geno")
+ else ("Temporary" if _dset.endswith("Temp")
+ else "mRNA"))),
+ "trait_name": _trt,
+ "trait_fullname": trait_fullname
+ }
+
+ _paramstr = ", ".join(["?"] * len(_resource_ids))
+ _privileges_by_resource: dict[str, tuple[str, ...]] = {}
+
+ # Separate synthetic temp resources from real resources
+ temp_resource_id = "gn-auth-temp-traits"
+ real_resource_ids = tuple(rid for rid in _resource_ids if rid != temp_resource_id)
+
+ # Query privileges only for real resources
+ if len(real_resource_ids) > 0:
+ real_paramstr = ", ".join(["?"] * len(real_resource_ids))
+ try:
+ with require_oauth.acquire("profile group resource") as _token:
+ user = _token.user
+ cursor.execute(
+ "SELECT ur.resource_id, r.role_id, rp.privilege_id "
+ "FROM user_roles AS ur "
+ "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+ "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+ "WHERE ur.user_id = ? "
+ f"AND ur.resource_id IN ({real_paramstr})",
+ (str(user.user_id),) + real_resource_ids
+ )
+ _privileges_by_resource = reduce(
+ lambda acc, curr: {
+ **acc,
+ curr["resource_id"]: (
+ acc.get(curr["resource_id"], tuple())
+ + (curr["privilege_id"],))
+ },
+ cursor.fetchall(),
+ {})
+ except _HTTPException as exc:
+ err_msg = json.loads(exc.body)
+ if err_msg["error"] == "missing_authorization":
+ cursor.execute(
+ "SELECT rsc.resource_id "
+ "FROM resources AS rsc "
+ "WHERE rsc.public = '1' "
+ f"AND rsc.resource_id IN ({real_paramstr}) ",
+ real_resource_ids)
+ _privileges_by_resource = {
+ row["resource_id"]: ('group:resource:view-resource',)
+ for row in cursor.fetchall()
+ }
+ else:
+ raise exc from None
+
+ # Temp resources are always publicly viewable
+ if temp_resource_id in _resource_ids:
+ _privileges_by_resource[temp_resource_id] = ('group:resource:view-resource',)
+
+ return jsonify({
+ "authorisation": [{
+ **resource,
+ "resource_data": [
+ __explode_resource_data__(item)
+ for item in resource["resource_data"]],
+ "privileges": _privileges_by_resource.get(resource["resource_id"], tuple())
+ } for resource in _all_resources.values()]
+ })
+
def __search_mrna__():
query = __request_key__("query", "")
@@ -182,12 +206,12 @@ def __search_mrna__():
ungrouped_mrna_data, gn3conn=gn3conn, search_query=query,
selected=__request_key_list__("selected"),
limit=limit, offset=offset)
- return jsonify(with_db_connection(__ungrouped__))
+ return jsonify(with_db_connection(app.config["SQL_URI"], __ungrouped__))
def __request_key__(key: str, default: Any = ""):
if bool(request_json()):
return request_json().get(#type: ignore[union-attr]
- key, request.args.get(key, request_json().get(key, default)))
+ key, request.args.get(key, default))
return request.args.get(key, request_json().get(key, default))
def __request_key_list__(key: str, default: tuple[Any, ...] = tuple()):
@@ -207,7 +231,7 @@ def __search_genotypes__():
ungrouped_genotype_data, gn3conn=gn3conn, search_query=query,
selected=__request_key_list__("selected"),
limit=limit, offset=offset)
- return jsonify(with_db_connection(__ungrouped__))
+ return jsonify(with_db_connection(app.config["SQL_URI"], __ungrouped__))
def __search_phenotypes__():
# launch the external process to search for phenotypes
@@ -216,7 +240,7 @@ def __search_phenotypes__():
job_id = uuid.uuid4()
selected = __request_key__("selected_traits", [])
command =[
- sys.executable, "-m", "scripts.search_phenotypes",
+ sys.executable, "-m", "gn_auth.scripts.search_phenotypes",
__request_key__("species_name"),
__request_key__("query"),
str(job_id),
@@ -282,6 +306,7 @@ def link_genotypes() -> Response:
return link_genotype_data(conn, group_by_id(conn, group_id), datasets)
return jsonify(with_db_connection(
+ app.config["SQL_URI"],
partial(__link__, **__values__(request_json()))))
@data.route("/link/mrna", methods=["POST"])
@@ -307,9 +332,11 @@ def link_mrna() -> Response:
return link_mrna_data(conn, group_by_id(conn, group_id), datasets)
return jsonify(with_db_connection(
+ app.config["SQL_URI"],
partial(__link__, **__values__(request_json()))))
@data.route("/link/phenotype", methods=["POST"])
+@require_oauth("profile group resource")
def link_phenotype() -> Response:
"""Link phenotype data to group."""
def __values__(form):
@@ -325,14 +352,28 @@ def link_phenotype() -> Response:
raise InvalidData("Expected at least one dataset to be provided.")
return {
"group_id": uuid.UUID(form["group_id"]),
- "traits": form["selected"]
+ "traits": form["selected"],
+ "using_raw_ids": bool(form.get("using-raw-ids") == "on")
}
- with gn3db.database_connection(app.config["SQL_URI"]) as gn3conn:
- def __link__(conn: db.DbConnection, group_id: uuid.UUID,
- traits: tuple[dict, ...]) -> dict:
- return link_phenotype_data(
- conn, gn3conn, group_by_id(conn, group_id), traits)
+ with (require_oauth.acquire("profile group resource") as token,
+ gn3db.database_connection(app.config["SQL_URI"]) as gn3conn):
+ def __link__(
+ conn: db.DbConnection,
+ group_id: uuid.UUID,
+ traits: tuple[dict, ...],
+ using_raw_ids: bool = False
+ ) -> dict:
+ if using_raw_ids:
+ return link_phenotype_data(conn,
+ token.user,
+ group_by_id(conn, group_id),
+ traits)
+ return link_phenotype_data(conn,
+ token.user,
+ group_by_id(conn, group_id),
+ pheno_traits_from_db(gn3conn, traits))
return jsonify(with_db_connection(
+ app.config["SQL_URI"],
partial(__link__, **__values__(request_json()))))
diff --git a/gn_auth/auth/authorisation/privileges/models.py b/gn_auth/auth/authorisation/privileges/models.py
index 77be7c0..cd23a0c 100644
--- a/gn_auth/auth/authorisation/privileges/models.py
+++ b/gn_auth/auth/authorisation/privileges/models.py
@@ -3,8 +3,8 @@ from dataclasses import dataclass
from typing import Iterable, Optional
import sqlite3
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User
diff --git a/gn_auth/auth/authorisation/resources/base.py b/gn_auth/auth/authorisation/resources/base.py
index 333ba0d..e4a1239 100644
--- a/gn_auth/auth/authorisation/resources/base.py
+++ b/gn_auth/auth/authorisation/resources/base.py
@@ -1,10 +1,17 @@
"""Base types for resources."""
+import logging
+import datetime
from uuid import UUID
from dataclasses import dataclass
-from typing import Any, Sequence
+from typing import Any, Sequence, Optional
import sqlite3
+from gn_auth.auth.authentication.users import User
+
+
+logger = logging.getLogger(__name__)
+
@dataclass(frozen=True)
class ResourceCategory:
@@ -22,10 +29,49 @@ class Resource:
resource_category: ResourceCategory
public: bool
resource_data: Sequence[dict[str, Any]] = tuple()
+ created_by: Optional[User] = None
+ created_at: datetime.datetime = datetime.datetime(1970, 1, 1, 0, 0, 0)
+
+ @staticmethod
+ def from_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ resource,
+ resource_id: Optional[UUID] = None,
+ resource_name: Optional[str] = None,
+ resource_category: Optional[ResourceCategory] = None,
+ public: Optional[bool] = None,
+ resource_data: Optional[Sequence[dict[str, Any]]] = None,
+ created_by: Optional[User] = None,
+ created_at: Optional[datetime.datetime] = None
+ ):
+ """Takes a Resource object `resource` and updates the attributes specified in `kwargs`."""
+ return Resource(
+ resource_id=resource_id or resource.resource_id,
+ resource_name=resource_name or resource.resource_name,
+ resource_category=resource_category or resource.resource_category,
+ public=bool(public) or resource.public,
+ resource_data=resource_data or resource.resource_data,
+ created_by=created_by or resource.created_by,
+ created_at=created_at or resource.created_at)
def resource_from_dbrow(row: sqlite3.Row):
"""Convert an SQLite3 resultset row into a resource."""
+ try:
+ created_at = datetime.datetime.fromtimestamp(row["created_at"])
+ except IndexError as _ie:
+ created_at = datetime.datetime(1970, 1, 1, 0, 0, 0)
+
+ try:
+ created_by = User.from_sqlite3_row({
+ "user_id": row["creator_user_id"],
+ "email": row["creator_email"],
+ "name": row["creator_name"],
+ "verified": row["creator_verified"],
+ "created": row["creator_created"]
+ })
+ except IndexError as _ie:
+ created_by = None
+
return Resource(
resource_id=UUID(row["resource_id"]),
resource_name=row["resource_name"],
@@ -33,4 +79,6 @@ def resource_from_dbrow(row: sqlite3.Row):
UUID(row["resource_category_id"]),
row["resource_category_key"],
row["resource_category_description"]),
- public=bool(int(row["public"])))
+ public=bool(int(row["public"])),
+ created_by=created_by,
+ created_at=created_at)
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index d8e3a9f..7b33fcc 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -1,47 +1,93 @@
"""Handle authorisation checks for resources"""
-from uuid import UUID
-from functools import reduce
-from typing import Sequence
-
-from ...db import sqlite3 as db
-from ...authentication.users import User
-
-def __organise_privileges_by_resource_id__(rows):
- def __organise__(privs, row):
- resource_id = UUID(row["resource_id"])
- return {
- **privs,
- resource_id: (row["privilege_id"],) + privs.get(
- resource_id, tuple())
- }
- return reduce(__organise__, rows, {})
-
-def authorised_for(conn: db.DbConnection,
- user: User,
- privileges: tuple[str, ...],
- resource_ids: Sequence[UUID]) -> dict[UUID, bool]:
+import uuid
+import logging
+import warnings
+
+import gn_libs.sqlite3 as authdb
+from gn_libs.privileges import check
+
+from .system.models import system_resource
+
+
+logger = logging.getLogger(__name__)
+
+
+def authorised_for_spec(
+ conn: authdb.DbConnection,
+ user_id: uuid.UUID,
+ resource_id: uuid.UUID,
+ auth_spec: str
+) -> bool:
"""
- Check whether `user` is authorised to access `resources` according to given
- `privileges`.
+ Check that a user, identified with `user_id`, has a set of privileges that
+ satisfy the `auth_spec` for the resource identified with `resource_id`.
"""
- with db.cursor(conn) as cursor:
+ with authdb.cursor(conn) as cursor:
+ _query = (
+ "SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
+ "privileges.* "
+ "FROM resources INNER JOIN user_roles "
+ "ON resources.resource_id=user_roles.resource_id "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id "
+ "INNER JOIN privileges "
+ "ON role_privileges.privilege_id=privileges.privilege_id "
+ "WHERE resources.resource_id=? "
+ "AND user_roles.user_id=?")
cursor.execute(
- ("SELECT ur.*, rp.privilege_id FROM "
- "user_roles AS ur "
- "INNER JOIN roles AS r ON ur.role_id=r.role_id "
- "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
- "WHERE ur.user_id=? "
- f"AND ur.resource_id IN ({', '.join(['?']*len(resource_ids))})"
- f"AND rp.privilege_id IN ({', '.join(['?']*len(privileges))})"),
- ((str(user.user_id),) + tuple(
- str(r_id) for r_id in resource_ids) + tuple(privileges)))
- resource_privileges = __organise_privileges_by_resource_id__(
- cursor.fetchall())
- authorised = tuple(resource_id for resource_id, res_privileges
- in resource_privileges.items()
- if all(priv in res_privileges
- for priv in privileges))
- return {
- resource_id: resource_id in authorised
- for resource_id in resource_ids
- }
+ _query,
+ (str(resource_id), str(user_id)))
+ _privileges = tuple(row["privilege_id"] for row in cursor.fetchall())
+ return check(auth_spec, _privileges)
+
+
+def can_delete(
+ conn: authdb.DbConnection,
+ user_id: uuid.UUID,
+ resource_id: uuid.UUID
+) -> bool:
+ """Check whether user is allowed delete a resource and/or its data."""
+ warnings.warn(
+ (f"Function '{__name__}.can_delete' is deprecated. "
+ "Use `gn_libs.privileges.resources.can_delete` instead."),
+ category=DeprecationWarning,
+ stacklevel=2)
+ return (
+ authorised_for_spec(# resource-level delete access
+ conn,
+ user_id,
+ resource_id,
+ "(OR group:resource:delete-resource system:resource:delete)")
+ or
+ authorised_for_spec(# system-wide delete access
+ conn,
+ user_id,
+ system_resource(conn).resource_id,
+ "(AND system:system-wide:data:delete)"))
+
+
+def can_edit(
+ conn: authdb.DbConnection,
+ user_id: uuid.UUID,
+ resource_id: uuid.UUID
+) -> bool:
+ """Check whether user is allowed edit a resource and/or its data."""
+ warnings.warn(
+ (f"Function '{__name__}.can_edit' is deprecated. "
+ "Use `gn_libs.privileges.resources.can_edit` instead."),
+ category=DeprecationWarning,
+ stacklevel=2)
+ return (
+ authorised_for_spec(
+ # resource-level edit access: user has edit access to his resource.
+ conn,
+ user_id,
+ resource_id,
+ "(OR group:resource:edit-resource system:resource:edit)")
+ or
+ authorised_for_spec(
+ # system-wide edit access: user can edit any/all resource(s).
+ conn,
+ user_id,
+ system_resource(conn).resource_id,
+ "(OR system:system-wide:data:edit system:resource:edit)"))
diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py
index 5d2b72b..13a0c87 100644
--- a/gn_auth/auth/authorisation/resources/common.py
+++ b/gn_auth/auth/authorisation/resources/common.py
@@ -1,10 +1,10 @@
"""Utilities common to more than one resource."""
import uuid
-from sqlite3 import Cursor
+from gn_libs import sqlite3 as db
def assign_resource_owner_role(
- cursor: Cursor,
+ cursor: db.DbCursor,
resource_id: uuid.UUID,
user_id: uuid.UUID
) -> dict:
diff --git a/gn_auth/auth/authorisation/resources/genotypes/models.py b/gn_auth/auth/authorisation/resources/genotypes/models.py
index e8dca9b..762ee7c 100644
--- a/gn_auth/auth/authorisation/resources/genotypes/models.py
+++ b/gn_auth/auth/authorisation/resources/genotypes/models.py
@@ -27,14 +27,15 @@ def resource_data(
def link_data_to_resource(
conn: db.DbConnection,
resource: Resource,
- data_link_id: uuid.UUID) -> dict:
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
"""Link Genotype data with a resource using the GUI."""
with db.cursor(conn) as cursor:
- params = {
+ params = tuple({
"resource_id": str(resource.resource_id),
"data_link_id": str(data_link_id)
- }
- cursor.execute(
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
"INSERT INTO genotype_resources VALUES"
"(:resource_id, :data_link_id)",
params)
@@ -68,7 +69,7 @@ def attach_resources_data(
return __attach_data__(cursor.fetchall(), resources)
-def insert_and_link_data_to_resource(# pylint: disable=[too-many-arguments]
+def insert_and_link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
cursor,
resource_id: uuid.UUID,
group_id: uuid.UUID,
diff --git a/gn_auth/auth/authorisation/resources/groups/data.py b/gn_auth/auth/authorisation/resources/groups/data.py
index 702955d..ad0dfba 100644
--- a/gn_auth/auth/authorisation/resources/groups/data.py
+++ b/gn_auth/auth/authorisation/resources/groups/data.py
@@ -1,7 +1,7 @@
"""Handles the resource objects' data."""
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.auth.errors import NotFoundError
@@ -9,7 +9,7 @@ from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups import Group
def __fetch_mrna_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch mRNA Assay data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -27,7 +27,7 @@ def __fetch_mrna_data_by_ids__(
raise NotFoundError("Could not find mRNA Assay data with the given ID.")
def __fetch_geno_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch genotype data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -45,7 +45,7 @@ def __fetch_geno_data_by_ids__(
raise NotFoundError("Could not find Genotype data with the given ID.")
def __fetch_pheno_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch phenotype data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -67,7 +67,7 @@ def __fetch_pheno_data_by_ids__(
"Could not find Phenotype/Publish data with the given IDs.")
def __fetch_data_by_id(
- conn: gn3db.DbConnection, dataset_type: str,
+ conn: gn3db.Connection, dataset_type: str,
dataset_ids: tuple[str, ...]) -> tuple[dict, ...]:
"""Fetch data from MySQL by IDs."""
fetch_fns = {
@@ -83,7 +83,7 @@ def __fetch_data_by_id(
"group(s)."),
oauth2_scope="profile group resource")
def link_data_to_group(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
dataset_type: str, dataset_ids: tuple[str, ...], group: Group) -> tuple[
dict, ...]:
"""Link the given data to the specified group."""
diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py
index 3263e37..79bead4 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -1,5 +1,6 @@
"""Handle the management of resource/user groups."""
import json
+import datetime
from uuid import UUID, uuid4
from functools import reduce
from dataclasses import dataclass
@@ -8,14 +9,18 @@ from typing import Any, Sequence, Iterable, Optional
import sqlite3
from flask import g
from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.either import Left, Right, Either
+from pymonad.tools import monad_from_none_or_value
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User, user_by_id
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.privileges import Privilege
-from gn_auth.auth.authorisation.resources.base import Resource
from gn_auth.auth.authorisation.resources.errors import MissingGroupError
+from gn_auth.auth.authorisation.resources.base import (
+ Resource,
+ resource_from_dbrow)
from gn_auth.auth.errors import (
NotFoundError, AuthorisationError, InconsistencyError)
from gn_auth.auth.authorisation.roles.models import (
@@ -93,8 +98,12 @@ def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]:
"create a new group."),
oauth2_scope="profile group")
def create_group(
- conn: db.DbConnection, group_name: str, group_leader: User,
- group_description: Optional[str] = None) -> Group:
+ conn: db.DbConnection,
+ group_name: str,
+ group_leader: User,
+ group_description: Optional[str] = None,
+ creator: Optional[User] = None
+) -> Group:
"""Create a new group."""
def resource_category_by_key(
cursor: db.DbCursor, category_key: str):
@@ -118,31 +127,35 @@ def create_group(
cursor, group_name, (
{"group_description": group_description}
if group_description else {}))
- group_resource = {
+ _group_resource_id = uuid4()
+ _group_resource = {
"group_id": str(new_group.group_id),
- "resource_id": str(uuid4()),
+ "resource_id": str(_group_resource_id),
"resource_name": group_name,
"resource_category_id": str(
resource_category_by_key(
cursor, "group")["resource_category_id"]
),
- "public": 0
+ "public": 0,
+ "created_by": str(
+ creator.user_id if creator else group_leader.user_id),
+ "created_at": datetime.datetime.now().timestamp()
}
cursor.execute(
"INSERT INTO resources VALUES "
- "(:resource_id, :resource_name, :resource_category_id, :public)",
- group_resource)
+ "(:resource_id, :resource_name, :resource_category_id, :public, "
+ ":created_by, :created_at)",
+ _group_resource)
cursor.execute(
"INSERT INTO group_resources(resource_id, group_id) "
"VALUES(:resource_id, :group_id)",
- group_resource)
+ _group_resource)
add_user_to_group(cursor, new_group, group_leader)
revoke_user_role_by_name(cursor, group_leader, "group-creator")
- assign_user_role_by_name(
- cursor,
- group_leader,
- UUID(str(group_resource["resource_id"])),
- "group-leader")
+ assign_user_role_by_name(cursor,
+ group_leader,
+ _group_resource_id,
+ "group-leader")
return new_group
@@ -233,15 +246,56 @@ def is_group_leader(conn: db.DbConnection, user: User, group: Group) -> bool:
return "group-leader" in role_names
-def all_groups(conn: db.DbConnection) -> Maybe[Sequence[Group]]:
+def __build_groups_list_query__(
+ base: str,
+ search: Optional[str] = None
+) -> tuple[str, tuple[Optional[str], ...]]:
+ """Build up the query from given search terms."""
+ if search is not None and search.strip() != "":
+ _search = search.strip()
+ return ((f"{base} WHERE groups.group_name LIKE ? "
+ "OR groups.group_metadata LIKE ?"),
+ (f"%{search}%", f"%{search}%"))
+ return base, tuple()
+
+
+def __limit_results_length__(base: str, start: int = 0, length: int = 0) -> str:
+ """Add the `LIMIT … OFFSET …` clause to query `base`."""
+ if length > 0:
+ return f"{base} LIMIT {length} OFFSET {start}"
+ return base
+
+
+def all_groups(
+ conn: db.DbConnection,
+ search: Optional[str] = None,
+ start: int = 0,
+ length: int = 0
+) -> Maybe[tuple[tuple[Group, ...], int, int]]:
"""Retrieve all existing groups"""
with db.cursor(conn) as cursor:
- cursor.execute("SELECT * FROM groups")
+ cursor.execute("SELECT COUNT(*) FROM groups")
+ _groups_total_count = int(cursor.fetchone()["COUNT(*)"])
+
+ _qdets = __build_groups_list_query__(
+ "SELECT COUNT(*) FROM groups", search)
+ cursor.execute(*__build_groups_list_query__(
+ "SELECT COUNT(*) FROM groups", search))
+ _filtered_total_count = int(cursor.fetchone()["COUNT(*)"])
+
+ _query, _params = __build_groups_list_query__(
+ "SELECT * FROM groups", search)
+
+ cursor.execute(__limit_results_length__(_query, start, length),
+ _params)
res = cursor.fetchall()
if res:
- return Just(tuple(
- Group(row["group_id"], row["group_name"],
- json.loads(row["group_metadata"])) for row in res))
+ return Just((
+ tuple(
+ Group(row["group_id"], row["group_name"],
+ json.loads(row["group_metadata"])) for row in res),
+ _groups_total_count,
+ _filtered_total_count))
return Nothing
@@ -268,6 +322,53 @@ def add_user_to_group(cursor: db.DbCursor, the_group: Group, user: User):
("INSERT INTO group_users VALUES (:group_id, :user_id) "
"ON CONFLICT (group_id, user_id) DO NOTHING"),
{"group_id": str(the_group.group_id), "user_id": str(user.user_id)})
+ revoke_user_role_by_name(cursor, user, "group-creator")
+
+
+def resource_from_group(conn: db.DbConnection, the_group: Group) -> Resource:
+ """Get the resource object that wraps the group for auth purposes."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT "
+ "resources.resource_id, resources.resource_name, "
+ "resources.public, resource_categories.* "
+ "FROM group_resources "
+ "INNER JOIN resources "
+ "ON group_resources.resource_id=resources.resource_id "
+ "INNER JOIN resource_categories "
+ "ON resources.resource_category_id=resource_categories.resource_category_id "
+ "WHERE group_resources.group_id=?",
+ (str(the_group.group_id),))
+ results = tuple(resource_from_dbrow(row) for row in cursor.fetchall())
+ match len(results):
+ case 0:
+ raise InconsistencyError("The group lacks a wrapper resource.")
+ case 1:
+ return results[0]
+ case _:
+ raise InconsistencyError(
+ "The group has more than one wrapper resource.")
+
+
+def remove_user_from_group(
+ conn: db.DbConnection,
+ group: Group,
+ user: User,
+ grp_resource: Resource
+):
+ """Add `user` to `group` as a member."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "DELETE FROM group_users "
+ "WHERE group_id=:group_id AND user_id=:user_id",
+ {"group_id": str(group.group_id), "user_id": str(user.user_id)})
+ cursor.execute(
+ "DELETE FROM user_roles WHERE user_id=? AND resource_id=?",
+ (str(user.user_id), str(grp_resource.resource_id)))
+ assign_user_role_by_name(cursor,
+ user,
+ grp_resource.resource_id,
+ "group-creator")
@authorised_p(
@@ -327,8 +428,8 @@ gjr.status='PENDING'",
return tuple(dict(row)for row in cursor.fetchall())
raise AuthorisationError(
- "You do not have the appropriate authorisation to access the "
- "group's join requests.")
+ "You need to be the group's leader in order to access the group's join "
+ "requests.")
@authorised_p(("system:group:view-group", "system:group:edit-group"),
@@ -497,3 +598,108 @@ def add_resources_to_group(conn: db.DbConnection,
"group_id": str(group.group_id),
"resource_id": str(rsc.resource_id)
} for rsc in resources))
+
+
+def admin_group(conn: db.DbConnection) -> Either:
+ """Return a group where at least one system admin is a member."""
+ query = (
+ "SELECT DISTINCT g.group_id, g.group_name, g.group_metadata "
+ "FROM roles AS r INNER JOIN user_roles AS ur ON r.role_id=ur.role_id "
+ "INNER JOIN group_users AS gu ON ur.user_id=gu.user_id "
+ "INNER JOIN groups AS g ON gu.group_id=g.group_id "
+ "WHERE role_name='system-administrator'")
+ with db.cursor(conn) as cursor:
+ cursor.execute(query)
+ return monad_from_none_or_value(
+ Left("There is no group of which the system admininstrator is a "
+ "member."),
+ lambda row: Right(Group(
+ UUID(row["group_id"]),
+ row["group_name"],
+ json.loads(row["group_metadata"]))),
+ cursor.fetchone())
+
+
+def group_resource(conn: db.DbConnection, group_id: UUID) -> Resource:
+ """Retrieve the system resource."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT group_resources.group_id, resource_categories.*, "
+ "resources.resource_id, resources.resource_name, resources.public "
+ "FROM group_resources INNER JOIN resources "
+ "ON group_resources.resource_id=resources.resource_id "
+ "INNER JOIN resource_categories "
+ "ON resources.resource_category_id=resource_categories.resource_category_id "
+ "WHERE group_resources.group_id=? "
+ "AND resource_categories.resource_category_key='group'",
+ (str(group_id),))
+ row = cursor.fetchone()
+ if row:
+ return resource_from_dbrow(row)
+
+ raise NotFoundError("Could not find a resource for group with ID "
+ f"{group_id}")
+
+
+def data_resources(
+ conn: db.DbConnection, group_id: UUID) -> Iterable[Resource]:
+ """Fetch a group's data resources."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT resource_ownership.group_id, resources.resource_id, "
+ "resources.resource_name, resources.public, resource_categories.* "
+ "FROM resource_ownership INNER JOIN resources "
+ "ON resource_ownership.resource_id=resources.resource_id "
+ "INNER JOIN resource_categories "
+ "ON resources.resource_category_id=resource_categories.resource_category_id "
+ "WHERE group_id=?",
+ (str(group_id),))
+ yield from (resource_from_dbrow(row) for row in cursor.fetchall())
+
+
+def group_leaders(conn: db.DbConnection, group_id: UUID) -> Iterable[User]:
+ """Fetch all of a group's group leaders."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT users.* FROM group_users INNER JOIN group_resources "
+ "ON group_users.group_id=group_resources.group_id "
+ "INNER JOIN user_roles "
+ "ON group_resources.resource_id=user_roles.resource_id "
+ "INNER JOIN roles "
+ "ON user_roles.role_id=roles.role_id "
+ "INNER JOIN users "
+ "ON user_roles.user_id=users.user_id "
+ "WHERE group_users.group_id=? "
+ "AND roles.role_name='group-leader'",
+ (str(group_id),))
+ yield from (User.from_sqlite3_row(row) for row in cursor.fetchall())
+
+
+def delete_group(conn: db.DbConnection, group_id: UUID):
+ """
+ Delete the group with the given ID
+
+ Parameters:
+ conn (db.DbConnection): an open connection to an SQLite3 database.
+ group_id (uuid.UUID): The identifier for the group to delete.
+
+ Returns:
+ None: It does not return a value.
+
+ Raises:
+ sqlite3.IntegrityError: if the group has members or linked resources, or
+ both.
+ """
+ rsc = group_resource(conn, group_id)
+ with db.cursor(conn) as cursor:
+ cursor.execute("DELETE FROM group_join_requests WHERE group_id=?",
+ (str(group_id),))
+ cursor.execute("DELETE FROM user_roles WHERE resource_id=?",
+ (str(rsc.resource_id),))
+ cursor.execute(
+ "DELETE FROM group_resources WHERE group_id=? AND resource_id=?",
+ (str(group_id), str(rsc.resource_id)))
+ cursor.execute("DELETE FROM resources WHERE resource_id=?",
+ (str(rsc.resource_id),))
+ cursor.execute("DELETE FROM groups WHERE group_id=?",
+ (str(group_id),))
diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py
index 920f504..2aa115a 100644
--- a/gn_auth/auth/authorisation/resources/groups/views.py
+++ b/gn_auth/auth/authorisation/resources/groups/views.py
@@ -6,28 +6,44 @@ import datetime
from functools import partial
from dataclasses import asdict
+import sqlite3
from MySQLdb.cursors import DictCursor
from flask import jsonify, Response, Blueprint, current_app
-from gn_auth.auth.requests import request_json
+from gn_libs import mysqldb as gn3db
+from gn_auth.auth.requests import request_json
from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authorisation.privileges import privileges_by_ids
from gn_auth.auth.errors import InvalidData, NotFoundError, AuthorisationError
-from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authentication.users import User, user_by_id
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.resources.checks import authorised_for_spec
+from gn_auth.auth.authorisation.resources.groups.models import (resource_from_group,
+ remove_user_from_group)
+
from .data import link_data_to_group
-from .models import (
- Group, user_group, all_groups, DUMMY_GROUP, GroupRole, group_by_id,
- join_requests, group_role_by_id, GroupCreationError,
- accept_reject_join_request, group_users as _group_users,
- create_group as _create_group, add_privilege_to_group_role,
- delete_privilege_from_group_role)
+from .models import (Group,
+ GroupRole,
+ user_group,
+ all_groups,
+ DUMMY_GROUP,
+ group_by_id,
+ group_leaders,
+ join_requests,
+ data_resources,
+ group_role_by_id,
+ GroupCreationError,
+ accept_reject_join_request,
+ add_privilege_to_group_role,
+ group_users as _group_users,
+ create_group as _create_group,
+ delete_group as _delete_group,
+ delete_privilege_from_group_role)
groups = Blueprint("groups", __name__)
@@ -35,11 +51,31 @@ groups = Blueprint("groups", __name__)
@require_oauth("profile group")
def list_groups():
"""Return the list of groups that exist."""
+ _kwargs = request_json()
+ def __add_total_group_count__(groups_info):
+ return {
+ "groups": groups_info[0],
+ "total-groups": groups_info[1],
+ "total-filtered": groups_info[2]
+ }
+
with db.connection(current_app.config["AUTH_DB"]) as conn:
- the_groups = all_groups(conn)
+ return jsonify(all_groups(
+ conn,
+ search=_kwargs.get("search"),
+ start=int(_kwargs.get("start", "0")),
+ length=int(_kwargs.get("length", "0"))
+ ).then(
+ __add_total_group_count__
+ ).maybe(
+ {
+ "groups": [],
+ "message": "No groups found!",
+ "total-groups": 0,
+ "total-filtered": 0
+ },
+ lambda _grpdata: _grpdata))
- return jsonify(the_groups.maybe(
- [], lambda grps: [asdict(grp) for grp in grps]))
@groups.route("/create", methods=["POST"])
@require_oauth("profile group")
@@ -169,7 +205,7 @@ def unlinked_genotype_data(
return tuple(dict(row) for row in cursor.fetchall())
def unlinked_phenotype_data(
- authconn: db.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: db.DbConnection, gn3conn: gn3db.Connection,
group: Group) -> tuple[dict, ...]:
"""
Retrieve all phenotype data linked to a group but not linked to any
@@ -235,7 +271,7 @@ def unlinked_data(resource_type: str) -> Response:
if resource_type in ("system", "group"):
return jsonify(tuple())
- if resource_type not in ("all", "mrna", "genotype", "phenotype"):
+ if resource_type not in ("all", "mrna", "genotype", "phenotype", "inbredset-group"):
raise AuthorisationError(f"Invalid resource type {resource_type}")
with require_oauth.acquire("profile group resource") as the_token:
@@ -253,7 +289,8 @@ def unlinked_data(resource_type: str) -> Response:
"genotype": unlinked_genotype_data,
"phenotype": lambda conn, grp: partial(
unlinked_phenotype_data, gn3conn=gn3conn)(
- authconn=conn, group=grp)
+ authconn=conn, group=grp),
+ "inbredset-group": lambda authconn, ugroup: [] # Still need to implement this
}
return jsonify(tuple(
dict(row) for row in unlinked_fns[resource_type](
@@ -347,3 +384,111 @@ def delete_priv_from_role(group_role_id: uuid.UUID) -> Response:
direction="DELETE", user=the_token.user))),
"description": "Privilege deleted successfully"
})
+
+
+@groups.route("/<uuid:group_id>", methods=["GET"])
+@require_oauth("profile group")
+def view_group(group_id: uuid.UUID) -> Response:
+ """View a particular group's details."""
+ # TODO: do authorisation checks here…
+ with (require_oauth.acquire("profile group") as _token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ return jsonify(group_by_id(conn, group_id))
+
+
+@groups.route("/<uuid:group_id>/data-resources", methods=["GET"])
+@require_oauth("profile group")
+def view_group_data_resources(group_id: uuid.UUID) -> Response:
+ """View data resources linked to the group."""
+ # TODO: do authorisation checks here…
+ with (require_oauth.acquire("profile group") as _token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ return jsonify(tuple(data_resources(conn, group_id)))
+
+
+@groups.route("/<uuid:group_id>/leaders", methods=["GET"])
+@require_oauth("profile group")
+def view_group_leaders(group_id: uuid.UUID) -> Response:
+ """View a group's leaders."""
+ # TODO: do authorisation checks here…
+ with (require_oauth.acquire("profile group") as _token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ return jsonify(tuple(group_leaders(conn, group_id)))
+
+
+@groups.route("/<uuid:group_id>/remove-member", methods=["POST"])
+@require_oauth("profile group")
+def remove_group_member(group_id: uuid.UUID):
+ """Remove a user as member of this group."""
+ with (require_oauth.acquire("profile group") as _token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ group = group_by_id(conn, group_id)
+ grp_resource = resource_from_group(conn, group)
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ grp_resource.resource_id,
+ "(OR group:user:remove-group-member system:group:remove-group-member)"):
+ raise AuthorisationError(
+ "You do not have appropriate privileges to remove a user from this "
+ "group.")
+
+ form = request_json()
+ if not bool(form.get("user_id")):
+ response = jsonify({
+ "error": "MissingUserId",
+ "error-description": (
+ "Expected 'user_id' value/parameter was not provided.")
+ })
+ response.status_code = 400
+ return response
+
+ try:
+ user = user_by_id(conn, uuid.UUID(form["user_id"]))
+ remove_user_from_group(conn, group, user, grp_resource)
+ success_msg = (
+ f"User '{user.name} ({user.email})' is no longer a member of "
+ f"group '{group.group_name}'.\n"
+ "They could, however, still have access to resources owned by "
+ "the group.")
+ return jsonify({
+ "description": success_msg,
+ "message": success_msg
+ })
+ except ValueError as _verr:
+ response = jsonify({
+ "error": "InvalidUserId",
+ "error-description": "The 'user_id' provided was invalid"
+ })
+ response.status_code = 400
+ return response
+
+
+@groups.route("/<uuid:group_id>/delete", methods=["DELETE"])
+@require_oauth("profile group")
+def delete_group(group_id: uuid.UUID) -> Response:
+ """Delete group with the specified `group_id`."""
+ with (require_oauth.acquire("profile group") as _token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ group = group_by_id(conn, group_id)
+ grp_resource = resource_from_group(conn, group)
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ grp_resource.resource_id,
+ "(AND system:group:delete-group)"):
+ raise AuthorisationError(
+ "You do not have appropriate privileges to delete this group.")
+ try:
+ _delete_group(conn, group.group_id)
+ return Response(status=204)
+ except sqlite3.IntegrityError as _s3ie:
+ response = jsonify({
+ "error": "IntegrityError",
+ "error-description": (
+ "A group that has members, linked resources, or both, "
+ "cannot be deleted from the system. Remove any members and "
+ "unlink any linked resources, and try again.")
+ })
+ response.status_code = 400
+ return response
diff --git a/gn_auth/auth/authorisation/resources/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py
index de1c18a..2626f3e 100644
--- a/gn_auth/auth/authorisation/resources/inbredset/models.py
+++ b/gn_auth/auth/authorisation/resources/inbredset/models.py
@@ -1,39 +1,12 @@
"""Functions to handle the low-level details regarding populations auth."""
from uuid import UUID, uuid4
+from typing import Sequence, Optional
import sqlite3
-from gn_auth.auth.errors import NotFoundError
+import gn_auth.auth.db.sqlite3 as db
from gn_auth.auth.authentication.users import User
-from gn_auth.auth.authorisation.resources.groups.models import Group
-from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory
-from gn_auth.auth.authorisation.resources.models import (
- create_resource as _create_resource)
-
-def create_resource(
- cursor: sqlite3.Cursor,
- resource_name: str,
- user: User,
- group: Group,
- public: bool
-) -> Resource:
- """Convenience function to create a resource of type 'inbredset-group'."""
- cursor.execute("SELECT * FROM resource_categories "
- "WHERE resource_category_key='inbredset-group'")
- category = cursor.fetchone()
- if category:
- return _create_resource(cursor,
- resource_name,
- ResourceCategory(
- resource_category_id=UUID(
- category["resource_category_id"]),
- resource_category_key="inbredset-group",
- resource_category_description=category[
- "resource_category_description"]),
- user,
- group,
- public)
- raise NotFoundError("Could not find a 'inbredset-group' resource category.")
+from gn_auth.auth.authorisation.resources.base import Resource
def assign_inbredset_group_owner_role(
@@ -62,7 +35,7 @@ def assign_inbredset_group_owner_role(
return resource
-def link_data_to_resource(# pylint: disable=[too-many-arguments]
+def link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
cursor: sqlite3.Cursor,
resource_id: UUID,
species_id: int,
@@ -94,3 +67,19 @@ def link_data_to_resource(# pylint: disable=[too-many-arguments]
"VALUES (:resource_id, :data_link_id)",
params)
return params
+
+
+def resource_data(
+ cursor: db.DbCursor,
+ resource_id: UUID,
+ offset: int = 0,
+ limit: Optional[int] = None) -> Sequence[sqlite3.Row]:
+ """Fetch data linked to a inbred-set resource"""
+ cursor.execute(
+ ("SELECT * FROM inbredset_group_resources AS igr "
+ "INNER JOIN linked_inbredset_groups AS lig "
+ "ON igr.data_link_id=lig.data_link_id "
+ "WHERE igr.resource_id=?") + (
+ f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""),
+ (str(resource_id),))
+ return cursor.fetchall()
diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py
index b559105..9603b5b 100644
--- a/gn_auth/auth/authorisation/resources/inbredset/views.py
+++ b/gn_auth/auth/authorisation/resources/inbredset/views.py
@@ -1,20 +1,54 @@
"""Views for InbredSet resources."""
+import uuid
+
from pymonad.either import Left, Right, Either
from flask import jsonify, Response, Blueprint, current_app as app
from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.requests import request_json
-from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authentication.users import User
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
-from gn_auth.auth.authorisation.resources.groups.models import user_group
-
-from .models import (create_resource,
- link_data_to_resource,
+from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory
+from gn_auth.auth.authorisation.resources.groups.models import (Group,
+ user_group,
+ admin_group)
+from gn_auth.auth.authorisation.resources.models import (
+ create_resource as _create_resource)
+
+from .models import (link_data_to_resource,
assign_inbredset_group_owner_role)
popbp = Blueprint("populations", __name__)
+
+def create_resource(
+ cursor: db.DbCursor,
+ resource_name: str,
+ user: User,
+ group: Group,
+ public: bool
+) -> Resource:
+ """Convenience function to create a resource of type 'inbredset-group'."""
+ cursor.execute("SELECT * FROM resource_categories "
+ "WHERE resource_category_key='inbredset-group'")
+ category = cursor.fetchone()
+ if category:
+ return _create_resource(cursor,
+ resource_name,
+ ResourceCategory(
+ resource_category_id=uuid.UUID(
+ category["resource_category_id"]),
+ resource_category_key="inbredset-group",
+ resource_category_description=category[
+ "resource_category_description"]),
+ user,
+ group,
+ public)
+ raise NotFoundError("Could not find a 'inbredset-group' resource category.")
+
+
@popbp.route("/populations/resource-id/<int:speciesid>/<int:inbredsetid>",
methods=["GET"])
def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
@@ -30,7 +64,7 @@ def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
(speciesid, inbredsetid))
return cursor.fetchone()
- res = with_db_connection(__res_by_iset_id__)
+ res = db.with_db_connection(__res_by_iset_id__)
if res:
resp = jsonify({"status": "success", "resource-id": res["resource_id"]})
else:
@@ -83,7 +117,14 @@ def create_population_resource():
return Right({"formdata": form, "group": usergroup})
- return user_group(conn, _token.user).then(
+ def __default_group_if_none__(group) -> Either:
+ if group.is_nothing():
+ return admin_group(conn)
+ return Right(group.value)
+
+ return __default_group_if_none__(
+ user_group(conn, _token.user)
+ ).then(
lambda group: __check_form__(request_json(), group)
).then(
lambda formdata: {
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index c1748f1..5762551 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -1,24 +1,28 @@
"""Handle the management of resources."""
+import logging
+from datetime import datetime
from dataclasses import asdict
from uuid import UUID, uuid4
from functools import reduce, partial
-from typing import Dict, Sequence, Optional
+from typing import Dict, Union, Sequence, Optional
-import sqlite3
+from flask import current_app as app
+
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User
-from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authorisation.roles import Role
from gn_auth.auth.authorisation.privileges import Privilege
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.errors import NotFoundError, AuthorisationError
-from .checks import authorised_for
-from .base import Resource, ResourceCategory, resource_from_dbrow
from .common import assign_resource_owner_role
+from .checks import can_edit, authorised_for_spec
+from .base import Resource, ResourceCategory, resource_from_dbrow
from .groups.models import Group, is_group_leader
+from .inbredset.models import resource_data as inbredset_resource_data
from .mrna import (
resource_data as mrna_resource_data,
attach_resources_data as mrna_attach_resources_data,
@@ -36,41 +40,91 @@ from .phenotypes.models import (
unlink_data_from_resource as phenotype_unlink_data_from_resource)
+logger = logging.getLogger(__name__)
+
+
@authorised_p(("group:resource:create-resource",),
error_description="Insufficient privileges to create a resource",
oauth2_scope="profile resource")
-def create_resource(# pylint: disable=[too-many-arguments]
- cursor: sqlite3.Cursor,
+def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ conn: Union[db.DbConnection, db.DbCursor],
resource_name: str,
resource_category: ResourceCategory,
user: User,
group: Group,
- public: bool
+ public: bool,
+ created_at: datetime = datetime.now()
) -> Resource:
"""Create a resource item."""
- resource = Resource(uuid4(), resource_name, resource_category, public)
- cursor.execute(
- "INSERT INTO resources VALUES (?, ?, ?, ?)",
- (str(resource.resource_id),
- resource_name,
- str(resource.resource_category.resource_category_id),
- 1 if resource.public else 0))
- # TODO: @fredmanglis,@rookie101
- # 1. Move the actions below into a (the?) hooks system
- # 2. Do more checks: A resource can have varying hooks depending on type
- # e.g. if mRNA, pheno or geno resource, assign:
- # - "resource-owner"
- # if inbredset-group, assign:
- # - "resource-owner",
- # - "inbredset-group-owner" etc.
- # if resource is of type "group", assign:
- # - group-leader
- cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) "
- "VALUES (?, ?)",
- (str(group.group_id), str(resource.resource_id)))
- assign_resource_owner_role(cursor, resource.resource_id, user.user_id)
-
- return resource
+ def __create_resource__(cursor: db.DbCursor) -> Resource:
+ resource = Resource(uuid4(), resource_name, resource_category, public)
+ cursor.execute(
+ "INSERT INTO resources VALUES (?, ?, ?, ?, ?, ?)",
+ (str(resource.resource_id),
+ resource_name,
+ str(resource.resource_category.resource_category_id),
+ 1 if resource.public else 0,
+ str(user.user_id),
+ created_at.timestamp()))
+ # TODO: @fredmanglis,@rookie101
+ # 1. Move the actions below into a (the?) hooks system
+ # 2. Do more checks: A resource can have varying hooks depending on type
+ # e.g. if mRNA, pheno or geno resource, assign:
+ # - "resource-owner"
+ # if inbredset-group, assign:
+ # - "resource-owner",
+ # - "inbredset-group-owner" etc.
+ # if resource is of type "group", assign:
+ # - group-leader
+ cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) "
+ "VALUES (?, ?)",
+ (str(group.group_id), str(resource.resource_id)))
+ assign_resource_owner_role(cursor, resource.resource_id, user.user_id)
+
+ return resource
+
+ if hasattr(conn, "cursor"): # This is a connection: get its cursor.
+ with db.cursor(conn) as cursor:
+ return __create_resource__(cursor)
+ else:
+ return __create_resource__(conn)
+
+
+def delete_resource(conn: db.DbConnection, resource_id: UUID):
+ """Delete a resource."""
+ with db.cursor(conn) as cursor:
+ cursor.execute("DELETE FROM user_roles WHERE resource_id=?",
+ (str(resource_id),))
+ cursor.execute("DELETE FROM resource_roles WHERE resource_id=?",
+ (str(resource_id),))
+ cursor.execute("DELETE FROM group_resources WHERE resource_id=?",
+ (str(resource_id),))
+ cursor.execute("DELETE FROM resource_ownership WHERE resource_id=?",
+ (str(resource_id),))
+ cursor.execute("DELETE FROM resources WHERE resource_id=?",
+ (str(resource_id),))
+
+
+def edit_resource(conn: db.DbConnection, resource_id: UUID, name: str) -> Resource:
+ """Edit basic resource details."""
+ with db.cursor(conn) as cursor:
+ cursor.execute("UPDATE resources SET resource_name=? "
+ "WHERE resource_id=?",
+ (name, str(resource_id)))
+ cursor.execute(
+ "SELECT r.*, rc.* FROM resources AS r "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE r.resource_id=?",
+ (str(resource_id),))
+ _resource = resource_from_dbrow(cursor.fetchone())
+ cursor.execute(
+ "SELECT u.* FROM resources AS r INNER JOIN users AS u "
+ "ON r.created_by=u.user_id WHERE r.resource_id=?",
+ (str(resource_id),))
+ return Resource.from_resource(
+ _resource, created_by=User.from_sqlite3_row(cursor.fetchone()))
+
def resource_category_by_id(
conn: db.DbConnection, category_id: UUID) -> ResourceCategory:
@@ -99,6 +153,18 @@ def resource_categories(conn: db.DbConnection) -> Sequence[ResourceCategory]:
for row in cursor.fetchall())
return tuple()
+
+def __fetch_creators__(cursor, creators_ids: tuple[str, ...]):
+ cursor.execute(
+ ("SELECT * FROM users "
+ f"WHERE user_id IN ({', '.join(['?'] * len(creators_ids))})"),
+ creators_ids)
+ return {
+ row["user_id"]: User.from_sqlite3_row(row)
+ for row in cursor.fetchall()
+ }
+
+
def public_resources(conn: db.DbConnection) -> Sequence[Resource]:
"""List all resources marked as public"""
categories = {
@@ -106,10 +172,19 @@ def public_resources(conn: db.DbConnection) -> Sequence[Resource]:
}
with db.cursor(conn) as cursor:
cursor.execute("SELECT * FROM resources WHERE public=1")
- results = cursor.fetchall()
+ resource_rows = tuple(cursor.fetchall())
+ _creators_ = __fetch_creators__(
+ cursor, tuple(row["created_by"] for row in resource_rows))
return tuple(
- Resource(UUID(row[0]), row[1], categories[row[2]], bool(row[3]))
- for row in results)
+ Resource(
+ UUID(row[0]),
+ row[1],
+ categories[row[2]],
+ bool(row[3]),
+ created_by=_creators_[row["created_by"]],
+ created_at=datetime.fromtimestamp(row["created_at"]))
+ for row in resource_rows)
+
def group_leader_resources(
conn: db.DbConnection, user: User, group: Group,
@@ -129,22 +204,63 @@ def group_leader_resources(
for row in cursor.fetchall())
return tuple()
-def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]:
+
+def user_resources(
+ conn: db.DbConnection,
+ user: User,
+ start_at: int = 0,
+ count: int = 0,
+ text_filter: str = ""
+) -> tuple[Sequence[Resource], int]:
"""List the resources available to the user"""
- with db.cursor(conn) as cursor:
- cursor.execute(
- ("SELECT DISTINCT(r.resource_id), r.resource_name, "
- "r.resource_category_id, r.public, rc.resource_category_key, "
- "rc.resource_category_description "
+ text_filter = text_filter.strip()
+ query_template = ("SELECT %%COLUMNS%% "
"FROM user_roles AS ur "
"INNER JOIN resources AS r ON ur.resource_id=r.resource_id "
"INNER JOIN resource_categories AS rc "
"ON r.resource_category_id=rc.resource_category_id "
- "WHERE ur.user_id=?"),
+ "WHERE ur.user_id=? %%LIKE%% %%LIMITS%%")
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ query_template.replace(
+ "%%COLUMNS%%", "COUNT(DISTINCT(r.resource_id)) AS count"
+ ).replace(
+ "%%LIKE%%", ""
+ ).replace(
+ "%%LIMITS%%", ""),
(str(user.user_id),))
+ _total_records = int(cursor.fetchone()["count"])
+ cursor.execute(
+ query_template.replace(
+ "%%COLUMNS%%",
+ "DISTINCT(r.resource_id), r.resource_name, "
+ "r.resource_category_id, r.public, r.created_by, r.created_at, "
+ "rc.resource_category_key, rc.resource_category_description"
+ ).replace(
+ "%%LIKE%%",
+ ("" if text_filter == "" else (
+ "AND (r.resource_name LIKE ? OR "
+ "rc.resource_category_key LIKE ? OR "
+ "rc.resource_category_description LIKE ? )"))
+ ).replace(
+ "%%LIMITS%%",
+ ("" if count <= 0 else f"LIMIT {count} OFFSET {start_at}")),
+ (str(user.user_id),) + (
+ tuple() if text_filter == "" else
+ tuple(f"%{text_filter}%" for _ in range(0, 3))
+ ))
rows = cursor.fetchall() or []
- return tuple(resource_from_dbrow(row) for row in rows)
+ _creators_ = __fetch_creators__(
+ cursor, tuple(row["created_by"] for row in rows))
+
+ return tuple(
+ Resource.from_resource(
+ resource_from_dbrow(row),
+ created_by=_creators_[row["created_by"]],
+ created_at=datetime.fromtimestamp(row["created_at"])
+ ) for row in rows), _total_records
+
def resource_data(conn, resource, offset: int = 0, limit: Optional[int] = None) -> tuple[dict, ...]:
@@ -159,7 +275,8 @@ def resource_data(conn, resource, offset: int = 0, limit: Optional[int] = None)
"genotype-metadata": lambda *args: tuple(),
"mrna-metadata": lambda *args: tuple(),
"system": lambda *args: tuple(),
- "group": lambda *args: tuple()
+ "group": lambda *args: tuple(),
+ "inbredset-group": inbredset_resource_data,
}
with db.cursor(conn) as cursor:
return tuple(
@@ -187,9 +304,11 @@ def attach_resource_data(cursor: db.DbCursor, resource: Resource) -> Resource:
def resource_by_id(
conn: db.DbConnection, user: User, resource_id: UUID) -> Resource:
"""Retrieve a resource by its ID."""
- if not authorised_for(
- conn, user, ("group:resource:view-resource",),
- (resource_id,))[resource_id]:
+ if not authorised_for_spec(
+ conn,
+ user.user_id,
+ resource_id,
+ "(OR group:resource:view-resource system:resource:view)"):
raise AuthorisationError(
"You are not authorised to access resource with id "
f"'{resource_id}'.")
@@ -207,36 +326,36 @@ def resource_by_id(
raise NotFoundError(f"Could not find a resource with id '{resource_id}'")
def link_data_to_resource(
- conn: db.DbConnection, user: User, resource_id: UUID, dataset_type: str,
- data_link_id: UUID) -> dict:
+ conn: db.DbConnection,
+ user: User,
+ resource_id: UUID,
+ dataset_type: str,
+ data_link_ids: tuple[UUID, ...]
+) -> tuple[dict, ...]:
"""Link data to resource."""
- if not authorised_for(
- conn, user, ("group:resource:edit-resource",),
- (resource_id,))[resource_id]:
+ if not can_edit(conn, user.user_id, resource_id):
raise AuthorisationError(
- "You are not authorised to link data to resource with id "
- f"{resource_id}")
+ "You are not authorised to link/unlink data to this resource.")
- resource = with_db_connection(partial(
- resource_by_id, user=user, resource_id=resource_id))
+ resource = with_db_connection(
+ app.config["AUTH_DB"],
+ partial(resource_by_id, user=user, resource_id=resource_id))
return {# type: ignore[operator]
"mrna": mrna_link_data_to_resource,
"genotype": genotype_link_data_to_resource,
"phenotype": phenotype_link_data_to_resource,
- }[dataset_type.lower()](conn, resource, data_link_id)
+ }[dataset_type.lower()](conn, resource, data_link_ids)
def unlink_data_from_resource(
conn: db.DbConnection, user: User, resource_id: UUID, data_link_id: UUID):
"""Unlink data from resource."""
- if not authorised_for(
- conn, user, ("group:resource:edit-resource",),
- (resource_id,))[resource_id]:
+ if not can_edit(conn, user.user_id, resource_id):
raise AuthorisationError(
- "You are not authorised to link data to resource with id "
- f"{resource_id}")
+ "You are not authorised to link/unlink data this resource.")
- resource = with_db_connection(partial(
- resource_by_id, user=user, resource_id=resource_id))
+ resource = with_db_connection(
+ app.config["AUTH_DB"],
+ partial(resource_by_id, user=user, resource_id=resource_id))
dataset_type = resource.resource_category.resource_category_key
return {
"mrna": mrna_unlink_data_from_resource,
@@ -326,9 +445,7 @@ def save_resource(
conn: db.DbConnection, user: User, resource: Resource) -> Resource:
"""Update an existing resource."""
resource_id = resource.resource_id
- authorised = authorised_for(
- conn, user, ("group:resource:edit-resource",), (resource_id,))
- if authorised[resource_id]:
+ if can_edit(conn, user.user_id, resource_id):
with db.cursor(conn) as cursor:
cursor.execute(
"UPDATE resources SET "
diff --git a/gn_auth/auth/authorisation/resources/mrna.py b/gn_auth/auth/authorisation/resources/mrna.py
index 7fce227..66f8824 100644
--- a/gn_auth/auth/authorisation/resources/mrna.py
+++ b/gn_auth/auth/authorisation/resources/mrna.py
@@ -26,14 +26,15 @@ def resource_data(cursor: db.DbCursor,
def link_data_to_resource(
conn: db.DbConnection,
resource: Resource,
- data_link_id: uuid.UUID) -> dict:
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
"""Link mRNA Assay data with a resource."""
with db.cursor(conn) as cursor:
- params = {
+ params = tuple({
"resource_id": str(resource.resource_id),
"data_link_id": str(data_link_id)
- }
- cursor.execute(
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
"INSERT INTO mrna_resources VALUES"
"(:resource_id, :data_link_id)",
params)
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/models.py b/gn_auth/auth/authorisation/resources/phenotypes/models.py
index d4a516a..0ef91ab 100644
--- a/gn_auth/auth/authorisation/resources/phenotypes/models.py
+++ b/gn_auth/auth/authorisation/resources/phenotypes/models.py
@@ -29,14 +29,15 @@ def resource_data(
def link_data_to_resource(
conn: db.DbConnection,
resource: Resource,
- data_link_id: uuid.UUID) -> dict:
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
"""Link Phenotype data with a resource."""
with db.cursor(conn) as cursor:
- params = {
+ params = tuple({
"resource_id": str(resource.resource_id),
"data_link_id": str(data_link_id)
- }
- cursor.execute(
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
"INSERT INTO phenotype_resources VALUES"
"(:resource_id, :data_link_id)",
params)
diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py
index 7c176aa..25089fa 100644
--- a/gn_auth/auth/authorisation/resources/system/models.py
+++ b/gn_auth/auth/authorisation/resources/system/models.py
@@ -1,14 +1,19 @@
"""Base functions and utilities for system resources."""
from uuid import UUID
from functools import reduce
-from typing import Sequence
+from typing import Union, Sequence
-from gn_auth.auth.db import sqlite3 as db
+from gn_libs import sqlite3 as db
+
+from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import User
from gn_auth.auth.authorisation.roles import Role
from gn_auth.auth.authorisation.privileges import Privilege
+from gn_auth.auth.authorisation.resources.base import (
+ Resource,
+ resource_from_dbrow)
def __organise_privileges__(acc, row):
role_id = UUID(row["role_id"])
@@ -24,6 +29,7 @@ def __organise_privileges__(acc, row):
(Privilege(row["privilege_id"], row["privilege_description"]),)))
}
+
def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
"""
Retrieve all roles assigned to the `user` that act on `system` resources.
@@ -45,3 +51,25 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
return tuple(reduce(
__organise_privileges__, cursor.fetchall(), {}).values())
return tuple()
+
+
+def system_resource(conn: Union[db.DbConnection, db.DbCursor]) -> Resource:
+ """Retrieve the system resource."""
+ def __fetch_sys_resource__(cursor: db.DbCursor) -> Resource:
+ cursor.execute(
+ "SELECT resource_categories.*, resources.resource_id, "
+ "resources.resource_name, resources.public "
+ "FROM resource_categories INNER JOIN resources "
+ "ON resource_categories.resource_category_id=resources.resource_category_id "
+ "WHERE resource_categories.resource_category_key='system'")
+ row = cursor.fetchone()
+ if row:
+ return resource_from_dbrow(row)
+
+ raise NotFoundError("Could not find a system resource!")
+
+ if hasattr(conn, "cursor"): # is connection
+ with db.cursor(conn) as cursor:
+ return __fetch_sys_resource__(cursor)
+ else:
+ return __fetch_sys_resource__(conn)
diff --git a/gn_auth/auth/authorisation/resources/system/views.py b/gn_auth/auth/authorisation/resources/system/views.py
index b0d40c2..54aa086 100644
--- a/gn_auth/auth/authorisation/resources/system/views.py
+++ b/gn_auth/auth/authorisation/resources/system/views.py
@@ -1,19 +1,41 @@
"""Views relating to `System` resource(s)."""
+import logging
from dataclasses import asdict
-from flask import jsonify, Blueprint
+from flask import (request,
+ jsonify,
+ Response,
+ Blueprint,
+ make_response,
+ current_app as app)
-from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_libs import sqlite3 as authdb
+from gn_auth.auth.authorisation.roles.models import db_rows_to_roles
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from .models import user_roles_on_system
+logger = logging.getLogger(__name__)
system = Blueprint("system", __name__)
+
@system.route("/roles")
-def system_roles():
+def system_roles() -> Response:
"""Get the roles that a user has that act on the system."""
- with require_oauth.acquire("profile group") as the_token:
- roles = with_db_connection(
- lambda conn: user_roles_on_system(conn, the_token.user))
- return jsonify(tuple(asdict(role) for role in roles))
+ with (authdb.connection(app.config["AUTH_DB"]) as conn,
+ authdb.cursor(conn) as cursor):
+ if not bool(request.headers.get("Authorization", False)):
+ cursor.execute(
+ "SELECT r.*, p.* FROM roles AS r "
+ "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+ "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id "
+ "WHERE r.role_name='public-view'")
+ roles_data = tuple(
+ asdict(role) for role in db_rows_to_roles(cursor.fetchall()))
+ else:
+ with require_oauth.acquire("profile group") as the_token:
+ roles_data = tuple(
+ asdict(role) for role in
+ user_roles_on_system(conn, the_token.user))
+
+ return make_response(jsonify(roles_data), 200)
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index 1c4104a..b8c7e24 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -1,9 +1,11 @@
"""The views/routes for the resources package"""
-from uuid import UUID, uuid4
+import time
import json
+import logging
import operator
+import warnings
import sqlite3
-import time
+from uuid import UUID, uuid4
from dataclasses import asdict
from functools import reduce
@@ -11,8 +13,14 @@ from functools import reduce
from werkzeug.exceptions import BadRequest
from authlib.jose import jwt
from authlib.integrations.flask_oauth2.errors import _HTTPException
-from flask import (make_response, request, jsonify, Response,
- Blueprint, current_app as app)
+from flask import (request,
+ jsonify,
+ url_for,
+ Response,
+ Blueprint,
+ make_response,
+ current_app as app)
+import gn_libs.privileges.resources
from gn_auth.auth.requests import request_json
@@ -39,23 +47,30 @@ from gn_auth.auth.authorisation.roles.models import (
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from gn_auth.auth.authentication.users import User, user_by_id, user_by_email
-from .checks import authorised_for
from .inbredset.views import popbp
from .genotypes.views import genobp
from .phenotypes.views import phenobp
from .errors import MissingGroupError
+from .system.models import system_resource
from .groups.models import Group, user_group
+from .checks import can_delete, authorised_for_spec
from .models import (
Resource, resource_data, resource_by_id, public_resources,
resource_categories, assign_resource_user, link_data_to_resource,
unassign_resource_user, resource_category_by_id, user_roles_on_resources,
unlink_data_from_resource, create_resource as _create_resource,
- get_resource_id)
+ get_resource_id, delete_resource as _delete_resource,
+ edit_resource as _edit_resource)
+
+from .system.views import system
+
+logger = logging.getLogger(__name__)
resources = Blueprint("resources", __name__)
resources.register_blueprint(popbp, url_prefix="/")
resources.register_blueprint(genobp, url_prefix="/")
resources.register_blueprint(phenobp, url_prefix="/")
+resources.register_blueprint(system, url_prefix="/system")
@resources.route("/categories", methods=["GET"])
@require_oauth("profile group resource")
@@ -75,8 +90,7 @@ def create_resource() -> Response:
resource_name = form.get("resource_name")
resource_category_id = UUID(form.get("resource_category"))
db_uri = app.config["AUTH_DB"]
- with (db.connection(db_uri) as conn,
- db.cursor(conn) as cursor):
+ with db.connection(db_uri) as conn:
try:
group = user_group(conn, the_token.user).maybe(
False, lambda grp: grp)# type: ignore[misc, arg-type]
@@ -84,7 +98,7 @@ def create_resource() -> Response:
raise MissingGroupError(# Not all resources require an owner group
"User with no group cannot create a resource.")
resource = _create_resource(
- cursor,
+ conn,
resource_name,
resource_category_by_id(conn, resource_category_id),
the_token.user,
@@ -96,11 +110,12 @@ def create_resource() -> Response:
"resources.resource_name"):
raise InconsistencyError(
"You cannot have duplicate resource names.") from sql3ie
- app.logger.debug(
- f"{type(sql3ie)=}: {sql3ie=}")
+ logger.debug("type(sql3ie)=%s: sql3ie=%s", type(sql3ie), sql3ie)
raise
+
@resources.route("/view/<uuid:resource_id>")
+@resources.route("/<uuid:resource_id>/view")
@require_oauth("profile group resource")
def view_resource(resource_id: UUID) -> Response:
"""View a particular resource's details."""
@@ -113,6 +128,49 @@ def view_resource(resource_id: UUID) -> Response:
)
)
+
+@resources.route("/<uuid:resource_id>/edit", methods=["POST"])
+@require_oauth("profile group resource")
+def edit_resource(resource_id: UUID) -> Response:
+ """Update/edit basic details regarding a resource."""
+ db_uri = app.config["AUTH_DB"]
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(db_uri) as conn):
+ def __extract_privileges__(roles: tuple[Role, ...]) -> tuple[str, ...]:
+ return tuple(
+ priv.privilege_id for role in roles
+ for priv in role.privileges)
+
+ _sys_resource = system_resource(conn)
+ _privileges = {
+ ("system_privileges"
+ if _rid == _sys_resource.resource_id
+ else "resource_privileges"): __extract_privileges__(_rroles)
+ for _rid, _rroles in user_roles_on_resources(
+ conn,
+ _token.user,
+ (resource_id, _sys_resource.resource_id)
+ ).items()
+ }
+ if not gn_libs.privileges.resources.can_edit(**_privileges):
+ return make_response(jsonify({
+ "error": "AuthorisationError",
+ "error_description": "You are not allowed to edit this resource."
+ }), 401)
+
+ name = (request_json().get("resource_name") or "").strip()
+ if bool(name):
+ return jsonify({
+ "resource": asdict(_edit_resource(conn, resource_id, name)),
+ "message": "Resource updated successfully",
+ "status": "success"
+ })
+
+ return make_response(jsonify({
+ "error_description": "Expected `resource_name` to be provided.",
+ "error": "InvalidInput"
+ }), 400)
+
def __safe_get_requests_page__(key: str = "page") -> int:
"""Get the results page if it exists or default to the first page."""
try:
@@ -137,7 +195,7 @@ def view_resource_data(resource_id: UUID) -> Response:
with require_oauth.acquire("profile group resource") as the_token:
db_uri = app.config["AUTH_DB"]
count_per_page = __safe_get_requests_count__("count_per_page")
- offset = (__safe_get_requests_page__("page") - 1)
+ offset = __safe_get_requests_page__("page") - 1
with db.connection(db_uri) as conn:
resource = resource_by_id(conn, the_token.user, resource_id)
return jsonify(resource_data(
@@ -153,7 +211,7 @@ def link_data():
try:
form = request_json()
assert "resource_id" in form, "Resource ID not provided."
- assert "data_link_id" in form, "Data Link ID not provided."
+ assert "data_link_ids" in form, "Data Link IDs not provided."
assert "dataset_type" in form, "Dataset type not specified"
assert form["dataset_type"].lower() in (
"mrna", "genotype", "phenotype"), "Invalid dataset type provided."
@@ -161,8 +219,11 @@ def link_data():
with require_oauth.acquire("profile group resource") as the_token:
def __link__(conn: db.DbConnection):
return link_data_to_resource(
- conn, the_token.user, UUID(form["resource_id"]),
- form["dataset_type"], UUID(form["data_link_id"]))
+ conn,
+ the_token.user,
+ UUID(form["resource_id"]),
+ form["dataset_type"],
+ tuple(UUID(dlinkid) for dlinkid in form["data_link_ids"]))
return jsonify(with_db_connection(__link__))
except AssertionError as aserr:
@@ -194,64 +255,55 @@ def resource_users(resource_id: UUID):
"""Retrieve all users with access to the given resource."""
with require_oauth.acquire("profile group resource") as the_token:
def __the_users__(conn: db.DbConnection):
- ########## BEGIN: HACK ##########
- # This hack gets the UI to work, but needs replacing.
- # It resolves (albeit, temporarily) the bug introduced after a
- # refactor that made the system itself, and the groups into
- # resources.
- grouplevelauth = authorised_for(
- conn,
- the_token.user,
- ("group:resource:view-resource",),
- (resource_id,))
- systemlevelauth = authorised_for(
- conn,
- the_token.user,
- ("system:user:list",),
- (resource_id,))
- authorised = {
- key: (grouplevelauth.get(key, False)
- or systemlevelauth.get(key, False))
- for key in grouplevelauth.keys() | systemlevelauth.keys()
- }
- ########## END: HACK ##########
- if authorised.get(resource_id, False):
- with db.cursor(conn) as cursor:
- def __organise_users_n_roles__(users_n_roles, row):
- user_id = UUID(row["user_id"])
- user = users_n_roles.get(user_id, {}).get(
- "user", User.from_sqlite3_row(row))
- role = Role(
- UUID(row["role_id"]), row["role_name"],
- bool(int(row["user_editable"])), tuple())
- return {
- **users_n_roles,
- user_id: {
- "user": user,
- "user_group": Group(
- UUID(row["group_id"]), row["group_name"],
- json.loads(row["group_metadata"])),
- "roles": users_n_roles.get(
- user_id, {}).get("roles", tuple()) + (role,)
- }
+ if not (authorised_for_spec(
+ conn,
+ the_token.user.user_id,
+ resource_id,
+ "(AND group:resource:view-resource)")
+ or authorised_for_spec(
+ conn,
+ the_token.user.user_id,
+ system_resource(conn).resource_id,
+ "(AND system:user:list)")):
+ raise AuthorisationError(
+ "You do not have sufficient privileges to view the resource "
+ "users.")
+ with db.cursor(conn) as cursor:
+ def __organise_users_n_roles__(users_n_roles, row):
+ user_id = UUID(row["user_id"])
+ user = users_n_roles.get(user_id, {}).get(
+ "user", User.from_sqlite3_row(row))
+ role = Role(
+ UUID(row["role_id"]), row["role_name"],
+ bool(int(row["user_editable"])), tuple())
+ return {
+ **users_n_roles,
+ user_id: {
+ "user": user,
+ "user_group": (
+ Group(UUID(row["group_id"]),
+ row["group_name"],
+ json.loads(row["group_metadata"]))
+ if bool(row["group_id"]) else False) ,
+ "roles": users_n_roles.get(
+ user_id, {}).get("roles", tuple()) + (role,)
}
- cursor.execute(
- "SELECT g.*, u.*, r.* "
- "FROM groups AS g INNER JOIN group_users AS gu "
- "ON g.group_id=gu.group_id INNER JOIN users AS u "
- "ON gu.user_id=u.user_id INNER JOIN user_roles AS ur "
- "ON u.user_id=ur.user_id INNER JOIN roles AS r "
- "ON ur.role_id=r.role_id "
- "WHERE ur.resource_id=?",
- (str(resource_id),))
- return reduce(__organise_users_n_roles__, cursor.fetchall(), {})
- raise AuthorisationError(
- "You do not have sufficient privileges to view the resource "
- "users.")
+ }
+ cursor.execute(
+ "SELECT g.*, u.*, r.* "
+ "FROM groups AS g INNER JOIN group_users AS gu "
+ "ON g.group_id=gu.group_id RIGHT JOIN users AS u "
+ "ON gu.user_id=u.user_id INNER JOIN user_roles AS ur "
+ "ON u.user_id=ur.user_id INNER JOIN roles AS r "
+ "ON ur.role_id=r.role_id "
+ "WHERE ur.resource_id=?",
+ (str(resource_id),))
+ return reduce(__organise_users_n_roles__, cursor.fetchall(), {})
results = (
{
"user": asdict(row["user"]),
- "user_group": asdict(row["user_group"]),
+ "user_group": (
+ asdict(row["user_group"]) if row["user_group"] else False),
"roles": tuple(asdict(role) for role in row["roles"])
} for row in (
user_row for user_id, user_row
@@ -262,6 +314,11 @@ def resource_users(resource_id: UUID):
@require_oauth("profile group resource role")
def assign_role_to_user(resource_id: UUID) -> Response:
"""Assign a role on the specified resource to a user."""
+ warnings.warn(
+ f"The function `{__name__}.assign_role_to_user` is deprecated. Please "
+ " use `gn_auth.auth.authorisation.users.views.assign_user_role`",
+ DeprecationWarning,
+ stacklevel=2)
with require_oauth.acquire("profile group resource role") as _token:
try:
form = request_json()
@@ -271,11 +328,13 @@ def assign_role_to_user(resource_id: UUID) -> Response:
assert bool(user_email), "The user email must be provided."
def __assign__(conn: db.DbConnection) -> dict:
- authorised_for(
- conn,
- _token.user,
- ("resource:role:assign-role",),
- (resource_id,))
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ resource_id,
+ "(AND resource:user:assign-role)"):
+ raise AuthorisationError(
+ "You are not authorised to assign roles on this resource.")
resource = resource_by_id(conn, _token.user, resource_id)
user = user_by_email(conn, user_email)
return assign_resource_user(
@@ -284,12 +343,25 @@ def assign_role_to_user(resource_id: UUID) -> Response:
except AssertionError as aserr:
raise AuthorisationError(aserr.args[0]) from aserr
- return jsonify(with_db_connection(__assign__))
+ new_uri = url_for(
+ "oauth2.users.assign_user_role",
+ user_id=str(_token.user.user_id)
+ ).replace(str(_token.user.user_id), "<uuid:user_id>")
+ return jsonify({
+ **with_db_connection(__assign__),
+ "DeprecationWarning": (
+ "This endpoint is deprecated and will be removed. Please use "
+ f"the {new_uri} endpoint.")})
@resources.route("<uuid:resource_id>/user/unassign", methods=["POST"])
@require_oauth("profile group resource role")
def unassign_role_to_user(resource_id: UUID) -> Response:
"""Unassign a role on the specified resource from a user."""
+ warnings.warn(
+ f"The function `{__name__}.unassign_role_to_user` is deprecated. Please "
+ " use `gn_auth.auth.authorisation.users.views.revoke_user_role`",
+ DeprecationWarning,
+ stacklevel=2)
with require_oauth.acquire("profile group resource role") as _token:
try:
form = request_json()
@@ -299,11 +371,13 @@ def unassign_role_to_user(resource_id: UUID) -> Response:
assert bool(user_id), "The user id must be provided."
def __assign__(conn: db.DbConnection) -> dict:
- authorised_for(
- conn,
- _token.user,
- ("resource:role:assign-role",),
- (resource_id,))
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ resource_id,
+ "(AND resource:user:assign-role)"):
+ raise AuthorisationError(
+ "You are not authorised to assign roles on this resource.")
resource = resource_by_id(conn, _token.user, resource_id)
return unassign_resource_user(
conn, resource, user_by_id(conn, UUID(user_id)),
@@ -311,7 +385,15 @@ def unassign_role_to_user(resource_id: UUID) -> Response:
except AssertionError as aserr:
raise AuthorisationError(aserr.args[0]) from aserr
- return jsonify(with_db_connection(__assign__))
+ new_uri = url_for(
+ "oauth2.users.revoke_user_role",
+ user_id=str(_token.user.user_id)
+ ).replace(str(_token.user.user_id), "<uuid:user_id>")
+ return jsonify({
+ **with_db_connection(__assign__),
+ "DeprecationWarning": (
+ "This endpoint is deprecated and will be removed. Please use "
+ f"the {new_uri} endpoint.")})
def __public_view_params__(cursor, user_id, resource_id):
ignore = (str(user_id),)
@@ -464,7 +546,7 @@ def resources_authorisation():
})
resp.status_code = 400
except Exception as _exc:#pylint: disable=[broad-except]
- app.logger.debug("Generic exception.", exc_info=True)
+ logger.debug("Generic exception.", exc_info=True)
resp = jsonify({
"status": "general-exception",
"error_description": (
@@ -502,7 +584,6 @@ def get_user_roles_on_resource(name) -> Response:
response = make_response({
# Flatten this list
"roles": roles,
- "silly": "ausah",
})
iat = int(time.time())
jose_header = {
@@ -577,12 +658,11 @@ def unassign_resource_role_privilege(resource_id: UUID, role_id: UUID):
db.cursor(conn) as cursor):
_role = role_by_id(conn, role_id)
- _authorised = authorised_for(
- conn,
- _token.user,
- privileges=("resource:role:edit-role",),
- resource_ids=(resource_id,)).get(resource_id)
- if not _authorised:
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ resource_id,
+ "(AND resource:role:edit-role)"):
raise AuthorisationError(
"You are not authorised to edit/update this role.")
@@ -670,3 +750,45 @@ def user_resource_roles(resource_id: UUID, user_id: UUID):
return jsonify([asdict(role) for role in
_user_resource_roles(conn, _token.user, _resource)])
+
+
+@resources.route("/delete", methods=["POST"])
+@require_oauth("profile group resource")
+def delete_resource():
+ """Delete the specified resource, if possible."""
+ with (require_oauth.acquire("profile group resource") as the_token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ form = request_json()
+ try:
+ resource_id = UUID(form.get("resource_id"))
+ if not can_delete(conn, the_token.user.user_id, resource_id):
+ raise AuthorisationError(
+ "You are not allowed to delete this resource.")
+
+ data = resource_data(
+ conn,
+ resource_by_id(conn, the_token.user, resource_id),
+ 0,
+ 10)
+ if bool(data):
+ return jsonify({
+ "error": "NonEmptyResouce",
+ "error-description": "Cannot delete a resource with linked data"
+ }), 400
+
+ _delete_resource(conn, resource_id)
+ return jsonify({
+ "description": f"Successfully deleted resource with ID '{resource_id}'."
+ })
+ except ValueError as _verr:
+ logger.debug("Error!", exc_info=True)
+ return jsonify({
+ "error": "ValueError",
+ "error-description": "An invalid identifier was provided"
+ }), 400
+ except TypeError as _terr:
+ logger.debug("Error!", exc_info=True)
+ return jsonify({
+ "error": "TypeError",
+ "error-description": "An invalid identifier was provided"
+ }), 400
diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py
index 2729b3b..89556a6 100644
--- a/gn_auth/auth/authorisation/roles/models.py
+++ b/gn_auth/auth/authorisation/roles/models.py
@@ -4,12 +4,12 @@ from functools import reduce
from dataclasses import dataclass
from typing import Sequence, Iterable, Optional
+from gn_libs import sqlite3 as db
from pymonad.either import Left, Right, Either
from gn_auth.auth.errors import NotFoundError, AuthorisationError
from gn_auth.auth.authorisation.resources.base import Resource
-from ...db import sqlite3 as db
from ...authentication.users import User
from ..checks import authorised_p
@@ -255,6 +255,25 @@ def assign_user_role_by_name(
})
+def unassign_user_role_by_name(
+ cursor: db.DbCursor, user: User, resource_id: UUID, role_name: str):
+ """Revoke a role from `user` on `resource_id` by the role's name."""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name=:role_name",
+ {"role_name": role_name})
+ role = cursor.fetchone()
+ if role:
+ cursor.execute(
+ ("DELETE FROM user_roles "
+ "WHERE user_id=:user_id AND role_id=:role_id "
+ "AND resource_id=:resource_id"),
+ {
+ "user_id": str(user.user_id),
+ "role_id": role["role_id"],
+ "resource_id": str(resource_id)
+ })
+
+
def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]:
"""Fetch a role from the database by its ID."""
with db.cursor(conn) as cursor:
@@ -271,7 +290,7 @@ def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]:
_roles = db_rows_to_roles(results)
if len(_roles) > 1:
- raise Exception("Data corruption: Expected a single role.")
+ raise Exception("Data corruption: Expected a single role.")# pylint: disable=[broad-exception-raised]
return _roles[0]
diff --git a/gn_auth/auth/authorisation/roles/views.py b/gn_auth/auth/authorisation/roles/views.py
index 00def89..91292e7 100644
--- a/gn_auth/auth/authorisation/roles/views.py
+++ b/gn_auth/auth/authorisation/roles/views.py
@@ -7,7 +7,7 @@ from flask import jsonify, Response, Blueprint, current_app
from ...db import sqlite3 as db
-from .models import user_role
+from .models import role_by_id
from ...authentication.oauth2.resource_server import require_oauth
@@ -17,11 +17,7 @@ roles = Blueprint("roles", __name__)
@require_oauth("profile role")
def view_role(role_id: uuid.UUID) -> Response:
"""Retrieve a user role with id `role_id`"""
- def __error__(exc: Exception):
- raise exc
- with require_oauth.acquire("profile role") as the_token:
+ with require_oauth.acquire("profile role") as _token:
db_uri = current_app.config["AUTH_DB"]
with db.connection(db_uri) as conn:
- the_role = user_role(conn, the_token.user, role_id)
- return the_role.either(
- __error__, lambda a_role: jsonify((asdict(a_role[0]), str(a_role[1]))))
+ return jsonify(asdict(role_by_id(conn, role_id)))# type: ignore[arg-type]
diff --git a/gn_auth/auth/authorisation/users/admin/models.py b/gn_auth/auth/authorisation/users/admin/models.py
index 36f3c09..65db8cc 100644
--- a/gn_auth/auth/authorisation/users/admin/models.py
+++ b/gn_auth/auth/authorisation/users/admin/models.py
@@ -1,23 +1,67 @@
"""Major function for handling admin users."""
+import warnings
+
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authorisation.roles.models import Role, db_rows_to_roles
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+from gn_auth.auth.authorisation.users.models import create_credentialed_user
-def make_sys_admin(cursor: db.DbCursor, user: User) -> User:
- """Make a given user into an system admin."""
+
+def sysadmin_role(conn: db.DbConnection) -> Role:
+ """Fetch the `system-administrator` role details."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT roles.*, privileges.* "
+ "FROM roles INNER JOIN role_privileges "
+ "ON roles.role_id=role_privileges.role_id "
+ "INNER JOIN privileges "
+ "ON role_privileges.privilege_id=privileges.privilege_id "
+ "WHERE role_name='system-administrator'")
+ results = db_rows_to_roles(cursor.fetchall())
+
+ assert len(results) == 1, (
+ "There should only ever be one 'system-administrator' role.")
+ return results[0]
+
+
+def grant_sysadmin_role(cursor: db.DbCursor, user: User) -> User:
+ """Grant `system-administrator` role to `user`."""
cursor.execute(
"SELECT * FROM roles WHERE role_name='system-administrator'")
admin_role = cursor.fetchone()
- cursor.execute(
- "SELECT * FROM resources AS r "
- "INNER JOIN resource_categories AS rc "
- "ON r.resource_category_id=rc.resource_category_id "
- "WHERE resource_category_key='system'")
- the_system = cursor.fetchone()
+ sysresource = system_resource(cursor)
cursor.execute(
"INSERT INTO user_roles VALUES (:user_id, :role_id, :resource_id)",
{
"user_id": str(user.user_id),
"role_id": admin_role["role_id"],
- "resource_id": the_system["resource_id"]
+ "resource_id": str(sysresource.resource_id)
})
return user
+
+
+def make_sys_admin(cursor: db.DbCursor, user: User) -> User:
+ """Make a given user into an system admin."""
+ warnings.warn(
+ DeprecationWarning(
+ f"The function `{__name__}.make_sys_admin` will be removed soon"),
+ stacklevel=1)
+ return grant_sysadmin_role(cursor, user)
+
+
+def revoke_sysadmin_role(conn: db.DbConnection, user: User):
+ """Revoke `system-administrator` role from `user`."""
+ with db.cursor(conn) as cursor:
+ cursor.execute("DELETE FROM user_roles WHERE user_id=? AND role_id=?",
+ (str(user.user_id), str(sysadmin_role(conn).role_id)))
+
+
+def create_verified_user(
+ conn: db.DbConnection,
+ email: str,
+ name: str,
+ password: str
+) -> User:
+ """Create a pre-verified credentialed user with no roles."""
+ return create_credentialed_user(conn, email, name, password, verified=True)
diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py
index 9bc1c36..62eccfd 100644
--- a/gn_auth/auth/authorisation/users/admin/views.py
+++ b/gn_auth/auth/authorisation/users/admin/views.py
@@ -1,6 +1,5 @@
"""UI for admin stuff"""
import uuid
-import json
import random
import string
from typing import Optional
@@ -240,13 +239,6 @@ def register_client():
client_secret = raw_client_secret)
-def __parse_client__(sqlite3_row) -> dict:
- """Parse the client details into python datatypes."""
- return {
- **dict(sqlite3_row),
- "client_metadata": json.loads(sqlite3_row["client_metadata"])
- }
-
@admin.route("/list-client", methods=["GET"])
@is_admin
def list_clients():
diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py
index f0a7fa2..30242c2 100644
--- a/gn_auth/auth/authorisation/users/collections/models.py
+++ b/gn_auth/auth/authorisation/users/collections/models.py
@@ -33,7 +33,7 @@ def __valid_email__(email:str) -> bool:
def __toggle_boolean_field__(
rconn: Redis, email: str, field: str):
"""Toggle the valuen of a boolean field"""
- mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}")
+ mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}") # type: ignore
if bool(mig_dict):
rconn.hset("migratable-accounts", email,
json.dumps({**mig_dict, field: not mig_dict.get(field, True)}))
@@ -52,7 +52,7 @@ def __build_email_uuid_bridge__(rconn: Redis):
"resources_migrated": False
} for account in (
acct for acct in
- (json.loads(usr) for usr in rconn.hgetall("users").values())
+ (json.loads(usr) for usr in rconn.hgetall("users").values()) # type: ignore
if (bool(acct.get("email_address", False)) and
__valid_email__(acct["email_address"])))
}
@@ -66,14 +66,14 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict:
accounts = rconn.hgetall("migratable-accounts")
if accounts:
return {
- key: json.loads(value) for key, value in accounts.items()
+ key: json.loads(value) for key, value in accounts.items() # type: ignore
}
return __build_email_uuid_bridge__(rconn)
def parse_collection(coll: dict) -> dict:
"""Parse the collection as persisted in redis to a usable python object."""
- created = coll.get("created", coll.get("created_timestamp"))
- changed = coll.get("changed", coll.get("changed_timestamp"))
+ created = coll.get("created", coll.get("created_timestamp", ""))
+ changed = coll.get("changed", coll.get("changed_timestamp", ""))
return {
"id": UUID(coll["id"]),
"name": coll["name"],
@@ -91,13 +91,13 @@ def __retrieve_old_user_collections__(rconn: Redis, old_user_id: UUID) -> tuple:
"""Retrieve any old collections relating to the user."""
return tuple(parse_collection(coll) for coll in
json.loads(rconn.hget(
- __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]"))
+ __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]")) # type: ignore
def user_collections(rconn: Redis, user: User) -> tuple[dict, ...]:
"""Retrieve current user collections."""
collections = tuple(parse_collection(coll) for coll in json.loads(
rconn.hget(REDIS_COLLECTIONS_KEY, str(user.user_id)) or
- "[]"))
+ "[]")) # type: ignore
old_accounts = __retrieve_old_accounts__(rconn)
if (user.email in old_accounts and
not old_accounts[user.email]["collections-migrated"]):
diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py
index f619c3d..5ed2c23 100644
--- a/gn_auth/auth/authorisation/users/collections/views.py
+++ b/gn_auth/auth/authorisation/users/collections/views.py
@@ -1,4 +1,5 @@
"""Views regarding user collections."""
+import logging
from uuid import UUID
from redis import Redis
@@ -25,8 +26,10 @@ from .models import (
REDIS_COLLECTIONS_KEY,
delete_collections as _delete_collections)
+logger = logging.getLogger(__name__)
collections = Blueprint("collections", __name__)
+
@collections.route("/list")
@require_oauth("profile user")
def list_user_collections() -> Response:
@@ -44,7 +47,7 @@ def list_anonymous_collections(anon_id: UUID) -> Response:
def __list__(conn: db.DbConnection) -> tuple:
try:
_user = user_by_id(conn, anon_id)
- current_app.logger.warning(
+ logger.warning(
"Fetch collections for authenticated user using the "
"`list_user_collections()` endpoint.")
return tuple()
diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py
index a155899..5c11f34 100644
--- a/gn_auth/auth/authorisation/users/masquerade/models.py
+++ b/gn_auth/auth/authorisation/users/masquerade/models.py
@@ -20,7 +20,7 @@ from ....db import sqlite3 as db
from ....authentication.users import User
from ....authentication.oauth2.models.oauth2token import OAuth2Token
-__FIVE_HOURS__ = (60 * 60 * 5)
+__FIVE_HOURS__ = 60 * 60 * 5
def can_masquerade(func):
"""Security decorator."""
diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py
index 8b897f2..12a8c97 100644
--- a/gn_auth/auth/authorisation/users/masquerade/views.py
+++ b/gn_auth/auth/authorisation/users/masquerade/views.py
@@ -1,14 +1,14 @@
"""Endpoints for user masquerade"""
from dataclasses import asdict
from uuid import UUID
-from functools import partial
-from flask import request, jsonify, Response, Blueprint
+from flask import request, jsonify, Response, Blueprint, current_app
from gn_auth.auth.errors import InvalidData
+from gn_auth.auth.authorisation.resources.groups.models import user_group
+from ....db import sqlite3 as db
from ...checks import require_json
-from ....db.sqlite3 import with_db_connection
from ....authentication.users import user_by_id
from ....authentication.oauth2.resource_server import require_oauth
@@ -21,13 +21,13 @@ masq = Blueprint("masquerade", __name__)
@require_json
def masquerade() -> Response:
"""Masquerade as a particular user."""
- with require_oauth.acquire("profile user masquerade") as token:
+ with (require_oauth.acquire("profile user masquerade") as token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
masqueradee_id = UUID(request.json["masquerade_as"])#type: ignore[index]
if masqueradee_id == token.user.user_id:
raise InvalidData("You are not allowed to masquerade as yourself.")
- masq_user = with_db_connection(partial(
- user_by_id, user_id=masqueradee_id))
+ masq_user = user_by_id(conn, user_id=masqueradee_id)
def __masq__(conn):
new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user)
@@ -39,6 +39,8 @@ def masquerade() -> Response:
},
"masquerade_as": {
"user": asdict(masq_user),
- "token": with_db_connection(__masq__)
+ "token": __masq__(conn),
+ **(user_group(conn, masq_user).maybe(# type: ignore[misc]
+ {}, lambda grp: {"group": grp}))
}
})
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
index bde2e33..21a9627 100644
--- a/gn_auth/auth/authorisation/users/models.py
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -1,22 +1,89 @@
"""Functions for acting on users."""
import uuid
+import warnings
from functools import reduce
+from datetime import datetime, timedelta
from ..roles.models import Role
from ..checks import authorised_p
from ..privileges import Privilege
from ...db import sqlite3 as db
-from ...authentication.users import User
+from ...authentication.users import User, save_user, set_user_password
+
+
+def __process_age_clause__(age_desc: str) -> tuple[str, int]:
+ """Process the age clause and parameter for 'LIST USERS' query."""
+ _today = datetime.now()
+ _clause = "created"
+ _parts = age_desc.split(" ")
+ _multipliers = {
+ # Temporary hack before dateutil module can make it to our deployment.
+ "days": 1,
+ "months": 30,
+ "years": 365
+ }
+ assert len(_parts) in (3, 4), "Invalid age descriptor!"
+
+ _param = int((
+ _today - timedelta(**{"days": int(_parts[-2]) * _multipliers[_parts[-1]]})
+ ).timestamp())
+
+ match _parts[0]:
+ case "older":
+ return "created < :created", _param
+ case "younger":
+ return "created > :created", _param
+ case "exactly":
+ return "created = :created", _param
+ case _:
+ raise Exception("Invalid age descriptor.")# pylint: disable=[broad-exception-raised]
+
+
+def __list_user_clauses_and_params__(**kwargs) -> tuple[str, dict[str, str]]:
+ """Process the WHERE clauses, and params for the 'LIST USERS' query."""
+ clauses = ""
+ params = {}
+ if bool(kwargs.get("email", "").strip()) and bool(kwargs.get("name", "").strip()):
+ clauses = "(email LIKE :email OR name LIKE :name)"
+ params = {
+ "email": f'%{kwargs["email"].strip()}%',
+ "name": f'%{kwargs["name"].strip()}%'
+ }
+ elif bool(kwargs.get("email", "").strip()):
+ clauses = "email LIKE :email"
+ params["email"] = f'%{kwargs["email"].strip()}%'
+ elif bool(kwargs.get("name", "").strip()):
+ clauses = "name LIKE :name"
+ params["name"] = f'%{kwargs["name"].strip()}%'
+ else:
+ clauses = ""
+
+ if bool(kwargs.get("verified", "").strip()):
+ clauses = clauses + (" AND " if len(clauses) > 0 else "") + "verified=:verified"
+ params["verified"] = "1" if kwargs["verified"].strip() == "yes" else "0"
+
+ if bool(kwargs.get("age", "").strip()):
+ _clause, _param = __process_age_clause__(kwargs["age"].strip())
+ clauses = clauses + (" AND " if len(clauses) > 0 else "") + _clause
+ params["created"] = str(_param)
+
+ return clauses, params
+
@authorised_p(
("system:user:list",),
"You do not have the appropriate privileges to list users.",
oauth2_scope="profile user")
-def list_users(conn: db.DbConnection) -> tuple[User, ...]:
+def list_users(conn: db.DbConnection, **kwargs) -> tuple[User, ...]:
"""List out all users."""
+ _query = "SELECT * FROM users"
+ _clauses, _params = __list_user_clauses_and_params__(**kwargs)
+ if len(_clauses) > 0:
+ _query = _query + " WHERE " + _clauses
+
with db.cursor(conn) as cursor:
- cursor.execute("SELECT * FROM users")
+ cursor.execute(_query, _params)
return tuple(User.from_sqlite3_row(row) for row in cursor.fetchall())
def __build_resource_roles__(rows):
@@ -62,3 +129,59 @@ def user_resource_roles(conn: db.DbConnection, user: User) -> dict[uuid.UUID, tu
(str(user.user_id),))
return __build_resource_roles__(
(dict(row) for row in cursor.fetchall()))
+
+
+def delete_users_by_id(
+ conn: db.DbConnection,
+ user_ids: tuple[uuid.UUID, ...]
+) -> int:
+ """Delete users unconditionally by ID, removing all dependent data.
+
+ Unlike the HTTP endpoint, this bypasses all policy checks — users are
+ deleted regardless of their roles or group memberships. Returns the
+ number of users removed from the users table.
+ """
+ warnings.warn(
+ (f"Running dangerous function `{__name__}.delete_users_by_id`. "
+ "Do ensure that is what you actually want."),
+ category=RuntimeWarning)
+ if not user_ids:
+ return 0
+ _ids = tuple(str(uid) for uid in user_ids)
+ _paramstr = ", ".join(["?"] * len(_ids))
+ _dependent_tables = (
+ ("authorisation_code", "user_id"),
+ ("forgot_password_tokens", "user_id"),
+ ("group_join_requests", "requester_id"),
+ ("jwt_refresh_tokens", "user_id"),
+ ("oauth2_tokens", "user_id"),
+ ("user_credentials", "user_id"),
+ ("user_roles", "user_id"),
+ ("user_verification_codes", "user_id"),
+ )
+ with db.cursor(conn) as cursor:
+ for table, col in _dependent_tables:
+ cursor.execute(
+ f"DELETE FROM {table} WHERE {col} IN ({_paramstr})", _ids)
+ cursor.execute(
+ f"DELETE FROM users WHERE user_id IN ({_paramstr})", _ids)
+ return cursor.rowcount
+
+
+def create_credentialed_user(
+ conn: db.DbConnection,
+ email: str,
+ name: str,
+ password: str,
+ *,
+ verified: bool = False
+) -> User:
+ """Create a user with stored password credentials.
+
+ Caller controls the verified flag — pass verified=True to bypass the
+ normal email-verification flow (e.g. admin provisioning).
+ """
+ with db.cursor(conn) as cursor:
+ user = save_user(cursor, email, name, verified=verified)
+ set_user_password(cursor, user, password)
+ return user
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 7adcd06..f7ac055 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -1,14 +1,18 @@
"""User authorisation endpoints."""
import uuid
+import logging
import sqlite3
import secrets
import traceback
-from typing import Any
from functools import partial
+from typing import Any, Union
from dataclasses import asdict
from urllib.parse import urljoin
from datetime import datetime, timedelta
from email.headerregistry import Address
+
+
+import werkzeug.wrappers.response
from email_validator import validate_email, EmailNotValidError
from flask import (
flash,
@@ -16,22 +20,26 @@ from flask import (
jsonify,
url_for,
redirect,
- Response,
Blueprint,
current_app,
- render_template)
+ make_response,
+ render_template,
+ Response as _Response)
+
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
+from gn_libs.privileges.resources import can_assign_role
from gn_auth.smtp import send_message, build_email_message
from gn_auth.auth.requests import request_json
-from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authorisation.resources.models import (
user_resources as _user_resources)
from gn_auth.auth.authorisation.roles.models import (
- assign_default_roles, user_roles as _user_roles)
+ assign_default_roles, assign_user_role_by_name, unassign_user_role_by_name,
+ user_roles as _user_roles, user_roles_on_resource)
from gn_auth.auth.authorisation.resources.groups.models import (
user_group as _user_group)
@@ -39,10 +47,12 @@ from gn_auth.auth.errors import (
NotFoundError,
UsernameError,
PasswordError,
+ ForbiddenAccess,
UserRegistrationError)
-from gn_auth.auth.authentication.users import valid_login, user_by_email
+from gn_auth.auth.authentication.users import (
+ valid_login, user_by_email, user_by_id)
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from gn_auth.auth.authentication.users import User, save_user, set_user_password
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
@@ -52,10 +62,14 @@ from .models import list_users
from .masquerade.views import masq
from .collections.views import collections
+logger = logging.getLogger(__name__)
+
users = Blueprint("users", __name__)
users.register_blueprint(masq, url_prefix="/masquerade")
users.register_blueprint(collections, url_prefix="/collections")
+Response = Union[_Response, werkzeug.wrappers.response.Response]
+
@users.route("/", methods=["GET"])
@require_oauth("profile")
def user_details() -> Response:
@@ -71,9 +85,25 @@ def user_details() -> Response:
False, lambda grp: grp)# type: ignore[arg-type]
return jsonify({
**user_dets,
- "group": asdict(the_group) if the_group else False
+ **({"group": asdict(the_group)} if the_group else {})
})
+@users.route("/<user_id>", methods=["GET"])
+def get_user(user_id: str) -> Union[Response, tuple[Response, int]]:
+ """Fetch user details by user_id."""
+ try:
+ with db.connection(current_app.config["AUTH_DB"]) as conn:
+ user = user_by_id(conn, uuid.UUID(user_id))
+ return jsonify({
+ "user_id": str(user.user_id),
+ "email": user.email,
+ "name": user.name
+ })
+ except ValueError:
+ return jsonify({"error": "Invalid user ID format"}), 400
+ except NotFoundError:
+ return jsonify({"error": "User not found"}), 404
+
@users.route("/roles", methods=["GET"])
@require_oauth("role")
def user_roles() -> Response:
@@ -114,6 +144,30 @@ def user_address(user: User) -> Address:
"""Compute the `email.headerregistry.Address` from a `User`"""
return Address(display_name=user.name, addr_spec=user.email)
+
+def display_minutes_for_humans(minutes):
+ """Convert minutes into human-readable display."""
+ _week_ = 10080 # minutes
+ _day_ = 1440 # minutes
+ _remainder_ = minutes
+
+ _human_readable_ = ""
+ if _remainder_ >= _week_:
+ _weeks_ = _remainder_ // _week_
+ _remainder_ = _remainder_ % _week_
+ _human_readable_ += f"{_weeks_} week" + ("s" if _weeks_ > 1 else "")
+
+ if _remainder_ >= _day_:
+ _days_ = _remainder_ // _day_
+ _remainder_ = _remainder_ % _day_
+ _human_readable_ += (" " if bool(_human_readable_) else "") + \
+ f"{_days_} day" + ("s" if _days_ > 1 else "")
+
+ if _remainder_ > 0:
+ _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_remainder_} minutes"
+
+ return _human_readable_
+
def send_verification_email(
conn,
user: User,
@@ -125,7 +179,7 @@ def send_verification_email(
subject="GeneNetwork: Please Verify Your Email"
verification_code = secrets.token_urlsafe(64)
generated = datetime.now()
- expiration_minutes = 15
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
def __render__(template):
return render_template(template,
subject=subject,
@@ -137,7 +191,8 @@ def send_verification_email(
client_id=client_id,
redirect_uri=redirect_uri,
verificationcode=verification_code)),
- expiration_minutes=expiration_minutes)
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
with db.cursor(conn) as cursor:
cursor.execute(
("INSERT INTO "
@@ -180,7 +235,7 @@ def register_user() -> Response:
with db.cursor(conn) as cursor:
user, _hashed_password = set_user_password(
cursor, save_user(
- cursor, email["email"], user_name), password)
+ cursor, email["email"], user_name), password) # type: ignore
assign_default_roles(cursor, user)
send_verification_email(conn,
user,
@@ -189,14 +244,14 @@ def register_user() -> Response:
redirect_uri=form["redirect_uri"])
return jsonify(asdict(user))
except sqlite3.IntegrityError as sq3ie:
- current_app.logger.error(traceback.format_exc())
+ logger.error(traceback.format_exc())
raise UserRegistrationError(
"A user with that email already exists") from sq3ie
except EmailNotValidError as enve:
- current_app.logger.error(traceback.format_exc())
+ logger.error(traceback.format_exc())
raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve
- raise Exception(
+ raise Exception(# pylint: disable=[broad-exception-raised]
"unknown_error", "The system experienced an unexpected error.")
def delete_verification_code(cursor, code: str):
@@ -271,12 +326,21 @@ def user_group() -> Response:
@require_oauth("profile resource")
def user_resources() -> Response:
"""Retrieve the resources a user has access to."""
+ _request_params = request_json()
with require_oauth.acquire("profile resource") as the_token:
db_uri = current_app.config["AUTH_DB"]
with db.connection(db_uri) as conn:
- return jsonify([
- asdict(resource) for resource in
- _user_resources(conn, the_token.user)])
+ _resources, _total_records = _user_resources(
+ conn,
+ the_token.user,
+ start_at=int(_request_params.get("start", 0)),
+ count=int(_request_params.get("length", 0)),
+ text_filter=_request_params.get("text_filter", ""))
+ return jsonify({
+ "resources": [asdict(resource) for resource in _resources],
+ "total-records": _total_records,
+ "filtered-records": len(_resources)
+ })
@users.route("group/join-request", methods=["GET"])
@require_oauth("profile group")
@@ -299,16 +363,41 @@ def user_join_request_exists():
"exists": False
}
with require_oauth.acquire("profile group") as the_token:
- return jsonify(with_db_connection(partial(
- __request_exists__, user=the_token.user)))
+ return jsonify(with_db_connection(
+ current_app.config["SQL_URI"],
+ partial(__request_exists__, user=the_token.user)))
@users.route("/list", methods=["GET"])
@require_oauth("profile user")
def list_all_users() -> Response:
"""List all the users."""
- with require_oauth.acquire("profile group") as _the_token:
- return jsonify(tuple(
- asdict(user) for user in with_db_connection(list_users)))
+ _kwargs = (
+ {
+ key: value
+ for key, value in request_json().items()
+ if key in ("email", "name", "verified", "age")
+ }
+ or
+ {
+ "email": "", "name": "", "verified": "", "age": ""
+ }
+ )
+
+ with (require_oauth.acquire("profile group") as _the_token,
+ db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ _users = list_users(conn, **_kwargs)
+ _start = int(_kwargs.get("start", "0"))
+ _length = int(_kwargs.get("length", "0"))
+ cursor.execute("SELECT COUNT(*) FROM users")
+ _total_users = int(cursor.fetchone()["COUNT(*)"])
+ return jsonify({
+ "users": tuple(asdict(user) for user in
+ (_users[_start:_start+_length]
+ if _length else _users)),
+ "total-users": _total_users,
+ "total-filtered": len(_users)
+ })
@users.route("/handle-unverified", methods=["POST"])
def handle_unverified():
@@ -380,7 +469,7 @@ def send_forgot_password_email(
subject="GeneNetwork: Change Your Password"
token = secrets.token_urlsafe(64)
generated = datetime.now()
- expiration_minutes = 15
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
def __render__(template):
return render_template(template,
subject=subject,
@@ -391,7 +480,8 @@ def send_forgot_password_email(
client_id=client_id,
redirect_uri=redirect_uri,
response_type=response_type)),
- expiration_minutes=expiration_minutes)
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
with db.cursor(conn) as cursor:
cursor.execute(
@@ -504,3 +594,53 @@ def change_password(forgot_password_token):
flash("Both the password and its confirmation MUST be provided!",
"alert-danger")
return change_password_page
+
+
+@users.route("/<uuid:user_id>/roles/assign", methods=["POST"])
+def assign_user_role(user_id: uuid.UUID) -> Response:
+ """Assign a role to a user on a given resource."""
+ with (require_oauth.acquire("profile user resource role") as token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ form = request_json()
+ resource_id = uuid.UUID(form["resource_id"])
+ caller_roles = user_roles_on_resource(
+ conn, token.user.user_id, resource_id)
+ if not can_assign_role(tuple(
+ priv.privilege_id for role in caller_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `resource:user:assign-role` privilege.")
+ target = user_by_id(conn, user_id)
+ with db.cursor(conn) as cursor:
+ assign_user_role_by_name(
+ cursor, target, resource_id, form["role_name"])
+ return make_response(jsonify({
+ "user_id": str(user_id),
+ "role_name": form["role_name"],
+ "resource_id": form["resource_id"]
+ }), 200)
+
+
+@users.route("/<uuid:user_id>/roles/revoke", methods=["POST"])
+def revoke_user_role(user_id: uuid.UUID) -> Response:
+ """Revoke a role from a user on a given resource."""
+ with (require_oauth.acquire("profile user resource role") as token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ form = request_json()
+ resource_id = uuid.UUID(form["resource_id"])
+ caller_roles = user_roles_on_resource(
+ conn, token.user.user_id, resource_id)
+ if not can_assign_role(tuple(
+ priv.privilege_id for role in caller_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `resource:user:assign-role` privilege.")
+ target = user_by_id(conn, user_id)
+ with db.cursor(conn) as cursor:
+ unassign_user_role_by_name(
+ cursor, target, resource_id, form["role_name"])
+ return make_response(jsonify({
+ "user_id": str(user_id),
+ "role_name": form["role_name"],
+ "resource_id": form["resource_id"]
+ }), 200)
diff --git a/gn_auth/auth/db/mariadb.py b/gn_auth/auth/db/mariadb.py
deleted file mode 100644
index a36e9d3..0000000
--- a/gn_auth/auth/db/mariadb.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Connections to MariaDB"""
-import logging
-import traceback
-import contextlib
-from urllib.parse import urlparse
-from typing import Any, Tuple, Protocol, Iterator
-
-import MySQLdb as mdb
-
-class DbConnection(Protocol):
- """Type annotation for a generic database connection object."""
- def cursor(self, *args, **kwargs) -> Any:
- """A cursor object"""
-
- def commit(self, *args, **kwargs) -> Any:
- """Commit the transaction."""
-
- def rollback(self) -> Any:
- """Rollback the transaction."""
-
-def parse_db_url(sql_uri: str) -> Tuple:
- """Parse SQL_URI env variable note:there is a default value for SQL_URI so a
- tuple result is always expected"""
- parsed_db = urlparse(sql_uri)
- return (
- parsed_db.hostname, parsed_db.username, parsed_db.password,
- parsed_db.path[1:], parsed_db.port)
-
-@contextlib.contextmanager
-def database_connection(sql_uri) -> Iterator[DbConnection]:
- """Connect to MySQL database."""
- host, user, passwd, db_name, port = parse_db_url(sql_uri)
- connection = mdb.connect(db=db_name,
- user=user,
- passwd=passwd or '',
- host=host,
- port=port or 3306)
- try:
- yield connection
- except mdb.Error as _mdb_err:
- logging.debug(traceback.format_exc())
- connection.rollback()
- finally:
- connection.commit()
- connection.close()
diff --git a/gn_auth/auth/db/sqlite3.py b/gn_auth/auth/db/sqlite3.py
index 12a46c7..5f54752 100644
--- a/gn_auth/auth/db/sqlite3.py
+++ b/gn_auth/auth/db/sqlite3.py
@@ -1,63 +1,28 @@
"""Handle connection to auth database."""
-import sqlite3
-import logging
-import contextlib
-from typing import Any, Protocol, Callable, Iterator
-
-import traceback
+import warnings
+from typing import Any, Callable
from flask import current_app
-from .protocols import DbCursor
-
-class DbConnection(Protocol):
- """Type annotation for a generic database connection object."""
- def cursor(self) -> Any:
- """A cursor object"""
-
- def commit(self) -> Any:
- """Commit the transaction."""
-
- def rollback(self) -> Any:
- """Rollback the transaction."""
+from gn_libs.sqlite3 import cursor, connection # pylint: disable=[unused-import]
+from gn_libs.protocols import DbCursor, DbConnection # pylint: disable=[unused-import]
-@contextlib.contextmanager
-def connection(db_path: str, row_factory: Callable = sqlite3.Row) -> Iterator[DbConnection]:
- """Create the connection to the auth database."""
- logging.debug("SQLite3 DB Path: '%s'.", db_path)
- conn = sqlite3.connect(db_path)
- conn.row_factory = row_factory
- conn.set_trace_callback(logging.debug)
- conn.execute("PRAGMA foreign_keys = ON")
- try:
- yield conn
- except sqlite3.Error as exc:
- conn.rollback()
- logging.debug(traceback.format_exc())
- raise exc
- finally:
- conn.commit()
- conn.close()
+warnings.warn(
+ f"Module '{__name__}' is deprecated. Use `gn_libs.sqlite3` instead.",
+ category=DeprecationWarning,
+ stacklevel=2)
-@contextlib.contextmanager
-def cursor(conn: DbConnection) -> Iterator[DbCursor]:
- """Get a cursor from the given connection to the auth database."""
- cur = conn.cursor()
- try:
- yield cur
- conn.commit()
- except sqlite3.Error as exc:
- conn.rollback()
- logging.debug(traceback.format_exc())
- raise exc
- finally:
- cur.close()
def with_db_connection(func: Callable[[DbConnection], Any]) -> Any:
"""
Takes a function of one argument `func`, whose one argument is a database
connection.
"""
+ warnings.warn(
+ (f"Function '{__name__}.with_db_connection' is deprecated. "
+ "Use `gn_libs.sqlite3.with_db_connection` instead."),
+ category=DeprecationWarning,
+ stacklevel=2)
db_uri = current_app.config["AUTH_DB"]
with connection(db_uri) as conn:
return func(conn)
diff --git a/gn_auth/auth/errors.py b/gn_auth/auth/errors.py
index 77b73aa..832d1bd 100644
--- a/gn_auth/auth/errors.py
+++ b/gn_auth/auth/errors.py
@@ -6,7 +6,7 @@ class AuthorisationError(Exception):
All exceptions in this package should inherit from this class.
"""
- error_code: int = 400
+ error_code: int = 401
class ForbiddenAccess(AuthorisationError):
"""Raised for forbidden access."""
@@ -14,6 +14,7 @@ class ForbiddenAccess(AuthorisationError):
class UserRegistrationError(AuthorisationError):
"""Raised whenever a user registration fails"""
+ error_code: int = 400
class UserVerificationError(UserRegistrationError):
"""Raised when verification of a user fails."""
@@ -26,6 +27,7 @@ class InvalidData(AuthorisationError):
"""
Exception if user requests invalid data
"""
+ error_code: int = 400
class InconsistencyError(AuthorisationError):
"""
@@ -37,8 +39,10 @@ class PasswordError(AuthorisationError):
"""
Raise in case of an error with passwords.
"""
+ error_code: int = 400
class UsernameError(AuthorisationError):
"""
Raise in case of an error with a user's name.
"""
+ error_code: int = 400
diff --git a/gn_auth/auth/requests.py b/gn_auth/auth/requests.py
index 00e9b35..01ff765 100644
--- a/gn_auth/auth/requests.py
+++ b/gn_auth/auth/requests.py
@@ -3,4 +3,10 @@ from flask import request
def request_json() -> dict:
"""Retrieve the JSON sent in a request."""
- return request.json or dict(request.form) or {}
+ if request.headers.get("Content-Type") == "application/json":
+ # KLUDGE: We have this check here since request.json has the
+ # type Any | None; see:
+ # <https://github.com/pallets/werkzeug/blob/7868bef5d978093a8baa0784464ebe5d775ae92a/src/werkzeug/wrappers/request.py#L545>
+ return request.json or {}
+ else:
+ return dict(request.args) or dict(request.form) or {}
diff --git a/gn_auth/auth/system/__init__.py b/gn_auth/auth/system/__init__.py
new file mode 100644
index 0000000..5455f73
--- /dev/null
+++ b/gn_auth/auth/system/__init__.py
@@ -0,0 +1,5 @@
+"""This is for system-specific functionality, e.g. administration.
+
+This is not meant for day-to-day user activities, more for administrative tasks
+to fix things.
+"""
diff --git a/gn_auth/auth/system/admin/resources.py b/gn_auth/auth/system/admin/resources.py
new file mode 100644
index 0000000..59d7686
--- /dev/null
+++ b/gn_auth/auth/system/admin/resources.py
@@ -0,0 +1,71 @@
+"""Administrative endpoints concerning resources."""
+from uuid import UUID
+
+from flask import jsonify, Response, Blueprint, current_app as app
+
+from gn_libs import sqlite3 as db
+
+from gn_auth.auth.errors import ForbiddenAccess
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.roles.models import (
+ user_roles_on_resource,
+ assign_user_role_by_name,
+ unassign_user_role_by_name)
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+
+resources = Blueprint("resources", __name__)
+
+
+def _require_assign_owner_privilege(conn, user) -> None:
+ """Raise ForbiddenAccess if user lacks system:resource:assign-owner on the system resource."""
+ _sys = system_resource(conn)
+ sys_roles = user_roles_on_resource(conn, user.user_id, _sys.resource_id)
+ sys_privs = tuple(
+ priv.privilege_id for role in sys_roles for priv in role.privileges)
+ if "system:resource:assign-owner" not in sys_privs:
+ raise ForbiddenAccess(
+ "You need the 'system:resource:assign-owner' privilege.")
+
+
+@resources.route("/<uuid:resource_id>/assign-owner", methods=["POST"])
+def assign_resource_owner(resource_id: UUID) -> Response:
+ """Assign the resource-owner role to a user on the given resource.
+
+ Only users with system:resource:assign-owner (sysadmins) may call this.
+ This is the correct path to bootstrap ownership on a resource that has
+ no owner yet.
+ """
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ _require_assign_owner_privilege(conn, _token.user)
+ form = request_json()
+ target = user_by_id(conn, UUID(form["user_id"]))
+ with db.cursor(conn) as cursor:
+ assign_user_role_by_name(cursor, target, resource_id, "resource-owner")
+ return jsonify({
+ "user_id": form["user_id"],
+ "resource_id": str(resource_id),
+ "role_name": "resource-owner",
+ })
+
+
+@resources.route("/<uuid:resource_id>/revoke-owner", methods=["POST"])
+def revoke_resource_owner(resource_id: UUID) -> Response:
+ """Revoke the resource-owner role from a user on the given resource.
+
+ Requires the same system:resource:assign-owner privilege as assign-owner.
+ """
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ _require_assign_owner_privilege(conn, _token.user)
+ form = request_json()
+ target = user_by_id(conn, UUID(form["user_id"]))
+ with db.cursor(conn) as cursor:
+ unassign_user_role_by_name(cursor, target, resource_id, "resource-owner")
+ return jsonify({
+ "user_id": form["user_id"],
+ "resource_id": str(resource_id),
+ "role_name": "resource-owner",
+ })
diff --git a/gn_auth/auth/system/admin/users.py b/gn_auth/auth/system/admin/users.py
new file mode 100644
index 0000000..7dfc7ca
--- /dev/null
+++ b/gn_auth/auth/system/admin/users.py
@@ -0,0 +1,232 @@
+"""Administrative endpoints for user management."""
+import sqlite3
+from functools import reduce
+from typing import Sequence
+
+from flask import jsonify, Response, Blueprint, make_response, current_app as app
+
+from gn_libs import sqlite3 as db
+
+from email_validator import validate_email, EmailNotValidError
+
+from gn_libs.privileges.system import can_create_or_delete_user
+
+from gn_auth.auth.errors import (
+ PasswordError,
+ UsernameError,
+ ForbiddenAccess,
+ UserRegistrationError)
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.resources.system.models import user_roles_on_system
+from gn_auth.auth.authorisation.users.admin.models import create_verified_user
+from gn_auth.auth.authorisation.users.views import (
+ validate_password,
+ validate_username)
+
+users = Blueprint("users", __name__)
+
+
+@users.route("/create", methods=["POST"])
+def create_user() -> Response:
+ """Create a new user. Requires system:user:create-user privilege."""
+ with (require_oauth.acquire("profile") as token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ u_roles = user_roles_on_system(conn, token.user)
+ if not can_create_or_delete_user(tuple(
+ priv.privilege_id for role in u_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `system:user:create-user` privilege.")
+ form = request_json()
+ errors = {}
+ try:
+ email = validate_email(
+ form.get("email", "").strip(), check_deliverability=False)
+ except EmailNotValidError as enve:
+ errors["email"] = (
+ f"E-Mail error: {'==>'.join(str(arg) for arg in enve.args)}")
+
+ try:
+ username = validate_username(form.get("name", "").strip())
+ except UsernameError as uerr:
+ errors["name"] = str(uerr.args[0])
+
+ try:
+ passwd = validate_password(
+ form.get("password", "").strip(),
+ form.get("password", "").strip())
+ except PasswordError as perr:
+ errors["password"] = str(perr.args[0])
+
+ if len(tuple(errors.keys())) > 0:
+ raise UserRegistrationError(tuple(errors.values()))
+
+ user = create_verified_user(conn, email.normalized, username, passwd)
+
+ return make_response(jsonify({
+ "user_id": str(user.user_id),
+ "email": user.email,
+ "name": user.name
+ }), 201)
+
+
+def __delete_users_individually__(cursor, user_ids, tables):
+ """Recovery function with dismal performance."""
+ _errors = tuple()
+ for _user_id in user_ids:
+ for _table, _col in tables:
+ try:
+ cursor.execute(
+ f"DELETE FROM {_table} WHERE {_col}=?",
+ (str(_user_id),))
+ except sqlite3.IntegrityError:
+ _errors = _errors + (
+ (("user_id", _user_id),
+ ("reason", f"User has data in table {_table}")),)
+
+ return _errors
+
+
+def __fetch_non_deletable_users__(cursor, ids_and_reasons):
+ """Fetch detail for non-deletable users."""
+ def __merge__(acc, curr):
+ _curr = dict(curr)
+ _this_dict = acc.get(
+ curr["user_id"], {"reasons": tuple()})
+ _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],)
+ return {**acc, curr["user_id"]: _this_dict}
+
+ _reasons_by_id = reduce(__merge__,
+ (dict(row) for row in ids_and_reasons),
+ {})
+ _user_ids = tuple(_reasons_by_id.keys())
+ _paramstr = ", ".join(["?"] * len(_user_ids))
+ cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})",
+ _user_ids)
+ return tuple({
+ "user": dict(row),
+ "reasons": _reasons_by_id[row["user_id"]]["reasons"]
+ } for row in cursor.fetchall())
+
+
+def __non_deletable_with_reason__(
+ user_ids: tuple[str, ...],
+ dbrows: Sequence[sqlite3.Row],
+ reason: str
+ ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]:
+ """Build a list of 'non-deletable' user objects."""
+ return tuple((("user_id", _uid), ("reason", reason))
+ for _uid in user_ids
+ if _uid in tuple(row["user_id"] for row in dbrows))
+
+
+@users.route("/delete", methods=["POST"])
+def delete_users() -> Response:
+ """Delete the specified users. Requires system:user:delete-user privilege."""
+ with (require_oauth.acquire("profile user role") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ u_roles = user_roles_on_system(conn, _token.user)
+ if not can_create_or_delete_user(tuple(
+ priv.privilege_id for role in u_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `system:user:delete-user` privilege to delete "
+ "users from the system.")
+
+ _form = request_json()
+ _user_ids = _form.get("user_ids", [])
+ _non_deletable = set()
+ if str(_token.user.user_id) in _user_ids:
+ _non_deletable.add(
+ (("user_id", str(_token.user.user_id),),
+ ("reason", "You are not allowed to delete yourself.")))
+
+ cursor.execute("SELECT user_id FROM group_users")
+ _group_members = tuple(row["user_id"] for row in cursor.fetchall())
+ _non_deletable.update(__non_deletable_with_reason__(
+ _user_ids,
+ cursor.fetchall(),
+ "User is member of a user group."))
+
+ cursor.execute("SELECT user_id FROM oauth2_clients;")
+ _non_deletable.update(__non_deletable_with_reason__(
+ _user_ids,
+ cursor.fetchall(),
+ "User is registered owner of an OAuth client."))
+
+ _important_roles = (
+ "group-leader",
+ "resource-owner",
+ "system-administrator",
+ "inbredset-group-owner")
+ _paramstr = ",".join(["?"] * len(_important_roles))
+ cursor.execute(
+ "SELECT DISTINCT user_roles.user_id FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ f"WHERE roles.role_name IN ({_paramstr})",
+ _important_roles)
+ _non_deletable.update(__non_deletable_with_reason__(
+ _user_ids,
+ cursor.fetchall(),
+ f"User holds on of the following roles: {_important_roles}"))
+
+ _delete = tuple(uid for uid in _user_ids if uid not in
+ (dict(row)["user_id"] for row in _non_deletable))
+ _paramstr = ", ".join(["?"] * len(_delete))
+ if len(_delete) > 0:
+ _dependent_tables = (
+ ("authorisation_code", "user_id"),
+ ("forgot_password_tokens", "user_id"),
+ ("group_join_requests", "requester_id"),
+ ("jwt_refresh_tokens", "user_id"),
+ ("oauth2_tokens", "user_id"),
+ ("user_credentials", "user_id"),
+ ("user_roles", "user_id"),
+ ("user_verification_codes", "user_id"))
+ try:
+ for _table, _col in _dependent_tables:
+ cursor.execute(
+ f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+ _delete)
+ except sqlite3.IntegrityError:
+ _non_deletable.update(__delete_users_individually__(
+ cursor, _delete, _dependent_tables))
+
+ _not_deleted = __fetch_non_deletable_users__(
+ cursor, _non_deletable)
+ _delete = tuple(# rebuild with those that failed.
+ _user_id for _user_id in _delete if _user_id not in
+ tuple(row["user"]["user_id"] for row in _not_deleted))
+ _paramstr = ", ".join(["?"] * len(_delete))
+ cursor.execute(
+ f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+ _delete)
+ _deleted_rows = cursor.rowcount
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": _deleted_rows,
+ "not-deleted": _not_deleted,
+ "deleted": _deleted_rows,
+ "message": (
+ f"Successfully deleted {_deleted_rows} users." +
+ (" Some users could not be deleted."
+ if len(_user_ids) - _deleted_rows > 0
+ else ""))
+ })
+
+ _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable)
+
+ return make_response(jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": 0,
+ "not-deleted": _not_deleted,
+ "deleted": 0,
+ "error": "Zero users were deleted",
+ "error_description": (
+ "No users were selected for deletion."
+ if len(_user_ids) == 0
+ else ("The selected users are system administrators, group "
+ "members, or resource owners."))
+ }), 400)
diff --git a/gn_auth/auth/system/admin/views.py b/gn_auth/auth/system/admin/views.py
new file mode 100644
index 0000000..f1f76f9
--- /dev/null
+++ b/gn_auth/auth/system/admin/views.py
@@ -0,0 +1,10 @@
+"""Administrative endpoints."""
+
+from flask import Blueprint
+
+from .resources import resources
+from .users import users
+
+admin = Blueprint("admin", __name__)
+admin.register_blueprint(resources, url_prefix="/resources")
+admin.register_blueprint(users, url_prefix="/users")
diff --git a/gn_auth/auth/system/views.py b/gn_auth/auth/system/views.py
new file mode 100644
index 0000000..2cebfbc
--- /dev/null
+++ b/gn_auth/auth/system/views.py
@@ -0,0 +1,8 @@
+"""The Blueprints for this package."""
+
+from flask import Blueprint
+
+from .admin.views import admin
+
+systembp = Blueprint("sysadm", __name__)
+systembp.register_blueprint(admin, url_prefix="/administration")
diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py
index 6867f38..383dc9f 100644
--- a/gn_auth/auth/views.py
+++ b/gn_auth/auth/views.py
@@ -10,7 +10,9 @@ from .authorisation.roles.views import roles
from .authorisation.resources.views import resources
from .authorisation.privileges.views import privileges
from .authorisation.resources.groups.views import groups
-from .authorisation.resources.system.views import system
+
+from .system.views import systembp
+
oauth2 = Blueprint("oauth2", __name__)
@@ -20,6 +22,6 @@ oauth2.register_blueprint(users, url_prefix="/user")
oauth2.register_blueprint(roles, url_prefix="/role")
oauth2.register_blueprint(admin, url_prefix="/admin")
oauth2.register_blueprint(groups, url_prefix="/group")
-oauth2.register_blueprint(system, url_prefix="/system")
oauth2.register_blueprint(resources, url_prefix="/resource")
oauth2.register_blueprint(privileges, url_prefix="/privileges")
+oauth2.register_blueprint(systembp, url_prefix="/system")
diff --git a/gn_auth/debug.py b/gn_auth/debug.py
deleted file mode 100644
index 6b7173b..0000000
--- a/gn_auth/debug.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""Debug utilities"""
-import logging
-from flask import current_app
-
-__this_module_name__ = __name__
-
-
-# pylint: disable=invalid-name
-def getLogger(name: str):
- """Return a logger"""
- return (
- logging.getLogger(name)
- if not bool(current_app)
- else current_app.logger)
-
-def __pk__(*args):
- """Format log entry"""
- value = args[-1]
- title_vals = " => ".join(args[0:-1])
- logger = getLogger(__this_module_name__)
- logger.debug("%s: %s", title_vals, value)
- return value
diff --git a/gn_auth/errors.py b/gn_auth/errors.py
deleted file mode 100644
index 4b6007a..0000000
--- a/gn_auth/errors.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""Handle application level errors."""
-import traceback
-
-from werkzeug.exceptions import NotFound
-from flask import Flask, request, jsonify, current_app, render_template
-
-from gn_auth.auth.errors import AuthorisationError
-
-def add_trace(exc: Exception, errobj: dict) -> dict:
- """Add the traceback to the error handling object."""
- current_app.logger.error("Endpoint: %s\n%s",
- request.url,
- traceback.format_exception(exc))
- return {
- **errobj,
- "error-trace": "".join(traceback.format_exception(exc))
- }
-
-def page_not_found(exc):
- """404 handler."""
- current_app.logger.error(f"Page '{request.url}' was not found.", exc_info=True)
- content_type = request.content_type
- if bool(content_type) and content_type.lower() == "application/json":
- return jsonify(add_trace(exc, {
- "error": exc.name,
- "error_description": (f"The page '{request.url}' does not exist on "
- "this server.")
- })), exc.code
-
- return render_template("404.html", page=request.url), exc.code
-
-
-def handle_general_exception(exc: Exception):
- """Handle generic unhandled exceptions."""
- current_app.logger.error("Error occurred!", exc_info=True)
- content_type = request.content_type
- if bool(content_type) and content_type.lower() == "application/json":
- exc_args = [str(x) for x in exc.args]
- msg = ("The following exception was raised while attempting to access "
- f"{request.url}: {' '.join(exc_args)}")
- return jsonify(add_trace(exc, {
- "error": type(exc).__name__,
- "error_description": msg
- })), 500
-
- return render_template("50x.html",
- page=request.url,
- error=exc,
- trace=traceback.format_exception(exc)), 500
-
-
-def handle_authorisation_error(exc: AuthorisationError):
- """Handle AuthorisationError if not handled anywhere else."""
- current_app.logger.error("Error occurred!", exc_info=True)
- current_app.logger.error(exc)
- return jsonify(add_trace(exc, {
- "error": type(exc).__name__,
- "error_description": " :: ".join(exc.args)
- })), exc.error_code
-
-__error_handlers__ = {
- NotFound: page_not_found,
- Exception: handle_general_exception,
- AuthorisationError: handle_authorisation_error
-}
-def register_error_handlers(app: Flask):
- """Register ALL defined error handlers"""
- for class_, error_handler in __error_handlers__.items():
- app.register_error_handler(class_, error_handler)
diff --git a/gn_auth/errors/__init__.py b/gn_auth/errors/__init__.py
new file mode 100644
index 0000000..97d1e9e
--- /dev/null
+++ b/gn_auth/errors/__init__.py
@@ -0,0 +1,48 @@
+"""Handle application level errors."""
+import logging
+import traceback
+
+from werkzeug.exceptions import NotFound, HTTPException
+from flask import (Flask,
+ request,
+ jsonify,
+ render_template)
+
+from gn_auth.auth.errors import AuthorisationError
+
+from .http import http_error_handlers
+from .authlib import authlib_error_handlers
+from .common import add_trace, build_handler
+
+logger = logging.getLogger(__name__)
+
+__all__ = ["register_error_handlers"]
+
+
+def handle_general_exception(exc: Exception):
+ """Handle generic unhandled exceptions."""
+ exc_args = [str(x) for x in exc.args]
+ _handle = build_handler("A generic exception occurred: "
+ " ".join(exc_args))
+ return _handle(exc)
+
+
+def handle_authorisation_error(exc: AuthorisationError):
+ """Handle AuthorisationError if not handled anywhere else."""
+ exc_args = [str(x) for x in exc.args]
+ _handle = build_handler("A generic authorisation error occurred: "
+ " ".join(exc_args))
+ return _handle(exc)
+
+
+def register_error_handlers(app: Flask):
+ """Register ALL defined error handlers"""
+ _handlers = {
+ **authlib_error_handlers(),
+ **http_error_handlers(),
+ Exception: handle_general_exception,
+ AuthorisationError: handle_authorisation_error
+ }
+ for class_, error_handler in _handlers.items():
+ logger.debug("Register handler for %s", class_.__name__)
+ app.register_error_handler(class_, error_handler)
diff --git a/gn_auth/errors/authlib.py b/gn_auth/errors/authlib.py
new file mode 100644
index 0000000..c85b67c
--- /dev/null
+++ b/gn_auth/errors/authlib.py
@@ -0,0 +1,34 @@
+"""Handle authlib errors."""
+import json
+import logging
+
+from authlib.integrations.flask_oauth2.errors import _HTTPException
+
+from gn_auth.errors.common import build_handler
+
+logger = logging.getLogger(__name__)
+
+
+def __description__(body):
+ """Improve description for errors in authlib.oauth2.rfc6749.errors"""
+ _desc = body.get("error_description", body["error"])
+ match body["error"]:
+ case "missing_authorization":
+ return (
+ 'The expected "Authorization: Bearer ..." token was not found '
+ 'in the headers. Do please try again with the token provided.')
+ case _:
+ return _desc
+
+
+def _http_exception_handler_(exc: _HTTPException):
+ """Handle Authlib's `_HTTPException` errors."""
+ _handle = build_handler(__description__(json.loads(exc.body)))
+ return _handle(exc)
+
+
+def authlib_error_handlers() -> dict:
+ """Return handlers for Authlib errors"""
+ return {
+ _HTTPException: _http_exception_handler_
+ }
diff --git a/gn_auth/errors/common.py b/gn_auth/errors/common.py
new file mode 100644
index 0000000..8dc0373
--- /dev/null
+++ b/gn_auth/errors/common.py
@@ -0,0 +1,58 @@
+"""Common utilities."""
+import logging
+import traceback
+from typing import Callable
+
+from flask import request, Response, make_response, render_template
+
+logger = logging.getLogger(__name__)
+
+
+def add_trace(exc: Exception, errobj: dict) -> dict:
+ """Add the traceback to the error handling object."""
+ return {
+ **errobj,
+ "error-trace": "".join(traceback.format_exception(exc))
+ }
+
+def __status_code__(exc: Exception):
+ """Fetch the error code for exceptions that have them."""
+ error_code_attributes = (
+ "code", "error_code", "errorcode", "status_code", "status_code")
+ for attr in error_code_attributes:
+ if hasattr(exc, attr):
+ return getattr(exc, attr)
+
+ return 500
+
+
+def build_handler(description: str) -> Callable[[Exception], Response]:
+ """Generic utility to build error handlers."""
+ def __handler__(exc: Exception) -> Response:
+ """Handle the exception as appropriate for requests of different mimetypes."""
+ error = (exc.name if hasattr(exc, "name") else exc.__class__.__name__)
+ status_code = __status_code__(exc)
+ content_type = request.content_type
+ if bool(content_type) and content_type.lower() == "application/json":
+ return make_response((
+ add_trace(
+ exc,
+ {
+ "requested-uri": request.url,
+ "error": error,
+ "error_description": description
+ }),
+ status_code,
+ {"Content-Type": "application/json"}))
+
+ return make_response((
+ render_template(
+ f"http-error-{str(status_code)[0:-2]}xx.html",
+ error=exc,
+ page=request.url,
+ description=description,
+ trace=traceback.format_exception(exc)),
+ status_code,
+ {"Content-Type": "text/html"}))
+
+ return __handler__
diff --git a/gn_auth/errors/http/__init__.py b/gn_auth/errors/http/__init__.py
new file mode 100644
index 0000000..f4164d1
--- /dev/null
+++ b/gn_auth/errors/http/__init__.py
@@ -0,0 +1,13 @@
+"""HTTP error handlers."""
+
+from .http_4xx_errors import http_4xx_error_handlers
+from .http_5xx_errors import http_5xx_error_handlers
+
+__all__ = ["http_error_handlers"]
+
+def http_error_handlers() -> dict:
+ """Return *ALL* HTTP error handlers."""
+ return {
+ **http_4xx_error_handlers(),
+ **http_5xx_error_handlers()
+ }
diff --git a/gn_auth/errors/http/http_4xx_errors.py b/gn_auth/errors/http/http_4xx_errors.py
new file mode 100644
index 0000000..3a2ed88
--- /dev/null
+++ b/gn_auth/errors/http/http_4xx_errors.py
@@ -0,0 +1,23 @@
+"""Handlers for HTTP 4** errors"""
+import logging
+
+from werkzeug.exceptions import NotFound, Forbidden, Unauthorized
+
+from gn_auth.errors.common import build_handler
+
+__all__ = ["http_4xx_error_handlers"]
+
+logger = logging.getLogger(__name__)
+
+
+def http_4xx_error_handlers() -> dict:
+ """Return handlers for HTTP errors in the 400-499 range"""
+ return {
+ Forbidden: build_handler(
+ "You do not have the necessary privileges to access the requested "
+ "resource."),
+ NotFound: build_handler(
+ "The requested page does not exist on this server."),
+ Unauthorized: build_handler(
+ "You are not authorised to access the requested resource.")
+ }
diff --git a/gn_auth/errors/http/http_5xx_errors.py b/gn_auth/errors/http/http_5xx_errors.py
new file mode 100644
index 0000000..71d09d8
--- /dev/null
+++ b/gn_auth/errors/http/http_5xx_errors.py
@@ -0,0 +1,7 @@
+"""Handlers for HTTP 5** errors."""
+
+__all__ = ["http_5xx_error_handlers"]
+
+def http_5xx_error_handlers() -> dict:
+ """Return handlers for HTTP errors in the 500-599 range"""
+ return {}
diff --git a/gn_auth/hooks.py b/gn_auth/hooks.py
index bd7380b..f15107f 100644
--- a/gn_auth/hooks.py
+++ b/gn_auth/hooks.py
@@ -5,7 +5,7 @@ from typing import List
from flask import request_finished
from flask import request, current_app
-from gn_auth.auth.db import sqlite3 as db
+from gn_libs import sqlite3 as db
def register_hooks(app):
"""Initialise hooks system on the application."""
diff --git a/gn_auth/jobs.py b/gn_auth/jobs.py
index 8f9f4f0..7cd5945 100644
--- a/gn_auth/jobs.py
+++ b/gn_auth/jobs.py
@@ -24,7 +24,7 @@ def job(redisconn: Redis, job_id: UUID) -> Either:
if the_job:
return Right({
key: json.loads(value, object_hook=jed.custom_json_decoder)
- for key, value in the_job.items()
+ for key, value in the_job.items() # type: ignore
})
return Left({
"error": "NotFound",
diff --git a/gn_auth/migrations.py b/gn_auth/migrations/__init__.py
index 3451e07..6acb058 100644
--- a/gn_auth/migrations.py
+++ b/gn_auth/migrations/__init__.py
@@ -1,4 +1,5 @@
-"""Run the migrations in the app, rather than with yoyo CLI."""
+"""Migrations package: Provides the migrations, and some utility functions to
+help with dealing with migrations."""
from pathlib import Path
from typing import Union
diff --git a/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py b/gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py
index d511f5d..d511f5d 100644
--- a/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py
+++ b/gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py
diff --git a/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py b/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
index 48bd663..48bd663 100644
--- a/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
+++ b/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
diff --git a/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py b/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
index 29f92d4..29f92d4 100644
--- a/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
+++ b/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
diff --git a/migrations/auth/20221108_02_wxTr9-create-privileges-table.py b/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
index 67720b2..67720b2 100644
--- a/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
+++ b/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
diff --git a/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py b/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
index ce752ef..ce752ef 100644
--- a/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
+++ b/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
diff --git a/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py b/gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py
index 76ffbef..76ffbef 100644
--- a/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py
+++ b/gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py
diff --git a/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py b/gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py
index 6c829b1..6c829b1 100644
--- a/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py
+++ b/gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py
diff --git a/migrations/auth/20221110_01_WtZ1I-create-resources-table.py b/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
index abc8895..abc8895 100644
--- a/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
+++ b/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
diff --git a/migrations/auth/20221110_05_BaNtL-create-roles-table.py b/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py
index 51e19e8..51e19e8 100644
--- a/migrations/auth/20221110_05_BaNtL-create-roles-table.py
+++ b/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py
diff --git a/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py b/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
index 2b55c2b..2b55c2b 100644
--- a/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
+++ b/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
diff --git a/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py b/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
index 0d0eeb9..0d0eeb9 100644
--- a/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
+++ b/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
diff --git a/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py b/gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py
index 077182b..077182b 100644
--- a/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py
+++ b/gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py
diff --git a/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py b/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
index 072f226..072f226 100644
--- a/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
+++ b/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
diff --git a/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py b/gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py
index 2048f4a..2048f4a 100644
--- a/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py
+++ b/gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py
diff --git a/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py b/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
index 6bd101b..6bd101b 100644
--- a/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
+++ b/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
diff --git a/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py b/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
index a7e7b45..a7e7b45 100644
--- a/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
+++ b/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
diff --git a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py b/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
index 386f481..386f481 100644
--- a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
+++ b/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
diff --git a/migrations/auth/20221114_05_hQun6-create-user-roles-table.py b/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
index e0de751..e0de751 100644
--- a/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
+++ b/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
diff --git a/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py b/gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py
index 2e4ae28..2e4ae28 100644
--- a/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py
+++ b/gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py
diff --git a/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py b/gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py
index a4d7806..a4d7806 100644
--- a/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py
+++ b/gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py
diff --git a/migrations/auth/20221117_02_fmuZh-create-group-users-table.py b/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
index 92885ef..92885ef 100644
--- a/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
+++ b/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
diff --git a/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py b/gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py
index 9aa3667..9aa3667 100644
--- a/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py
+++ b/gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py
diff --git a/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py b/gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py
index 2238069..2238069 100644
--- a/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py
+++ b/gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py
diff --git a/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py b/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
index 475be01..475be01 100644
--- a/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
+++ b/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
diff --git a/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py b/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
index 778282b..778282b 100644
--- a/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
+++ b/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
diff --git a/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py b/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
index 1683f87..1683f87 100644
--- a/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
+++ b/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
diff --git a/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py b/gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py
index 7e7fda2..7e7fda2 100644
--- a/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py
+++ b/gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py
diff --git a/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py b/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
index 1ef5ab0..1ef5ab0 100644
--- a/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
+++ b/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
diff --git a/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py b/gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py
index ceae5ea..ceae5ea 100644
--- a/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py
+++ b/gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py
diff --git a/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py b/gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py
index 8b406a6..8b406a6 100644
--- a/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py
+++ b/gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py
diff --git a/migrations/auth/20230210_02_lDK14-create-system-admin-role.py b/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
index 9b3fc2b..9b3fc2b 100644
--- a/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
+++ b/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
diff --git a/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py b/gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py
index 84bbd49..84bbd49 100644
--- a/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py
+++ b/gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py
diff --git a/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py b/gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py
index 3caad55..3caad55 100644
--- a/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py
+++ b/gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py
diff --git a/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py b/gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py
index 647325f..647325f 100644
--- a/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py
+++ b/gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py
diff --git a/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py b/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
index 7c9e986..7c9e986 100644
--- a/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
+++ b/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
diff --git a/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py b/gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py
index 02e8718..02e8718 100644
--- a/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py
+++ b/gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py
diff --git a/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py b/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
index 1a865e0..1a865e0 100644
--- a/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
+++ b/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
diff --git a/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py b/gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py
index db9a6bf..db9a6bf 100644
--- a/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py
+++ b/gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py
diff --git a/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py b/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
index 2ad1056..2ad1056 100644
--- a/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
+++ b/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
diff --git a/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py b/gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py
index 37fcfe7..37fcfe7 100644
--- a/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py
+++ b/gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py
diff --git a/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py b/gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py
index c4397c9..c4397c9 100644
--- a/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py
+++ b/gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py
diff --git a/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py b/gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py
index 0f491c2..0f491c2 100644
--- a/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py
+++ b/gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py
diff --git a/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py b/gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py
index a26834a..a26834a 100644
--- a/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py
+++ b/gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py
diff --git a/migrations/auth/20230912_01_BxrhE-add-system-resource.py b/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py
index 66c6461..66c6461 100644
--- a/migrations/auth/20230912_01_BxrhE-add-system-resource.py
+++ b/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py
diff --git a/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py b/gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py
index 1b3f0b1..1b3f0b1 100644
--- a/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py
+++ b/gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py
diff --git a/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py b/gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py
index 1172034..1172034 100644
--- a/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py
+++ b/gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py
diff --git a/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py b/gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py
index 402e9a5..402e9a5 100644
--- a/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py
+++ b/gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py
diff --git a/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py b/gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py
index a4238ed..a4238ed 100644
--- a/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py
+++ b/gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py
diff --git a/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py b/gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py
index 049ac6b..049ac6b 100644
--- a/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py
+++ b/gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py
diff --git a/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py b/gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py
index 0cab1c3..0cab1c3 100644
--- a/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py
+++ b/gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py
diff --git a/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py b/gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py
index a45fd30..a45fd30 100644
--- a/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py
+++ b/gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py
diff --git a/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py b/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
index 0695c0e..0695c0e 100644
--- a/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
+++ b/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
diff --git a/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py b/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
index 45d689c..45d689c 100644
--- a/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
+++ b/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
diff --git a/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py b/gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py
index 44318bd..44318bd 100644
--- a/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py
+++ b/gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py
diff --git a/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py b/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
index 5c6e81d..5c6e81d 100644
--- a/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
+++ b/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
diff --git a/gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py b/gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py
new file mode 100644
index 0000000..d22ad01
--- /dev/null
+++ b/gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py
@@ -0,0 +1,42 @@
+"""
+add admin ui privilege to system-administrator role
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20240924_01_thbvh-hooks-for-edu-domains'}
+
+def get_system_admin_id(cursor):
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+def add_admin_ui_privilege(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ # Create admin-ui privilege
+ cursor.execute(
+ "INSERT INTO privileges (privilege_id, privilege_description) "
+ "VALUES(?, ?)",
+ ("system:user:admin-ui", "View UI elements that should only be visible to system administrators"))
+
+ # Add UI privilege to system-administrator role
+ cursor.execute(
+ "INSERT INTO role_privileges (role_id, privilege_id) "
+ "VALUES(?, ?)",
+ (get_system_admin_id(cursor), "system:user:admin-ui")
+ )
+
+def remove_admin_ui_privilege(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ # Remove UI privilege from system-administrator role
+ cursor.execute(
+ "DELETE FROM role_privileges WHERE privilege_id='system:user:admin-ui'")
+
+ # Remove UI privilege from privileges table
+ cursor.execute(
+ "DELETE FROM privileges WHERE privilege_id='system:user:admin-ui'")
+
+steps = [
+ step(add_admin_ui_privilege, remove_admin_ui_privilege)
+]
diff --git a/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py b/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
new file mode 100644
index 0000000..73a4880
--- /dev/null
+++ b/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
@@ -0,0 +1,49 @@
+"""
+Add Batch Edit privileges
+"""
+
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role'}
+
+def add_batch_edit_privilege_and_role(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ # Create batch edit privilege
+ cursor.execute(
+ "INSERT INTO privileges (privilege_id, privilege_description) "
+ "VALUES(?, ?)",
+ ("system:data:batch-edit", "Batch Edit"))
+
+ # Create batch editor role
+ cursor.execute(
+ "INSERT INTO roles (role_id, role_name, user_editable) "
+ "VALUES(?, ?, ?)",
+ ("0f391910-5225-476a-bb8d-9c0adc9d81cc", "Batch Editors", 0))
+
+ # Link role/privilege
+ cursor.execute(
+ "INSERT INTO role_privileges (role_id, privilege_id) "
+ "VALUES(?, ?)",
+ ("0f391910-5225-476a-bb8d-9c0adc9d81cc", "system:data:batch-edit")
+ )
+
+def remove_batch_edit_privilege_and_role(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ # Remove batch edit role/privilege link
+ cursor.execute(
+ "DELETE FROM role_privileges WHERE privilege_id='system:data:batch-edit'")
+
+ # Remove Batch Editor role
+ cursor.execute(
+ "DELETE FROM roles WHERE role_id='0f391910-5225-476a-bb8d-9c0adc9d81cc'")
+
+ # Remove Batch Edit privilege
+ cursor.execute(
+ "DELETE FROM privileges WHERE privilege_id='system:data:batch-edit'")
+
+
+steps = [
+ step(add_batch_edit_privilege_and_role, remove_batch_edit_privilege_and_role)
+]
diff --git a/gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py b/gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py
new file mode 100644
index 0000000..3b9e928
--- /dev/null
+++ b/gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py
@@ -0,0 +1,19 @@
+"""
+Add new 'group:data:link-to-group' privilege.
+"""
+
+from yoyo import step
+
+__depends__ = {'20240924_01_thbvh-hooks-for-edu-domains'}
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES(
+ 'group:data:link-to-group',
+ 'Allow linking data to only one specific group.'
+ )
+ """,
+ "DELETE FROM privileges WHERE privilege_id='group:data:link-to-group'")
+]
diff --git a/gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py b/gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py
new file mode 100644
index 0000000..5d9c306
--- /dev/null
+++ b/gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py
@@ -0,0 +1,23 @@
+"""
+Assign 'group:data:link-to-group' privilege to group leader.
+"""
+
+from yoyo import step
+
+__depends__ = {'20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege'}
+
+steps = [
+ step(
+ """
+ INSERT INTO role_privileges(role_id, privilege_id)
+ VALUES(
+ 'a0e67630-d502-4b9f-b23f-6805d0f30e30',
+ 'group:data:link-to-group'
+ )
+ """,
+ """
+ DELETE FROM role_privileges
+ WHERE role_id='a0e67630-d502-4b9f-b23f-6805d0f30e30'
+ AND privilege_id='group:data:link-to-group'
+ """)
+]
diff --git a/gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py b/gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py
new file mode 100644
index 0000000..6335152
--- /dev/null
+++ b/gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py
@@ -0,0 +1,27 @@
+"""
+Add role management privileges to group-leader role
+"""
+
+from yoyo import step
+
+__depends__ = {'20250609_01_LB60X-add-batch-edit-privileges', '20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader'}
+
+steps = [
+ step(
+ """
+ INSERT INTO role_privileges(role_id, privilege_id)
+ VALUES
+ ('a0e67630-d502-4b9f-b23f-6805d0f30e30', 'resource:role:create-role'),
+ ('a0e67630-d502-4b9f-b23f-6805d0f30e30', 'resource:role:delete-role'),
+ ('a0e67630-d502-4b9f-b23f-6805d0f30e30', 'resource:role:edit-role')
+ """,
+ """
+ DELETE FROM role_privileges
+ WHERE role_id='a0e67630-d502-4b9f-b23f-6805d0f30e30'
+ AND privilege_id IN (
+ 'resource:role:create-role',
+ 'resource:role:delete-role',
+ 'resource:role:edit-role'
+ )
+ """)
+]
diff --git a/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py b/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py
new file mode 100644
index 0000000..f00ab11
--- /dev/null
+++ b/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py
@@ -0,0 +1,18 @@
+"""
+Create new 'system:user:edit' privilege.
+"""
+
+from yoyo import step
+
+__depends__ = {'20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role'}
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES(
+ 'system:user:edit',
+ 'Allow general user-information edit.')
+ """,
+ "DELETE FROM privileges WHERE privilege_id='system:user:edit'")
+]
diff --git a/gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py b/gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py
new file mode 100644
index 0000000..b956bef
--- /dev/null
+++ b/gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py
@@ -0,0 +1,36 @@
+"""
+Add 'system:user:edit' privilege to 'system-admin' role.
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250722_01_7Gro7-create-new-system-user-edit-privilege'}
+
+
+def system_administrator_role_id(cursor):
+ """Fetch ID for role 'system-administrator'."""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+
+def add_system_user_edit_privilege(conn):
+ """Add the 'system:user:edit' to the 'system-administrator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES(?, ?)",
+ (system_administrator_role_id(cursor), 'system:user:edit'))
+
+
+def remove_system_user_edit_privilege(conn):
+ """Remove the 'system:user:edit' from the 'system-administrator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+ (system_administrator_role_id(cursor), 'system:user:edit'))
+
+steps = [
+ step(add_system_user_edit_privilege, remove_system_user_edit_privilege)
+]
diff --git a/gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py b/gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py
new file mode 100644
index 0000000..be0d022
--- /dev/null
+++ b/gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py
@@ -0,0 +1,31 @@
+"""
+Create initial system-wide resources access privileges
+"""
+
+from yoyo import step
+
+__depends__ = {'20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role'}
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES
+ ("system:resource:view",
+ "View the wrapper resource object (not attached data). This is mostly for administration purposes."),
+ ("system:resource:edit",
+ "Edit/update the wrapper resource object (not attached data). This is mostly for administration purposes."),
+ ("system:resource:delete",
+ "Delete the wrapper resource object (not attached data). This is mostly for administration purposes."),
+ ("system:resource:reassign-group",
+ "Reassign the resource, and its data, to a different user group."),
+ ("system:resource:assign-owner",
+ "Assign ownership of any resource to any user.")
+ """,
+ """
+ DELETE FROM privileges WHERE privilege_id IN
+ ("system:resource:view", "system:resource:edit",
+ "system:resource:delete", "system:resource:reassign-group",
+ "system:resource:assign-owner")
+ """)
+]
diff --git a/gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py b/gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py
new file mode 100644
index 0000000..e79ab1c
--- /dev/null
+++ b/gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py
@@ -0,0 +1,53 @@
+"""
+Assign initial system-wide resources-access privileges to sys-admins.
+"""
+import contextlib
+
+from yoyo import step
+
+def system_administrator_role_id(cursor):
+ """Fetch ID for role 'system-administrator'."""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+
+def assign_system_wide_resource_access_to_sysadmin(conn):
+ """
+ Assign initial system-wide resources-access privileges to
+ `system-administrator` role.
+ """
+ with contextlib.closing(conn.cursor()) as cursor:
+ sysadmin_role_id = system_administrator_role_id(cursor)
+ cursor.executemany(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES(?, ?)",
+ ((sysadmin_role_id, "system:resource:view"),
+ (sysadmin_role_id, "system:resource:edit"),
+ (sysadmin_role_id, "system:resource:delete"),
+ (sysadmin_role_id, "system:resource:reassign-group"),
+ (sysadmin_role_id, "system:resource:assign-owner")))
+
+
+def revoke_system_wide_resource_access_from_sysadmin(conn):
+ """
+ Revoke initial system-wide resources-access privileges from
+ `system-administrator` role.
+ """
+ with contextlib.closing(conn.cursor()) as cursor:
+ sysadmin_role_id = system_administrator_role_id(cursor)
+ cursor.executemany(
+ "DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id=?",
+ ((sysadmin_role_id, "system:resource:view"),
+ (sysadmin_role_id, "system:resource:edit"),
+ (sysadmin_role_id, "system:resource:delete"),
+ (sysadmin_role_id, "system:resource:reassign-group"),
+ (sysadmin_role_id, "system:resource:assign-owner")))
+
+__depends__ = {'20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges'}
+
+steps = [
+ step(assign_system_wide_resource_access_to_sysadmin,
+ revoke_system_wide_resource_access_from_sysadmin)
+]
diff --git a/gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py b/gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py
new file mode 100644
index 0000000..e3bdc8f
--- /dev/null
+++ b/gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py
@@ -0,0 +1,75 @@
+"""
+Grant role to ALL resources to sys-admin users.
+"""
+import itertools
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins'}
+
+
+def system_administrator_role_id(cursor):
+ """Fetch ID for role 'system-administrator'."""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+
+def system_resource_id(cursor):
+ cursor.execute(
+ "SELECT resources.resource_id FROM resource_categories "
+ "INNER JOIN resources ON resource_categories.resource_category_id=resources.resource_category_id "
+ "WHERE resource_category_key = 'system'")
+ return cursor.fetchone()[0]
+
+
+def fetch_ids_for_sysadmin_users(cursor):
+ """Fetch all sysadmin users' IDs."""
+ cursor.execute(
+ "SELECT user_roles.user_id FROM roles INNER JOIN user_roles "
+ "ON roles.role_id=user_roles.role_id "
+ "WHERE role_name='system-administrator' AND resource_id=?",
+ (system_resource_id(cursor),))
+ return tuple(row[0] for row in cursor.fetchall())
+
+
+def fetch_non_system_resources(cursor):
+ """Fetch IDs for all resources that are not of the 'system' category."""
+ cursor.execute(
+ "SELECT resources.resource_id FROM resource_categories "
+ "INNER JOIN resources "
+ "ON resource_categories.resource_category_id=resources.resource_category_id "
+ "WHERE resource_category_key != 'system'")
+ return tuple(row[0] for row in cursor.fetchall())
+
+
+def assign_sysadmin_role_on_non_system_resources(conn):
+ """Assign sysadmins the sysadmin role on all non-system resources."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ sysadminroleid = system_administrator_role_id(cursor)
+ cursor.executemany(
+ "INSERT INTO user_roles(user_id, resource_id, role_id) "
+ "VALUES (?, ?, ?)",
+ tuple(item + (sysadminroleid,)
+ for item in itertools.product(
+ fetch_ids_for_sysadmin_users(cursor),
+ fetch_non_system_resources(cursor))))
+
+
+def revoke_sysadmin_role_on_non_system_resources(conn):
+ """Revoke sysadmins the sysadmin role on all non-system resources."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ sysadminroleid = system_administrator_role_id(cursor)
+ cursor.executemany(
+ "DELETE FROM user_roles "
+ "WHERE user_id=? AND resource_id=? AND role_id=?",
+ tuple(item + (sysadminroleid,)
+ for item in itertools.product(
+ fetch_ids_for_sysadmin_users(cursor),
+ fetch_non_system_resources(cursor))))
+
+steps = [
+ step(assign_sysadmin_role_on_non_system_resources,
+ revoke_sysadmin_role_on_non_system_resources)
+]
diff --git a/gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py b/gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py
new file mode 100644
index 0000000..95a6fbb
--- /dev/null
+++ b/gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py
@@ -0,0 +1,70 @@
+"""
+Add sysadmin privileges for acting on groups: mostly handling user management.
+"""
+import itertools
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users'}
+
+
+def system_administrator_role_id(cursor):
+ """Fetch ID for role 'system-administrator'."""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+
+def add_group_privileges_to_sysadmin_role(conn):
+ """Add group-management privileges to sysadmin role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ sysadminroleid = system_administrator_role_id(cursor)
+ cursor.executemany(
+ "INSERT INTO role_privileges(role_id, privilege_id) VALUES (?, ?)",
+ tuple(itertools.product(
+ (sysadminroleid,),
+ ('system:group:add-group-member',
+ 'system:group:remove-group-member',
+ 'system:group:assign-group-leader',
+ 'system:group:revoke-group-leader'))))
+
+
+def remove_group_privileges_to_sysadmin_role(conn):
+ """Remove group-management privileges from sysadmin role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ sysadminroleid = system_administrator_role_id(cursor)
+ cursor.executemany(
+ "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+ tuple(itertools.product(
+ (sysadminroleid,),
+ ('system:group:add-group-member',
+ 'system:group:remove-group-member',
+ 'system:group:assign-group-leader',
+ 'system:group:revoke-group-leader'))))
+
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES
+ ('system:group:add-group-member',
+ 'Make an existing user a member of a group.'),
+ ('system:group:remove-group-member',
+ 'Remove a member user from a group.'),
+ ('system:group:assign-group-leader',
+ 'Assign an existing group member the group-leader role'),
+ ('system:group:revoke-group-leader',
+ 'Revoke the group-leader role from a group member with the role.')
+ """,
+ """
+ DELETE FROM privileges WHERE privilege_id IN
+ ('system:group:add-group-member',
+ 'system:group:remove-group-member',
+ 'system:group:assign-group-leader',
+ 'system:group:revoke-group-leader')
+ """),
+ step(add_group_privileges_to_sysadmin_role,
+ remove_group_privileges_to_sysadmin_role)
+]
diff --git a/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py b/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py
new file mode 100644
index 0000000..63e807a
--- /dev/null
+++ b/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py
@@ -0,0 +1,61 @@
+"""
+add role systemwide-data-curator.
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members'}
+
+
+def create_systemwide_data_curator_role(conn):
+ """Create a new 'systemwide-data-curator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO roles(role_id, role_name, user_editable) "
+ "VALUES (?, 'systemwide-data-curator', 0)",
+ (str(uuid.uuid4()),))
+
+
+def link_privileges_to_role(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT role_id FROM roles "
+ "WHERE role_name='systemwide-data-curator'")
+ role_id = cursor.fetchone()[0]
+ cursor.executemany("INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES (?, ?)",
+ tuple((role_id, priv) for priv in
+ ("system:system-wide:data:edit",
+ "system:system-wide:data:delete")))
+
+
+def unlink_privileges_from_role(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT role_id FROM roles "
+ "WHERE role_name='systemwide-data-curator'")
+ role_id = cursor.fetchone()[0]
+ cursor.executemany("DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id=?",
+ tuple((role_id, priv) for priv in
+ ("system:system-wide:data:edit",
+ "system:system-wide:data:delete")))
+
+
+steps = [
+ step(# Add new privileges
+ """
+ INSERT INTO privileges (privilege_id, privilege_description)
+ VALUES
+ ('system:system-wide:data:edit',
+ 'A user with this privilege can edit any data on the entire system.'),
+ ('system:system-wide:data:delete',
+ 'A user with this privilege can delete any data from the system.')
+ """,
+ """
+ DELETE FROM privileges WHERE privilege_id IN
+ ('system:system-wide:data:edit', 'system:system-wide:data:delete')"""),
+ step(create_systemwide_data_curator_role,
+ "DELETE FROM roles WHERE role_name='systemwide-data-curator'"),
+ step(link_privileges_to_role, unlink_privileges_from_role)
+]
diff --git a/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py b/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py
new file mode 100644
index 0000000..d618f14
--- /dev/null
+++ b/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py
@@ -0,0 +1,62 @@
+"""
+add privilege for gn-docs documentation editing
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260206_01_v3f4P-add-role-systemwide-data-curator'}
+
+ROLE_NAME = 'systemwide-docs-editor'
+
+
+def create_systemwide_docs_editor_role(conn):
+ """Create a new 'systemwide-data-curator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO roles(role_id, role_name, user_editable) "
+ "VALUES (?, ?, 0)",
+ (str(uuid.uuid4()), ROLE_NAME))
+
+
+def delete_systemwide_docs_editor_role(conn):
+ """Create a new 'systemwide-data-curator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("DELETE FROM roles WHERE role_name=?", (ROLE_NAME,))
+
+
+def assign_edit_priv_to_docs_editor(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT role_id FROM roles WHERE role_name=?",
+ (ROLE_NAME,))
+ role_id = cursor.fetchone()[0]
+
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES (?, ?)",
+ (role_id, "system:documentation:edit"))
+
+
+def revoke_edit_priv_to_docs_editor(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT role_id FROM roles WHERE role_name=?",
+ (ROLE_NAME,))
+ role_id = cursor.fetchone()[0]
+
+ cursor.execute(
+ "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+ (role_id, "system:documentation:edit"))
+
+
+steps = [
+ step(
+ """INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES(
+ 'system:documentation:edit',
+ 'Allows the holder to edit documentation presented with the Genenetwork system.'
+ )""",
+ "DELETE FROM privileges WHERE privilege_id='system:documentation:edit'"),
+ step(create_systemwide_docs_editor_role, delete_systemwide_docs_editor_role),
+ step(assign_edit_priv_to_docs_editor, revoke_edit_priv_to_docs_editor)
+]
diff --git a/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py b/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py
new file mode 100644
index 0000000..e79ef6a
--- /dev/null
+++ b/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py
@@ -0,0 +1,66 @@
+"""
+Assign 'systemwide-docs-editor' role to sysadmins
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing'}
+
+
+def fetch_docs_editor_role_id(cursor):
+ """Fetch ID of systemwide-docs-editor role"""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='systemwide-docs-editor'")
+ return cursor.fetchone()[0]
+
+
+def fetch_sys_resource_id(cursor):
+ """Fetch the resource ID of the system."""
+ cursor.execute("SELECT resource_id FROM resources "
+ "WHERE resource_name='GeneNetwork System'")
+ return cursor.fetchone()[0]
+
+
+def fetch_sys_admin_ids(cursor):
+ """Fetch the sysadmins' IDs."""
+ cursor.execute(
+ "SELECT user_roles.user_id FROM resources INNER JOIN user_roles "
+ "ON resources.resource_id=user_roles.resource_id INNER JOIN roles "
+ "ON user_roles.role_id=roles.role_id "
+ "WHERE resources.resource_name='GeneNetwork System' "
+ "AND roles.role_name='system-administrator'")
+ return tuple(row[0] for row in cursor.fetchall())
+
+
+def __build_params__(cursor):
+ sysresourceid = fetch_sys_resource_id(cursor)
+ sysadminids = fetch_sys_admin_ids(cursor)
+ roleid = fetch_docs_editor_role_id(cursor)
+ return tuple({
+ "user_id": userid,
+ "role_id": roleid,
+ "resource_id": sysresourceid
+ } for userid in sysadminids)
+
+
+def assign_systemwide_docs_editor_role_to_sysadmins(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.executemany(
+ "INSERT INTO user_roles(user_id, role_id, resource_id) "
+ "VALUES(:user_id, :role_id, :resource_id)",
+ __build_params__(cursor))
+
+
+def revoke_systemwide_docs_editor_role_from_sysadmins(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.executemany(
+ "DELETE FROM user_roles WHERE user_id=:user_id "
+ "AND role_id=:role_id AND resource_id=:resource_id",
+ __build_params__(cursor))
+
+steps = [
+ step(assign_systemwide_docs_editor_role_to_sysadmins,
+ revoke_systemwide_docs_editor_role_from_sysadmins)
+]
diff --git a/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py b/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py
new file mode 100644
index 0000000..bdf8a56
--- /dev/null
+++ b/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py
@@ -0,0 +1,49 @@
+"""
+Restrict access to resources' 'Make Public' feature.
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins'}
+
+
+def fetch_systemwide_data_curator_role_id(cursor):
+ "Fetch the role's ID."
+ cursor.execute("SELECT role_id FROM roles "
+ "WHERE role_name='systemwide-data-curator'")
+ return cursor.fetchone()[0]
+
+
+def assign_make_public_to_systemwide_data_curator(conn):
+ """Assign privilege to 'systemwide-data-curator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES(?, 'system:resource:make-public')",
+ (fetch_systemwide_data_curator_role_id(cursor),))
+
+
+def revoke_make_public_from_systemwide_data_curator(conn):
+ """Revoke privilege from 'systemwide-data-curator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id='system:resource:make-public'",
+ (fetch_systemwide_data_curator_role_id(cursor),))
+
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES(
+ 'system:resource:make-public',
+ 'Allow user to make a resource publicly accessible.')
+ """,
+ """
+ DELETE FROM privileges WHERE privilege_id='system:resource:make-public'
+ """),
+ step(assign_make_public_to_systemwide_data_curator,
+ revoke_make_public_from_systemwide_data_curator),
+]
diff --git a/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py b/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py
new file mode 100644
index 0000000..22863ae
--- /dev/null
+++ b/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py
@@ -0,0 +1,69 @@
+"""
+Add privileges to role systemwide-data-curator
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_03_vxBCX-restrict-access-to-resources-make-public-feature'}
+
+
+__new_privileges__ = (
+ ("system:system-wide:inbredset:view-case-attribute",
+ "Enable view of any and all inbredset case attributes system-wide."),
+ ("system:system-wide:inbredset:edit-case-attribute",
+ "Enable edit of any and all inbredset case attributes system-wide."),
+ ("system:system-wide:inbredset:delete-case-attribute",
+ "Enable deletion of any and all inbredset case attributes system-wide."),
+ ("system:system-wide:inbredset:apply-case-attribute-edit",
+ "Enable applying changes to any and all inbredset case attributes system-wide."),
+ ("system:system-wide:inbredset:reject-case-attribute-edit",
+ "Enable rejecting changes to any and all inbredset case attributes system-wide."))
+
+
+def fetch_systemwide_data_curator_role_id(cursor):
+ "Fetch the role's ID."
+ cursor.execute("SELECT role_id FROM roles "
+ "WHERE role_name='systemwide-data-curator'")
+ return cursor.fetchone()[0]
+
+
+def create_new_privileges(conn):
+ """Create new privileges for the system."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.executemany(
+ "INSERT INTO privileges(privilege_id, privilege_description) "
+ "VALUES (?, ?)",
+ __new_privileges__)
+
+
+def delete_new_privileges(conn):
+ """Delete these new privileges from the system."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.executemany("DELETE FROM privileges WHERE privilege_id=?",
+ tuple((priv[0],) for priv in __new_privileges__))
+
+
+def assign_new_privileges(conn):
+ """Assign the new privileges to the `systemwide-data-curator` role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ role_id = fetch_systemwide_data_curator_role_id(cursor)
+ cursor.executemany(
+ "INSERT INTO role_privileges(role_id, privilege_id) VALUES (?, ?)",
+ tuple((role_id, privilege[0]) for privilege in __new_privileges__))
+
+
+def revoke_new_privileges(conn):
+ """Revoke the new privileges from the `systemwide-data-curator` role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ role_id = fetch_systemwide_data_curator_role_id(cursor)
+ cursor.executemany(
+ "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+ tuple((role_id, privilege[0]) for privilege in __new_privileges__))
+
+
+
+steps = [
+ step(create_new_privileges, delete_new_privileges),
+ step(assign_new_privileges, revoke_new_privileges)
+]
diff --git a/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py b/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py
new file mode 100644
index 0000000..702c418
--- /dev/null
+++ b/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py
@@ -0,0 +1,185 @@
+"""
+Add user and time tracking to resources table
+"""
+import random
+import contextlib
+from datetime import datetime
+
+from yoyo import step
+
+__depends__ = {'20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator'}
+
+GN_AUTH_INIT_TIMESTAMP = 1691130509.0
+__admin_id__ = ""
+
+
+def fetch_acentenos_id(conn):
+ """Fetch the default resource creator."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT user_id FROM users WHERE email=?",
+ (("acent" "eno@" "uthsc" "." "edu"),))
+ res = cursor.fetchone()
+ return res[0] if bool(res) else None
+
+
+def fetch_a_sysadmin_id(conn, resources_table):
+ """Fetch one ID out of all system administrator users."""
+ global __admin_id__
+
+ def __fetch__():
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ f"SELECT ur.user_id FROM {resources_table} AS rsc "
+ "INNER JOIN user_roles AS ur ON rsc.resource_id=ur.resource_id "
+ "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+ "WHERE resource_name='GeneNetwork System' "
+ "AND r.role_name='system-administrator'"
+ )
+ return tuple(row[0] for row in cursor.fetchall())
+
+ if not bool(__admin_id__):
+ __admins__ = __fetch__()
+ if len(__admins__) > 0:
+ __admin_id__ = random.choice(__admins__)
+
+ return __admin_id__
+
+
+def add_user_and_time_tracking_columns(conn):
+ """Add user and time tracking columns."""
+ conn.execute(
+ """
+ CREATE TABLE resources_new(
+ resource_id TEXT NOT NULL,
+ resource_name TEXT NOT NULL UNIQUE,
+ resource_category_id TEXT NOT NULL,
+ public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1),
+ created_by TEXT NOT NULL,
+ created_at REAL NOT NULL DEFAULT '1691130509.0',
+ PRIMARY KEY(resource_id),
+ FOREIGN KEY(resource_category_id)
+ REFERENCES resource_categories(resource_category_id)
+ ON UPDATE CASCADE ON DELETE RESTRICT,
+ FOREIGN KEY(created_by)
+ REFERENCES users(user_id) ON UPDATE CASCADE ON DELETE RESTRICT
+ ) WITHOUT ROWID
+ """)
+
+
+def drop_user_and_time_tracking_columns(conn):
+ """Drop user and time tracking columns."""
+ conn.execute("PRAGMA foreign_keys = OFF")
+ conn.execute("DROP TABLE IF EXISTS resources")
+ conn.execute("ALTER TABLE resources_old RENAME TO resources")
+ conn.execute("PRAGMA foreign_key_check")
+ conn.execute("PRAGMA foreign_keys = ON")
+
+
+def update_data_for_new_resources_table(conn):
+ """Add creator and time to original data."""
+ __creator__ = (
+ fetch_acentenos_id(conn) or fetch_a_sysadmin_id(conn, "resources"))
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT * FROM resources")
+ cursor.executemany(
+ "INSERT INTO resources_new("
+ " resource_id,"
+ " resource_name,"
+ " resource_category_id,"
+ " public,"
+ " created_by,"
+ " created_at"
+ ") VALUES (?, ?, ?, ?, ?, ?)",
+ tuple(
+ tuple(row) + (__creator__, GN_AUTH_INIT_TIMESTAMP)
+ for row in cursor.fetchall()))
+
+
+def restore_data_for_old_resources_table(conn):
+ """Remove creator and time from data."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT * FROM resources")
+ cursor.executemany(
+ "INSERT INTO resources_old("
+ " resource_id,"
+ " resource_name,"
+ " resource_category_id,"
+ " public"
+ ") VALUES (?, ?, ?, ?)",
+ tuple(tuple(row)[0:4] for row in cursor.fetchall()))
+
+
+def replace_old_table_with_new_table(conn):
+ """Restore old resources table with the new resources table."""
+ conn.execute("PRAGMA foreign_keys = OFF")
+ conn.execute("DROP TABLE resources")
+ conn.execute("ALTER TABLE resources_new RENAME TO resources")
+ conn.execute("PRAGMA foreign_key_check")
+ conn.execute("PRAGMA foreign_keys = ON")
+
+
+def restore_old_table(conn):
+ """Restore old 'resources' table schema."""
+ conn.execute(
+ """
+ CREATE TABLE resources_old(
+ resource_id TEXT NOT NULL,
+ resource_name TEXT NOT NULL UNIQUE,
+ resource_category_id TEXT NOT NULL,
+ public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1),
+ PRIMARY KEY(resource_id),
+ FOREIGN KEY(resource_category_id)
+ REFERENCES resource_categories(resource_category_id)
+ ON UPDATE CASCADE ON DELETE RESTRICT
+ ) WITHOUT ROWID
+ """)
+
+
+def parse_creator_and_time(cursor, row):
+ __return__ = None
+
+ __name_parts__ = row[1].split("—")
+ if len(__name_parts__) == 4:
+ __email__, __inbredsetname__, __datetimestr__, count = __name_parts__
+ cursor.execute("SELECT user_id FROM users WHERE email=?",
+ (__email__.strip(),))
+ results = cursor.fetchone()
+ if bool(results):
+ __return__ = {
+ "resource_id": row[0],
+ "creator": results[0],
+ "created": datetime.fromisoformat(__datetimestr__).timestamp()
+ }
+
+ return __return__
+
+
+def update_creators_and_time(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT resource_id, resource_name FROM resources")
+ cursor.executemany(
+ "UPDATE resources SET created_by=:creator, created_at=:created "
+ "WHERE resource_id=:resource_id",
+ tuple(item for item in
+ (parse_creator_and_time(cursor, row)
+ for row in cursor.fetchall())
+ if item is not None))
+
+
+
+def restore_default_creators_and_time(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ __creator__ = (
+ fetch_acentenos_id(conn) or fetch_a_sysadmin_id(conn, "resources"))
+ cursor.execute("UPDATE resources SET created_by=?, created_at=?",
+ (__creator__, GN_AUTH_INIT_TIMESTAMP))
+
+
+steps = [
+ step(add_user_and_time_tracking_columns,
+ drop_user_and_time_tracking_columns),
+ step(update_data_for_new_resources_table,
+ restore_data_for_old_resources_table),
+ step(replace_old_table_with_new_table, restore_old_table),
+ step(update_creators_and_time, restore_default_creators_and_time)
+]
diff --git a/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py b/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py
new file mode 100644
index 0000000..2dddc56
--- /dev/null
+++ b/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py
@@ -0,0 +1,19 @@
+"""
+New privilege: system:system-wide:data:view
+"""
+
+from yoyo import step
+
+__depends__ = {'20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table'}
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES('system:system-wide:data:view',
+ 'A user with this privilege can view any data on the entire system.')
+ """,
+ """
+ DELETE FROM privileges WHERE privilege_id='system:system-wide:data:view'
+ """)
+]
diff --git a/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py b/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py
new file mode 100644
index 0000000..537bf9b
--- /dev/null
+++ b/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py
@@ -0,0 +1,62 @@
+"""
+Add privileges to batch-editors role
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260428_01_Tak6O-new-privilege-system-system-wide-data-view'}
+
+
+def fetch_batch_editors_role_id(cursor):
+ """Fetch the ID of the batch-editors role."""
+ cursor.execute("SELECT role_id FROM roles WHERE role_name='Batch Editors'")
+ res = cursor.fetchone()
+ if not bool(res):
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='batch-editors'")
+ res = cursor.fetchone()
+
+ return res[0] if bool(res) else None
+
+
+def rename_role(conn):
+ """Rename role from 'Batch Editors' to 'batch-editors'."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "UPDATE roles SET role_name='batch-editors' WHERE role_id=?",
+ (fetch_batch_editors_role_id(cursor),))
+
+
+def restore_old_role_name(conn):
+ """Rename role from 'batch-editors' to 'Batch Editors'."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "UPDATE roles SET role_name='Batch Editors' WHERE role_id=?",
+ (fetch_batch_editors_role_id(cursor),))
+
+
+def add_new_privileges(conn):
+ """Add new privileges to 'batch-editors' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ role_id = fetch_batch_editors_role_id(cursor)
+ cursor.executemany(
+ "INSERT INTO role_privileges(role_id, privilege_id) VALUES(?, ?)",
+ tuple((role_id, priv) for priv in (
+ "system:system-wide:data:view",
+ "system:system-wide:data:edit")))
+
+
+def remove_new_privileges(conn):
+ """Remove new privileges from 'batch-editors' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges WHERE role_id=? AND privilege_id IN "
+ "('system:system-wide:data:view', 'system:system-wide:data:edit')",
+ (fetch_batch_editors_role_id(cursor),))
+
+
+steps = [
+ step(rename_role, restore_old_role_name),
+ step(add_new_privileges, remove_new_privileges)
+]
diff --git a/gn_auth/migrations/auth/20260825_01_4uVPR-add-privilege-system-user-list-to-the-resource-owner-role.py b/gn_auth/migrations/auth/20260825_01_4uVPR-add-privilege-system-user-list-to-the-resource-owner-role.py
new file mode 100644
index 0000000..89d2d0d
--- /dev/null
+++ b/gn_auth/migrations/auth/20260825_01_4uVPR-add-privilege-system-user-list-to-the-resource-owner-role.py
@@ -0,0 +1,40 @@
+"""
+Add privilege `system:user:list` to the `resource-owner` role.
+
+The resource-owner needs to be able to list the users in the system in order to
+actually select which user they want to assign a particular role to.
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260428_02_L6zIV-add-privileges-to-batch-editors-role'}
+
+
+def fetch_resource_owner_role_id(cursor):
+ cursor.execute("SELECT role_id FROM roles WHERE role_name='resource-owner'")
+ return cursor.fetchone()[0]
+
+
+def assign_system_user_list_to_resource_owner(conn):
+ """Assign the 'system:user:list' privilege to the 'resource-owner' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES (?, ?) "
+ "ON CONFLICT (role_id, privilege_id) DO NOTHING",
+ (fetch_resource_owner_role_id(cursor), "system:user:list"))
+
+
+def revoke_system_user_list_from_resource_owner(conn):
+ """Revoke the 'system:user:list' privilege from the 'resource-owner' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id=?",
+ (fetch_resource_owner_role_id(cursor), "system:user:list"))
+
+steps = [
+ step(assign_system_user_list_to_resource_owner,
+ revoke_system_user_list_from_resource_owner)
+]
diff --git a/gn_auth/migrations/auth/20260826_01_6Hzl8-add-system-user-create-user-privilege-to-system-administrator-role.py b/gn_auth/migrations/auth/20260826_01_6Hzl8-add-system-user-create-user-privilege-to-system-administrator-role.py
new file mode 100644
index 0000000..50e448f
--- /dev/null
+++ b/gn_auth/migrations/auth/20260826_01_6Hzl8-add-system-user-create-user-privilege-to-system-administrator-role.py
@@ -0,0 +1,44 @@
+"""
+Add 'system:user:create-user' privilege to 'system-administrator' role.
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260825_01_4uVPR-add-privilege-system-user-list-to-the-resource-owner-role'}
+
+
+def fetch_system_admin_role_id(cursor):
+ cursor.execute("SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+
+def assign_system_user_create_user_to_sys_admin(conn):
+ """Assign the 'system:user:create-user' privilege to the 'system-administrator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES (?, ?) "
+ "ON CONFLICT (role_id, privilege_id) DO NOTHING",
+ (fetch_system_admin_role_id(cursor), "system:user:create-user"))
+
+
+def revoke_system_user_create_user_from_sys_admin(conn):
+ """Revoke the 'system:user:create-user' privilege from the 'system-administrator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id=?",
+ (fetch_system_admin_role_id(cursor), "system:user:create-user"))
+
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES('system:user:create-user', 'Create a user for the system')
+ """,
+ "DELETE FROM privileges WHERE privilege_id='system:user:create-user'"),
+ step(assign_system_user_create_user_to_sys_admin,
+ revoke_system_user_create_user_from_sys_admin)
+]
diff --git a/gn_auth/migrations/auth/20260831_01_VZp52-assign-resource-user-assign-role-to-the-system-administrator-role.py b/gn_auth/migrations/auth/20260831_01_VZp52-assign-resource-user-assign-role-to-the-system-administrator-role.py
new file mode 100644
index 0000000..4dd3f68
--- /dev/null
+++ b/gn_auth/migrations/auth/20260831_01_VZp52-assign-resource-user-assign-role-to-the-system-administrator-role.py
@@ -0,0 +1,39 @@
+"""
+Assign 'resource:user:assign-role' to the 'system-administrator' role
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260826_01_6Hzl8-add-system-user-create-user-privilege-to-system-administrator-role'}
+
+
+def fetch_system_admin_role_id(cursor):
+ cursor.execute("SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+
+def assign_assign_role_to_sys_admin(conn):
+ """Assign 'resource:user:assign-role' privilege to the
+ 'system-administrator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES (?, ?) "
+ "ON CONFLICT (role_id, privilege_id) DO NOTHING",
+ (fetch_system_admin_role_id(cursor), "resource:user:assign-role"))
+
+
+def revoke_assign_role_from_sys_admin(conn):
+ """Revoke 'resource:user:assign-role' privilege from the
+ 'system-administrator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id=?",
+ (fetch_system_admin_role_id(cursor), "resource:user:assign-role"))
+
+
+steps = [
+ step(assign_assign_role_to_sys_admin, revoke_assign_role_from_sys_admin)
+]
diff --git a/gn_auth/migrations/auth/20260901_01_asMmm-revoke-system-administration-role-on-non-system-resources.py b/gn_auth/migrations/auth/20260901_01_asMmm-revoke-system-administration-role-on-non-system-resources.py
new file mode 100644
index 0000000..cb4b9bc
--- /dev/null
+++ b/gn_auth/migrations/auth/20260901_01_asMmm-revoke-system-administration-role-on-non-system-resources.py
@@ -0,0 +1,79 @@
+"""
+Revoke system-administration role on non-system resources.
+
+Migration `20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py`
+assigns the "system-administrator" role on all resources to the system-admin
+users. This is not the correct way of things.
+
+The correct way is that the "system-administrator" role can only ever be applied
+against the system itself, and the sys-admin can masquerade as whatever user
+they need to in order to accomplish their goal.
+"""
+import itertools
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260831_01_VZp52-assign-resource-user-assign-role-to-the-system-administrator-role'}
+
+
+def system_administrator_role_id(cursor):
+ """Fetch ID for role 'system-administrator'."""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+
+def system_resource_id(cursor):
+ cursor.execute(
+ "SELECT resources.resource_id FROM resource_categories "
+ "INNER JOIN resources ON resource_categories.resource_category_id=resources.resource_category_id "
+ "WHERE resource_category_key = 'system'")
+ return cursor.fetchone()[0]
+
+
+def fetch_ids_for_sysadmin_users(cursor):
+ """Fetch all sysadmin users' IDs."""
+ cursor.execute(
+ "SELECT user_roles.user_id FROM roles INNER JOIN user_roles "
+ "ON roles.role_id=user_roles.role_id "
+ "WHERE role_name='system-administrator' AND resource_id=?",
+ (system_resource_id(cursor),))
+ return tuple(row[0] for row in cursor.fetchall())
+
+
+def fetch_non_system_resources(cursor):
+ """Fetch IDs for all resources that are not of the 'system' category."""
+ cursor.execute(
+ "SELECT resources.resource_id FROM resource_categories "
+ "INNER JOIN resources "
+ "ON resource_categories.resource_category_id=resources.resource_category_id "
+ "WHERE resource_category_key != 'system'")
+ return tuple(row[0] for row in cursor.fetchall())
+
+
+def revoke_sys_admin_role_on_non_system_resources(conn):
+ """Revoke the 'system-administration' role from non-system resources."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM user_roles WHERE role_id=? AND resource_id != ?",
+ (system_administrator_role_id(cursor), system_resource_id(cursor)))
+
+
+def restore_sys_admin_role_on_non_system_resources(conn):
+ """..."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ sysadminroleid = system_administrator_role_id(cursor)
+ cursor.executemany(
+ "INSERT INTO user_roles(user_id, resource_id, role_id) "
+ "VALUES (?, ?, ?)",
+ tuple(item + (sysadminroleid,)
+ for item in itertools.product(
+ fetch_ids_for_sysadmin_users(cursor),
+ fetch_non_system_resources(cursor))))
+
+
+steps = [
+ step(revoke_sys_admin_role_on_non_system_resources,
+ restore_sys_admin_role_on_non_system_resources)
+]
diff --git a/migrations/auth/__init__.py b/gn_auth/migrations/auth/__init__.py
index 1358c9a..1358c9a 100644
--- a/migrations/auth/__init__.py
+++ b/gn_auth/migrations/auth/__init__.py
diff --git a/gn_auth/scripts/__init__.py b/gn_auth/scripts/__init__.py
new file mode 100644
index 0000000..5be56d8
--- /dev/null
+++ b/gn_auth/scripts/__init__.py
@@ -0,0 +1 @@
+"""These are command-line scripts to be run manually or in the background."""
diff --git a/scripts/assign_data_to_default_admin.py b/gn_auth/scripts/assign_data_to_default_admin.py
index d10b687..69fc50c 100644
--- a/scripts/assign_data_to_default_admin.py
+++ b/gn_auth/scripts/assign_data_to_default_admin.py
@@ -11,10 +11,9 @@ from pathlib import Path
from uuid import UUID, uuid4
import click
+from gn_libs import mysqldb as biodb
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import mariadb as biodb
-
import gn_auth.auth.db.sqlite3 as authdb
from gn_auth.auth.authentication.users import User
from gn_auth.auth.authorisation.roles.models import (
diff --git a/scripts/batch_assign_data_to_default_admin.py b/gn_auth/scripts/batch_assign_data_to_default_admin.py
index 3df123d..95d9794 100644
--- a/scripts/batch_assign_data_to_default_admin.py
+++ b/gn_auth/scripts/batch_assign_data_to_default_admin.py
@@ -7,16 +7,15 @@ import logging
from pathlib import Path
import click
+from gn_libs import mysqldb as biodb
from pymonad.maybe import Just, Maybe, Nothing
from pymonad.tools import monad_from_none_or_value
-from gn_auth.auth.db import mariadb as biodb
from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.auth.authentication.users import User
from gn_auth.auth.authorisation.resources.groups.models import (
Group, db_row_to_group)
-
-from scripts.assign_data_to_default_admin import (
+from gn_auth.scripts.assign_data_to_default_admin import (
default_resources, assign_data_to_resource)
diff --git a/scripts/link_inbredsets.py b/gn_auth/scripts/link_inbredsets.py
index 5db7ea8..ad743f5 100644
--- a/scripts/link_inbredsets.py
+++ b/gn_auth/scripts/link_inbredsets.py
@@ -6,12 +6,11 @@ import uuid
from pathlib import Path
import click
+from gn_libs import mysqldb as biodb
import gn_auth.auth.db.sqlite3 as authdb
-from gn_auth.auth.db import mariadb as biodb
-
-from scripts.assign_data_to_default_admin import (
+from gn_auth.scripts.assign_data_to_default_admin import (
sys_admins, admin_group, select_sys_admin)
def linked_inbredsets(conn):
diff --git a/scripts/register_sys_admin.py b/gn_auth/scripts/register_sys_admin.py
index dfd4d59..06aa845 100644
--- a/scripts/register_sys_admin.py
+++ b/gn_auth/scripts/register_sys_admin.py
@@ -16,7 +16,7 @@ def fetch_email() -> str:
try:
user_input = input("Enter the administrator's email: ")
email = validate_email(user_input.strip(), check_deliverability=True)
- return email["email"]
+ return email["email"] # type: ignore
except EmailNotValidError as _enve:
print("You did not provide a valid email address. Try again...",
file=sys.stderr)
diff --git a/scripts/search_phenotypes.py b/gn_auth/scripts/search_phenotypes.py
index 20d91c9..eee112d 100644
--- a/scripts/search_phenotypes.py
+++ b/gn_auth/scripts/search_phenotypes.py
@@ -11,9 +11,9 @@ from datetime import datetime, timedelta
import click
import redis
import requests
+from gn_libs import mysqldb as gn3db
from gn_auth import jobs
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.settings import SQL_URI, AUTH_DB
from gn_auth.auth.authorisation.data.phenotypes import linked_phenotype_data
@@ -26,7 +26,7 @@ def do_search(
"""Do the search and return the results"""
search_uri = urljoin(host, (f"search/?page={page}&per_page={per_page}"
f"&type=phenotype&query={query}"))
- response = requests.get(search_uri)
+ response = requests.get(search_uri, timeout=300)
results = response.json()
if len(results) > 0:
return (item for item in results)
@@ -52,7 +52,7 @@ def update_search_results(redisconn: redis.Redis, redisname: str,
results: tuple[dict[str, Any], ...]):
"""Save the results to redis db."""
key = "search_results"
- prev_results = tuple(json.loads(redisconn.hget(redisname, key) or "[]"))
+ prev_results = tuple(json.loads(redisconn.hget(redisname, key) or "[]")) # type: ignore
redisconn.hset(redisname, key, json.dumps(prev_results + results))
def expire_redis_results(redisconn: redis.Redis, redisname: str):
@@ -75,7 +75,7 @@ def expire_redis_results(redisconn: redis.Redis, redisname: str):
@click.option(
"--redis-uri", default="redis://:@localhost:6379/0",
help="The URI to the redis server.")
-def search(# pylint: disable=[too-many-arguments, too-many-locals]
+def search(# pylint: disable=[too-many-arguments, too-many-positional-arguments, too-many-locals]
species: str, query: str, job_id: uuid.UUID, host: str, per_page: int,
selected: str, auth_db_uri: str, gn3_db_uri: str, redis_uri: str):
"""
diff --git a/scripts/worker.py b/gn_auth/scripts/worker.py
index 0a77d41..0a77d41 100644
--- a/scripts/worker.py
+++ b/gn_auth/scripts/worker.py
diff --git a/gn_auth/settings.py b/gn_auth/settings.py
index d561fa9..f903553 100644
--- a/gn_auth/settings.py
+++ b/gn_auth/settings.py
@@ -14,7 +14,7 @@ SESSION_EXPIRY_MINUTES = 10
# Database settings
SQL_URI = "mysql://webqtlout:webqtlout@localhost/db_webqtl"
AUTH_DB = f"{os.environ.get('HOME')}/genenetwork/gn3_files/db/auth.db"
-AUTH_MIGRATIONS = "migrations/auth"
+AUTH_MIGRATIONS = "gn_auth/migrations/auth"
# Redis settings
REDIS_URI = "redis://localhost:6379/0"
@@ -45,3 +45,9 @@ SMTP_TIMEOUT = 200 # seconds
SMTP_USER = "no-reply@genenetwork.org"
SMTP_PASSWORD = "asecrettoken"
EMAIL_ADDRESS = "no-reply@uthsc.edu"
+
+
+## Variable settings for various emails going out to users
+AUTH_EMAILS_EXPIRY_MINUTES = 15
+
+LOGGABLE_MODULES = ["gn_auth"]
diff --git a/gn_auth/smtp.py b/gn_auth/smtp.py
index 2f0e7f4..0040f35 100644
--- a/gn_auth/smtp.py
+++ b/gn_auth/smtp.py
@@ -16,7 +16,7 @@ def __read_mime__(filepath) -> dict:
return {}
-def build_email_message(# pylint: disable=[too-many-arguments]
+def build_email_message(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
from_address: str,
to_addresses: tuple[Address, ...],
subject: str,
@@ -40,7 +40,7 @@ def build_email_message(# pylint: disable=[too-many-arguments]
return msg
-def send_message(# pylint: disable=[too-many-arguments]
+def send_message(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
smtp_user: str,
smtp_passwd: str,
message: EmailMessage,
diff --git a/gn_auth/static/css/autocomplete.css b/gn_auth/static/css/autocomplete.css
new file mode 100644
index 0000000..1501e28
--- /dev/null
+++ b/gn_auth/static/css/autocomplete.css
@@ -0,0 +1,85 @@
+.autocomplete {
+ /*the container must be positioned relative:*/
+ position: relative;
+ display: inline-block;
+
+
+}
+
+input.autocomplete {
+ border: 1px solid transparent;
+ background-color: #f1f1f1;
+ padding: 10px;
+ font-size: 16px;
+}
+
+input[type=text].autocomplete {
+ background-color: #f1f1f1;
+ width: 100%;
+}
+
+input[type=submit].autocomplete {
+ background-color: DodgerBlue;
+ color: #fff;
+}
+
+.autocomplete-items {
+ position: absolute;
+ border: 1px solid #d4d4d4;
+ border-bottom: none;
+ border-top: none;
+ z-index: 99;
+ /*position the autocomplete items to be the same width as the container:*/
+ top: 100%;
+ left: 0;
+ right: 0;
+ border:1px solid black;
+ border-top:none;
+ box-shadow: rgba(0, 0, 0, 0.25) 0px 54px 55px, rgba(0, 0, 0, 0.12) 0px -12px 30px, rgba(0, 0, 0, 0.12) 0px 4px 6px, rgba(0, 0, 0, 0.17) 0px 12px 13px, rgba(0, 0, 0, 0.09) 0px -3px 5px;
+
+}
+
+.autocomplete-items div {
+ padding: 10px;
+ cursor: pointer;
+ background-color: #fff;
+ border-bottom: 1px dotted #d4d4d4;
+}
+
+.autocomplete-items div:hover {
+ /*when hovering an item:*/
+ background-color: #e9e9e9;
+}
+
+.autocomplete-active {
+ /*when navigating through the items using the arrow keys:*/
+ background-color: DodgerBlue !important;
+ color: #ffffff;
+}
+
+.recent-search-title {
+ display: -webkit-box;
+ display: -moz-box;
+ display: -ms-flexbox;
+ display: -webkit-flex;
+ display: flex;
+}
+
+.recent-search-title * {
+ -webkit-box-flex: 1 1 auto;
+ -moz-box-flex: 1 1 auto;
+ -webkit-flex: 1 1 auto;
+ -ms-flex: 1 1 auto;
+ flex: 1 1 auto;
+}
+
+
+.recent-search-title input[type="button"] {
+ border: none;
+ background: none;
+ cursor: pointer;
+ margin: 0;
+ padding: 0;
+ color: blue;
+
+} \ No newline at end of file
diff --git a/gn_auth/static/css/bootstrap-custom.css b/gn_auth/static/css/bootstrap-custom.css
new file mode 100644
index 0000000..27db0ef
--- /dev/null
+++ b/gn_auth/static/css/bootstrap-custom.css
@@ -0,0 +1,7570 @@
+/*!
+ * Bootstrap v3.3.0 (http://getbootstrap.com)
+ * Copyright 2011-2014 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ */
+
+/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
+html {
+ font-family: sans-serif;
+ -webkit-text-size-adjust: 100%;
+ -ms-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+}
+
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+main,
+menu,
+nav,
+section,
+summary {
+ display: block;
+}
+
+audio,
+canvas,
+progress,
+video {
+ display: inline-block;
+ vertical-align: baseline;
+}
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+[hidden],
+template {
+ display: none;
+}
+
+a {
+ background-color: transparent;
+}
+
+a:active,
+a:hover {
+ outline: 0;
+}
+
+abbr[title] {
+ border-bottom: 1px dotted;
+}
+
+b,
+strong {
+ font-weight: bold;
+}
+
+dfn {
+ font-style: italic;
+}
+
+h1 {
+ margin: .67em 0;
+ font-size: 2em;
+}
+
+mark {
+ color: #000;
+ background: #ff0;
+}
+
+small {
+ font-size: 80%;
+}
+
+sub,
+sup {
+ position: relative;
+ font-size: 75%;
+ line-height: 0;
+ vertical-align: baseline;
+}
+
+sup {
+ top: -.5em;
+}
+
+sub {
+ bottom: -.25em;
+}
+
+img {
+ border: 0;
+}
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+figure {
+ margin: 1em 40px;
+}
+
+hr {
+ height: 0;
+ -webkit-box-sizing: content-box;
+ -moz-box-sizing: content-box;
+ box-sizing: content-box;
+}
+
+pre {
+ overflow: auto;
+}
+
+code,
+kbd,
+pre,
+samp {
+ font-family: monospace, monospace;
+ font-size: 1em;
+}
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ margin: 0;
+ font: inherit;
+ color: inherit;
+}
+
+button {
+ overflow: visible;
+}
+
+button,
+select {
+ text-transform: none;
+}
+
+button,
+html input[type="button"],
+input[type="reset"],
+input[type="submit"] {
+ -webkit-appearance: button;
+ cursor: pointer;
+}
+
+button[disabled],
+html input[disabled] {
+ cursor: default;
+}
+
+button::-moz-focus-inner,
+input::-moz-focus-inner {
+ padding: 0;
+ border: 0;
+}
+
+input {
+ line-height: normal;
+}
+
+input[type="checkbox"],
+input[type="radio"] {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ padding: 0;
+}
+
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+
+input[type="search"] {
+ -webkit-box-sizing: content-box;
+ -moz-box-sizing: content-box;
+ box-sizing: content-box;
+ -webkit-appearance: textfield;
+}
+
+input[type="search"]::-webkit-search-cancel-button,
+input[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+fieldset {
+ padding: .35em .625em .75em;
+ margin: 0 2px;
+ border: 1px solid #c0c0c0;
+}
+
+legend {
+ padding: 0;
+ border: 0;
+}
+
+textarea {
+ overflow: auto;
+}
+
+optgroup {
+ font-weight: bold;
+}
+
+table {
+ border-spacing: 0;
+ border-collapse: collapse;
+}
+
+th {
+ /* Specific to table headers only! */
+ text-transform: capitalize;
+}
+
+td,
+th {
+ padding: 0;
+}
+
+/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */
+@media print {
+
+ *,
+ *:before,
+ *:after {
+ color: #000 !important;
+ text-shadow: none !important;
+ background: transparent !important;
+ -webkit-box-shadow: none !important;
+ box-shadow: none !important;
+ }
+
+ a,
+ a:visited {
+ text-decoration: underline;
+ }
+
+ a[href]:after {
+ content: " ("attr(href) ")";
+ }
+
+ abbr[title]:after {
+ content: " ("attr(title) ")";
+ }
+
+ a[href^="#"]:after,
+ a[href^="javascript:"]:after {
+ content: "";
+ }
+
+ pre,
+ blockquote {
+ border: 1px solid #999;
+
+ page-break-inside: avoid;
+ }
+
+ thead {
+ display: table-header-group;
+ }
+
+ tr,
+ img {
+ page-break-inside: avoid;
+ }
+
+ img {
+ max-width: 100% !important;
+ }
+
+ p,
+ h2,
+ h3 {
+ orphans: 3;
+ widows: 3;
+ }
+
+ h2,
+ h3 {
+ page-break-after: avoid;
+ }
+
+ select {
+ background: #fff !important;
+ }
+
+ .navbar {
+ display: none;
+ }
+
+ .btn>.caret,
+ .dropup>.btn>.caret {
+ border-top-color: #000 !important;
+ }
+
+ .label {
+ border: 1px solid #000;
+ }
+
+ .table {
+ border-collapse: collapse !important;
+ }
+
+ .table td,
+ .table th {
+ background-color: #fff !important;
+ }
+
+ .table-bordered th,
+ .table-bordered td {
+ border: 1px solid #000 !important;
+ }
+}
+
+@font-face {
+ font-family: 'Glyphicons Halflings';
+
+ src: url('../fonts/glyphicons-halflings-regular.eot');
+ src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');
+}
+
+.glyphicon {
+ position: relative;
+ top: 1px;
+ display: inline-block;
+ font-family: 'Glyphicons Halflings';
+ font-style: normal;
+ font-weight: normal;
+ line-height: 1;
+
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.glyphicon-asterisk:before {
+ content: "\2a";
+}
+
+.glyphicon-plus:before {
+ content: "\2b";
+}
+
+.glyphicon-euro:before,
+.glyphicon-eur:before {
+ content: "\20ac";
+}
+
+.glyphicon-minus:before {
+ content: "\2212";
+}
+
+.glyphicon-cloud:before {
+ content: "\2601";
+}
+
+.glyphicon-envelope:before {
+ content: "\2709";
+}
+
+.glyphicon-pencil:before {
+ content: "\270f";
+}
+
+.glyphicon-glass:before {
+ content: "\e001";
+}
+
+.glyphicon-music:before {
+ content: "\e002";
+}
+
+.glyphicon-search:before {
+ content: "\e003";
+}
+
+.glyphicon-heart:before {
+ content: "\e005";
+}
+
+.glyphicon-star:before {
+ content: "\e006";
+}
+
+.glyphicon-star-empty:before {
+ content: "\e007";
+}
+
+.glyphicon-user:before {
+ content: "\e008";
+}
+
+.glyphicon-film:before {
+ content: "\e009";
+}
+
+.glyphicon-th-large:before {
+ content: "\e010";
+}
+
+.glyphicon-th:before {
+ content: "\e011";
+}
+
+.glyphicon-th-list:before {
+ content: "\e012";
+}
+
+.glyphicon-ok:before {
+ content: "\e013";
+}
+
+.glyphicon-remove:before {
+ content: "\e014";
+}
+
+.glyphicon-zoom-in:before {
+ content: "\e015";
+}
+
+.glyphicon-zoom-out:before {
+ content: "\e016";
+}
+
+.glyphicon-off:before {
+ content: "\e017";
+}
+
+.glyphicon-signal:before {
+ content: "\e018";
+}
+
+.glyphicon-cog:before {
+ content: "\e019";
+}
+
+.glyphicon-trash:before {
+ content: "\e020";
+}
+
+.glyphicon-home:before {
+ content: "\e021";
+}
+
+.glyphicon-file:before {
+ content: "\e022";
+}
+
+.glyphicon-time:before {
+ content: "\e023";
+}
+
+.glyphicon-road:before {
+ content: "\e024";
+}
+
+.glyphicon-download-alt:before {
+ content: "\e025";
+}
+
+.glyphicon-download:before {
+ content: "\e026";
+}
+
+.glyphicon-upload:before {
+ content: "\e027";
+}
+
+.glyphicon-inbox:before {
+ content: "\e028";
+}
+
+.glyphicon-play-circle:before {
+ content: "\e029";
+}
+
+.glyphicon-repeat:before {
+ content: "\e030";
+}
+
+.glyphicon-refresh:before {
+ content: "\e031";
+}
+
+.glyphicon-list-alt:before {
+ content: "\e032";
+}
+
+.glyphicon-lock:before {
+ content: "\e033";
+}
+
+.glyphicon-flag:before {
+ content: "\e034";
+}
+
+.glyphicon-headphones:before {
+ content: "\e035";
+}
+
+.glyphicon-volume-off:before {
+ content: "\e036";
+}
+
+.glyphicon-volume-down:before {
+ content: "\e037";
+}
+
+.glyphicon-volume-up:before {
+ content: "\e038";
+}
+
+.glyphicon-qrcode:before {
+ content: "\e039";
+}
+
+.glyphicon-barcode:before {
+ content: "\e040";
+}
+
+.glyphicon-tag:before {
+ content: "\e041";
+}
+
+.glyphicon-tags:before {
+ content: "\e042";
+}
+
+.glyphicon-book:before {
+ content: "\e043";
+}
+
+.glyphicon-bookmark:before {
+ content: "\e044";
+}
+
+.glyphicon-print:before {
+ content: "\e045";
+}
+
+.glyphicon-camera:before {
+ content: "\e046";
+}
+
+.glyphicon-font:before {
+ content: "\e047";
+}
+
+.glyphicon-bold:before {
+ content: "\e048";
+}
+
+.glyphicon-italic:before {
+ content: "\e049";
+}
+
+.glyphicon-text-height:before {
+ content: "\e050";
+}
+
+.glyphicon-text-width:before {
+ content: "\e051";
+}
+
+.glyphicon-align-left:before {
+ content: "\e052";
+}
+
+.glyphicon-align-center:before {
+ content: "\e053";
+}
+
+.glyphicon-align-right:before {
+ content: "\e054";
+}
+
+.glyphicon-align-justify:before {
+ content: "\e055";
+}
+
+.glyphicon-list:before {
+ content: "\e056";
+}
+
+.glyphicon-indent-left:before {
+ content: "\e057";
+}
+
+.glyphicon-indent-right:before {
+ content: "\e058";
+}
+
+.glyphicon-facetime-video:before {
+ content: "\e059";
+}
+
+.glyphicon-picture:before {
+ content: "\e060";
+}
+
+.glyphicon-map-marker:before {
+ content: "\e062";
+}
+
+.glyphicon-adjust:before {
+ content: "\e063";
+}
+
+.glyphicon-tint:before {
+ content: "\e064";
+}
+
+.glyphicon-edit:before {
+ content: "\e065";
+}
+
+.glyphicon-share:before {
+ content: "\e066";
+}
+
+.glyphicon-check:before {
+ content: "\e067";
+}
+
+.glyphicon-move:before {
+ content: "\e068";
+}
+
+.glyphicon-step-backward:before {
+ content: "\e069";
+}
+
+.glyphicon-fast-backward:before {
+ content: "\e070";
+}
+
+.glyphicon-backward:before {
+ content: "\e071";
+}
+
+.glyphicon-play:before {
+ content: "\e072";
+}
+
+.glyphicon-pause:before {
+ content: "\e073";
+}
+
+.glyphicon-stop:before {
+ content: "\e074";
+}
+
+.glyphicon-forward:before {
+ content: "\e075";
+}
+
+.glyphicon-fast-forward:before {
+ content: "\e076";
+}
+
+.glyphicon-step-forward:before {
+ content: "\e077";
+}
+
+.glyphicon-eject:before {
+ content: "\e078";
+}
+
+.glyphicon-chevron-left:before {
+ content: "\e079";
+}
+
+.glyphicon-chevron-right:before {
+ content: "\e080";
+}
+
+.glyphicon-plus-sign:before {
+ content: "\e081";
+}
+
+.glyphicon-minus-sign:before {
+ content: "\e082";
+}
+
+.glyphicon-remove-sign:before {
+ content: "\e083";
+}
+
+.glyphicon-ok-sign:before {
+ content: "\e084";
+}
+
+.glyphicon-question-sign:before {
+ content: "\e085";
+}
+
+.glyphicon-info-sign:before {
+ content: "\e086";
+}
+
+.glyphicon-screenshot:before {
+ content: "\e087";
+}
+
+.glyphicon-remove-circle:before {
+ content: "\e088";
+}
+
+.glyphicon-ok-circle:before {
+ content: "\e089";
+}
+
+.glyphicon-ban-circle:before {
+ content: "\e090";
+}
+
+.glyphicon-arrow-left:before {
+ content: "\e091";
+}
+
+.glyphicon-arrow-right:before {
+ content: "\e092";
+}
+
+.glyphicon-arrow-up:before {
+ content: "\e093";
+}
+
+.glyphicon-arrow-down:before {
+ content: "\e094";
+}
+
+.glyphicon-share-alt:before {
+ content: "\e095";
+}
+
+.glyphicon-resize-full:before {
+ content: "\e096";
+}
+
+.glyphicon-resize-small:before {
+ content: "\e097";
+}
+
+.glyphicon-exclamation-sign:before {
+ content: "\e101";
+}
+
+.glyphicon-gift:before {
+ content: "\e102";
+}
+
+.glyphicon-leaf:before {
+ content: "\e103";
+}
+
+.glyphicon-fire:before {
+ content: "\e104";
+}
+
+.glyphicon-eye-open:before {
+ content: "\e105";
+}
+
+.glyphicon-eye-close:before {
+ content: "\e106";
+}
+
+.glyphicon-warning-sign:before {
+ content: "\e107";
+}
+
+.glyphicon-plane:before {
+ content: "\e108";
+}
+
+.glyphicon-calendar:before {
+ content: "\e109";
+}
+
+.glyphicon-random:before {
+ content: "\e110";
+}
+
+.glyphicon-comment:before {
+ content: "\e111";
+}
+
+.glyphicon-magnet:before {
+ content: "\e112";
+}
+
+.glyphicon-chevron-up:before {
+ content: "\e113";
+}
+
+.glyphicon-chevron-down:before {
+ content: "\e114";
+}
+
+.glyphicon-retweet:before {
+ content: "\e115";
+}
+
+.glyphicon-shopping-cart:before {
+ content: "\e116";
+}
+
+.glyphicon-folder-close:before {
+ content: "\e117";
+}
+
+.glyphicon-folder-open:before {
+ content: "\e118";
+}
+
+.glyphicon-resize-vertical:before {
+ content: "\e119";
+}
+
+.glyphicon-resize-horizontal:before {
+ content: "\e120";
+}
+
+.glyphicon-hdd:before {
+ content: "\e121";
+}
+
+.glyphicon-bullhorn:before {
+ content: "\e122";
+}
+
+.glyphicon-bell:before {
+ content: "\e123";
+}
+
+.glyphicon-certificate:before {
+ content: "\e124";
+}
+
+.glyphicon-thumbs-up:before {
+ content: "\e125";
+}
+
+.glyphicon-thumbs-down:before {
+ content: "\e126";
+}
+
+.glyphicon-hand-right:before {
+ content: "\e127";
+}
+
+.glyphicon-hand-left:before {
+ content: "\e128";
+}
+
+.glyphicon-hand-up:before {
+ content: "\e129";
+}
+
+.glyphicon-hand-down:before {
+ content: "\e130";
+}
+
+.glyphicon-circle-arrow-right:before {
+ content: "\e131";
+}
+
+.glyphicon-circle-arrow-left:before {
+ content: "\e132";
+}
+
+.glyphicon-circle-arrow-up:before {
+ content: "\e133";
+}
+
+.glyphicon-circle-arrow-down:before {
+ content: "\e134";
+}
+
+.glyphicon-globe:before {
+ content: "\e135";
+}
+
+.glyphicon-wrench:before {
+ content: "\e136";
+}
+
+.glyphicon-tasks:before {
+ content: "\e137";
+}
+
+.glyphicon-filter:before {
+ content: "\e138";
+}
+
+.glyphicon-briefcase:before {
+ content: "\e139";
+}
+
+.glyphicon-fullscreen:before {
+ content: "\e140";
+}
+
+.glyphicon-dashboard:before {
+ content: "\e141";
+}
+
+.glyphicon-paperclip:before {
+ content: "\e142";
+}
+
+.glyphicon-heart-empty:before {
+ content: "\e143";
+}
+
+.glyphicon-link:before {
+ content: "\e144";
+}
+
+.glyphicon-phone:before {
+ content: "\e145";
+}
+
+.glyphicon-pushpin:before {
+ content: "\e146";
+}
+
+.glyphicon-usd:before {
+ content: "\e148";
+}
+
+.glyphicon-gbp:before {
+ content: "\e149";
+}
+
+.glyphicon-sort:before {
+ content: "\e150";
+}
+
+.glyphicon-sort-by-alphabet:before {
+ content: "\e151";
+}
+
+.glyphicon-sort-by-alphabet-alt:before {
+ content: "\e152";
+}
+
+.glyphicon-sort-by-order:before {
+ content: "\e153";
+}
+
+.glyphicon-sort-by-order-alt:before {
+ content: "\e154";
+}
+
+.glyphicon-sort-by-attributes:before {
+ content: "\e155";
+}
+
+.glyphicon-sort-by-attributes-alt:before {
+ content: "\e156";
+}
+
+.glyphicon-unchecked:before {
+ content: "\e157";
+}
+
+.glyphicon-expand:before {
+ content: "\e158";
+}
+
+.glyphicon-collapse-down:before {
+ content: "\e159";
+}
+
+.glyphicon-collapse-up:before {
+ content: "\e160";
+}
+
+.glyphicon-log-in:before {
+ content: "\e161";
+}
+
+.glyphicon-flash:before {
+ content: "\e162";
+}
+
+.glyphicon-log-out:before {
+ content: "\e163";
+}
+
+.glyphicon-new-window:before {
+ content: "\e164";
+}
+
+.glyphicon-record:before {
+ content: "\e165";
+}
+
+.glyphicon-save:before {
+ content: "\e166";
+}
+
+.glyphicon-open:before {
+ content: "\e167";
+}
+
+.glyphicon-saved:before {
+ content: "\e168";
+}
+
+.glyphicon-import:before {
+ content: "\e169";
+}
+
+.glyphicon-export:before {
+ content: "\e170";
+}
+
+.glyphicon-send:before {
+ content: "\e171";
+}
+
+.glyphicon-floppy-disk:before {
+ content: "\e172";
+}
+
+.glyphicon-floppy-saved:before {
+ content: "\e173";
+}
+
+.glyphicon-floppy-remove:before {
+ content: "\e174";
+}
+
+.glyphicon-floppy-save:before {
+ content: "\e175";
+}
+
+.glyphicon-floppy-open:before {
+ content: "\e176";
+}
+
+.glyphicon-credit-card:before {
+ content: "\e177";
+}
+
+.glyphicon-transfer:before {
+ content: "\e178";
+}
+
+.glyphicon-cutlery:before {
+ content: "\e179";
+}
+
+.glyphicon-header:before {
+ content: "\e180";
+}
+
+.glyphicon-compressed:before {
+ content: "\e181";
+}
+
+.glyphicon-earphone:before {
+ content: "\e182";
+}
+
+.glyphicon-phone-alt:before {
+ content: "\e183";
+}
+
+.glyphicon-tower:before {
+ content: "\e184";
+}
+
+.glyphicon-stats:before {
+ content: "\e185";
+}
+
+.glyphicon-sd-video:before {
+ content: "\e186";
+}
+
+.glyphicon-hd-video:before {
+ content: "\e187";
+}
+
+.glyphicon-subtitles:before {
+ content: "\e188";
+}
+
+.glyphicon-sound-stereo:before {
+ content: "\e189";
+}
+
+.glyphicon-sound-dolby:before {
+ content: "\e190";
+}
+
+.glyphicon-sound-5-1:before {
+ content: "\e191";
+}
+
+.glyphicon-sound-6-1:before {
+ content: "\e192";
+}
+
+.glyphicon-sound-7-1:before {
+ content: "\e193";
+}
+
+.glyphicon-copyright-mark:before {
+ content: "\e194";
+}
+
+.glyphicon-registration-mark:before {
+ content: "\e195";
+}
+
+.glyphicon-cloud-download:before {
+ content: "\e197";
+}
+
+.glyphicon-cloud-upload:before {
+ content: "\e198";
+}
+
+.glyphicon-tree-conifer:before {
+ content: "\e199";
+}
+
+.glyphicon-tree-deciduous:before {
+ content: "\e200";
+}
+
+* {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+*:before,
+*:after {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+html {
+ font-size: 10px;
+
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+}
+
+body {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 14px;
+ line-height: 1.42857143;
+ color: #000;
+ background-color: #fff;
+}
+
+input,
+button,
+select,
+textarea {
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+}
+
+a {
+ color: #3071a9;
+ text-decoration: none;
+}
+
+a:hover,
+a:focus {
+ color: #2a6496;
+ text-decoration: underline;
+}
+
+a:focus {
+ outline: thin dotted;
+ outline: 5px auto -webkit-focus-ring-color;
+ outline-offset: -2px;
+}
+
+figure {
+ margin: 0;
+}
+
+img {
+ vertical-align: middle;
+}
+
+.img-responsive,
+.thumbnail>img,
+.thumbnail a>img,
+.carousel-inner>.item>img,
+.carousel-inner>.item>a>img {
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+
+.img-rounded {
+ border-radius: 6px;
+}
+
+.img-thumbnail {
+ display: inline-block;
+ max-width: 100%;
+ height: auto;
+ padding: 4px;
+ line-height: 1.42857143;
+ background-color: #fff;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ -webkit-transition: all .2s ease-in-out;
+ -o-transition: all .2s ease-in-out;
+ transition: all .2s ease-in-out;
+}
+
+.img-circle {
+ border-radius: 50%;
+}
+
+hr {
+ margin-top: 20px;
+ margin-bottom: 20px;
+ border: 0;
+ border-top: 1px solid #eee;
+}
+
+.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;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+.h1,
+.h2,
+.h3,
+.h4,
+.h5,
+.h6 {
+ font-family: inherit;
+ font-weight: 500;
+ line-height: 1.1;
+ color: inherit;
+}
+
+h1 small,
+h2 small,
+h3 small,
+h4 small,
+h5 small,
+h6 small,
+.h1 small,
+.h2 small,
+.h3 small,
+.h4 small,
+.h5 small,
+.h6 small,
+h1 .small,
+h2 .small,
+h3 .small,
+h4 .small,
+h5 .small,
+h6 .small,
+.h1 .small,
+.h2 .small,
+.h3 .small,
+.h4 .small,
+.h5 .small,
+.h6 .small {
+ font-weight: normal;
+ line-height: 1;
+ color: #777;
+}
+
+h1,
+.h1,
+h2,
+.h2,
+h3,
+.h3 {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+
+h1 small,
+.h1 small,
+h2 small,
+.h2 small,
+h3 small,
+.h3 small,
+h1 .small,
+.h1 .small,
+h2 .small,
+.h2 .small,
+h3 .small,
+.h3 .small {
+ font-size: 65%;
+}
+
+h4,
+.h4,
+h5,
+.h5,
+h6,
+.h6 {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+
+h4 small,
+.h4 small,
+h5 small,
+.h5 small,
+h6 small,
+.h6 small,
+h4 .small,
+.h4 .small,
+h5 .small,
+.h5 .small,
+h6 .small,
+.h6 .small {
+ font-size: 75%;
+}
+
+h1,
+.h1 {
+ font-size: 30px;
+}
+
+h2,
+.h2 {
+ font-size: 24px;
+}
+
+h3,
+.h3 {
+ font-size: 18px;
+}
+
+h4,
+.h4 {
+ font-size: 14px;
+}
+
+h5,
+.h5 {
+ font-size: 12px;
+}
+
+h6,
+.h6 {
+ font-size: 10px;
+}
+
+p {
+ margin: 0 0 10px;
+}
+
+.lead {
+ margin-bottom: 20px;
+ font-size: 16px;
+ font-weight: 300;
+ line-height: 1.4;
+}
+
+@media (min-width: 768px) {
+ .lead {
+ font-size: 21px;
+ }
+}
+
+small,
+.small {
+ font-size: 85%;
+}
+
+mark,
+.mark {
+ padding: .2em;
+ background-color: #fcf8e3;
+}
+
+.text-left {
+ text-align: left;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-justify {
+ text-align: justify;
+}
+
+.text-nowrap {
+ white-space: nowrap;
+}
+
+.text-lowercase {
+ text-transform: lowercase;
+}
+
+.text-uppercase {
+ text-transform: uppercase;
+}
+
+.text-capitalize {
+ text-transform: capitalize;
+}
+
+.text-muted {
+ color: #777;
+}
+
+.text-primary {
+ color: #428bca;
+}
+
+a.text-primary:hover {
+ color: #3071a9;
+}
+
+.text-success {
+ color: #3c763d;
+}
+
+a.text-success:hover {
+ color: #2b542c;
+}
+
+.text-info {
+ color: #31708f;
+}
+
+a.text-info:hover {
+ color: #245269;
+}
+
+.text-warning {
+ color: #8a6d3b;
+}
+
+a.text-warning:hover {
+ color: #66512c;
+}
+
+.text-danger {
+ color: #a94442;
+}
+
+a.text-danger:hover {
+ color: #843534;
+}
+
+.bg-primary {
+ color: #fff;
+ background-color: #428bca;
+}
+
+a.bg-primary:hover {
+ background-color: #3071a9;
+}
+
+.bg-success {
+ background-color: #dff0d8;
+}
+
+a.bg-success:hover {
+ background-color: #c1e2b3;
+}
+
+.bg-info {
+ background-color: #d9edf7;
+}
+
+a.bg-info:hover {
+ background-color: #afd9ee;
+}
+
+.bg-warning {
+ background-color: #fcf8e3;
+}
+
+a.bg-warning:hover {
+ background-color: #f7ecb5;
+}
+
+.bg-danger {
+ background-color: #f2dede;
+}
+
+a.bg-danger:hover {
+ background-color: #e4b9b9;
+}
+
+.page-header {
+ padding-bottom: 9px;
+ margin: 10px 0 10px;
+ border-bottom: 1px solid #eee;
+}
+
+ul,
+ol {
+ margin-top: 0;
+ margin-bottom: 10px;
+}
+
+ul ul,
+ol ul,
+ul ol,
+ol ol {
+ margin-bottom: 0;
+}
+
+.list-unstyled {
+ padding-left: 0;
+ list-style: none;
+}
+
+.list-inline {
+ padding-left: 0;
+ margin-left: -5px;
+ list-style: none;
+}
+
+.list-inline>li {
+ display: inline-block;
+ padding-right: 5px;
+ padding-left: 5px;
+}
+
+dl {
+ margin-top: 0;
+ margin-bottom: 20px;
+}
+
+dt,
+dd {
+ line-height: 1.42857143;
+}
+
+dt {
+ font-weight: bold;
+}
+
+dd {
+ margin-left: 0;
+}
+
+@media (min-width: 768px) {
+ .dl-horizontal dt {
+ float: left;
+ width: 160px;
+ overflow: hidden;
+ clear: left;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .dl-horizontal dd {
+ margin-left: 180px;
+ }
+}
+
+abbr[title],
+abbr[data-original-title] {
+ cursor: help;
+ border-bottom: 1px dotted #777;
+}
+
+.initialism {
+ font-size: 90%;
+ text-transform: uppercase;
+}
+
+blockquote {
+ padding: 10px 20px;
+ margin: 0 0 20px;
+ font-size: 17.5px;
+ border-left: 5px solid #eee;
+}
+
+blockquote p:last-child,
+blockquote ul:last-child,
+blockquote ol:last-child {
+ margin-bottom: 0;
+}
+
+blockquote footer,
+blockquote small,
+blockquote .small {
+ display: block;
+ font-size: 80%;
+ line-height: 1.42857143;
+ color: #777;
+}
+
+blockquote footer:before,
+blockquote small:before,
+blockquote .small:before {
+ content: '\2014 \00A0';
+}
+
+.blockquote-reverse,
+blockquote.pull-right {
+ padding-right: 15px;
+ padding-left: 0;
+ text-align: right;
+ border-right: 5px solid #eee;
+ border-left: 0;
+}
+
+.blockquote-reverse footer:before,
+blockquote.pull-right footer:before,
+.blockquote-reverse small:before,
+blockquote.pull-right small:before,
+.blockquote-reverse .small:before,
+blockquote.pull-right .small:before {
+ content: '';
+}
+
+.blockquote-reverse footer:after,
+blockquote.pull-right footer:after,
+.blockquote-reverse small:after,
+blockquote.pull-right small:after,
+.blockquote-reverse .small:after,
+blockquote.pull-right .small:after {
+ content: '\00A0 \2014';
+}
+
+address {
+ margin-bottom: 20px;
+ font-style: normal;
+ line-height: 1.42857143;
+}
+
+code,
+kbd,
+pre,
+samp {
+ font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+}
+
+code {
+ padding: 2px 4px;
+ font-size: 90%;
+ color: #c7254e;
+ background-color: #f9f2f4;
+ border-radius: 4px;
+}
+
+kbd {
+ padding: 2px 4px;
+ font-size: 90%;
+ color: #fff;
+ background-color: #333;
+ border-radius: 3px;
+ -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25);
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25);
+}
+
+kbd kbd {
+ padding: 0;
+ font-size: 100%;
+ font-weight: bold;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+}
+
+pre {
+ display: block;
+ padding: 9.5px;
+ margin: 0 0 10px;
+ font-size: 13px;
+ line-height: 1.42857143;
+ color: #333;
+ word-break: break-all;
+ word-wrap: break-word;
+ background-color: #f5f5f5;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+}
+
+pre code {
+ padding: 0;
+ font-size: inherit;
+ color: inherit;
+ white-space: pre-wrap;
+ background-color: transparent;
+ border-radius: 0;
+}
+
+.pre-scrollable {
+ max-height: 340px;
+ overflow-y: scroll;
+}
+
+.container {
+ padding-right: 15px;
+ padding-left: 15px;
+}
+
+/*
+@media (min-width: 768px) {
+ .container {
+ width: 750px;
+ }
+}
+@media (min-width: 992px) {
+ .container {
+ width: 970px;
+ }
+}
+@media (min-width: 1200px) {
+ .container {
+ width: 1170px;
+ }
+}*/
+
+.container-fluid {
+ padding-right: 15px;
+ padding-left: 15px;
+ margin-right: auto;
+ margin-left: auto;
+}
+
+.row {
+ margin-right: -15px;
+ margin-left: -15px;
+}
+
+.col-xs-1,
+.col-sm-1,
+.col-md-1,
+.col-lg-1,
+.col-xs-2,
+.col-sm-2,
+.col-md-2,
+.col-lg-2,
+.col-xs-3,
+.col-sm-3,
+.col-md-3,
+.col-lg-3,
+.col-xs-4,
+.col-sm-4,
+.col-md-4,
+.col-lg-4,
+.col-xs-5,
+.col-sm-5,
+.col-md-5,
+.col-lg-5,
+.col-xs-6,
+.col-sm-6,
+.col-md-6,
+.col-lg-6,
+.col-xs-7,
+.col-sm-7,
+.col-md-7,
+.col-lg-7,
+.col-xs-8,
+.col-sm-8,
+.col-md-8,
+.col-lg-8,
+.col-xs-9,
+.col-sm-9,
+.col-md-9,
+.col-lg-9,
+.col-xs-10,
+.col-sm-10,
+.col-md-10,
+.col-lg-10,
+.col-xs-11,
+.col-sm-11,
+.col-md-11,
+.col-lg-11,
+.col-xs-12,
+.col-sm-12,
+.col-md-12,
+.col-lg-12 {
+ position: relative;
+ min-height: 1px;
+ padding-right: 15px;
+ padding-left: 15px;
+}
+
+.col-xs-1,
+.col-xs-2,
+.col-xs-3,
+.col-xs-4,
+.col-xs-5,
+.col-xs-6,
+.col-xs-7,
+.col-xs-8,
+.col-xs-9,
+.col-xs-10,
+.col-xs-11,
+.col-xs-12 {
+ float: left;
+}
+
+.col-xs-12 {
+ width: 100%;
+}
+
+.col-xs-11 {
+ width: 91.66666667%;
+}
+
+.col-xs-10 {
+ width: 83.33333333%;
+}
+
+.col-xs-9 {
+ width: 75%;
+}
+
+.col-xs-8 {
+ width: 66.66666667%;
+}
+
+.col-xs-7 {
+ width: 58.33333333%;
+}
+
+.col-xs-6 {
+ width: 50%;
+}
+
+.col-xs-5 {
+ width: 41.66666667%;
+}
+
+.col-xs-4 {
+ width: 33.33333333%;
+}
+
+.col-xs-3 {
+ width: 25%;
+}
+
+.col-xs-2 {
+ width: 16.66666667%;
+}
+
+.col-xs-1 {
+ width: 8.33333333%;
+}
+
+.col-xs-pull-12 {
+ right: 100%;
+}
+
+.col-xs-pull-11 {
+ right: 91.66666667%;
+}
+
+.col-xs-pull-10 {
+ right: 83.33333333%;
+}
+
+.col-xs-pull-9 {
+ right: 75%;
+}
+
+.col-xs-pull-8 {
+ right: 66.66666667%;
+}
+
+.col-xs-pull-7 {
+ right: 58.33333333%;
+}
+
+.col-xs-pull-6 {
+ right: 50%;
+}
+
+.col-xs-pull-5 {
+ right: 41.66666667%;
+}
+
+.col-xs-pull-4 {
+ right: 33.33333333%;
+}
+
+.col-xs-pull-3 {
+ right: 25%;
+}
+
+.col-xs-pull-2 {
+ right: 16.66666667%;
+}
+
+.col-xs-pull-1 {
+ right: 8.33333333%;
+}
+
+.col-xs-pull-0 {
+ right: auto;
+}
+
+.col-xs-push-12 {
+ left: 100%;
+}
+
+.col-xs-push-11 {
+ left: 91.66666667%;
+}
+
+.col-xs-push-10 {
+ left: 83.33333333%;
+}
+
+.col-xs-push-9 {
+ left: 75%;
+}
+
+.col-xs-push-8 {
+ left: 66.66666667%;
+}
+
+.col-xs-push-7 {
+ left: 58.33333333%;
+}
+
+.col-xs-push-6 {
+ left: 50%;
+}
+
+.col-xs-push-5 {
+ left: 41.66666667%;
+}
+
+.col-xs-push-4 {
+ left: 33.33333333%;
+}
+
+.col-xs-push-3 {
+ left: 25%;
+}
+
+.col-xs-push-2 {
+ left: 16.66666667%;
+}
+
+.col-xs-push-1 {
+ left: 8.33333333%;
+}
+
+.col-xs-push-0 {
+ left: auto;
+}
+
+.col-xs-offset-12 {
+ margin-left: 100%;
+}
+
+.col-xs-offset-11 {
+ margin-left: 91.66666667%;
+}
+
+.col-xs-offset-10 {
+ margin-left: 83.33333333%;
+}
+
+.col-xs-offset-9 {
+ margin-left: 75%;
+}
+
+.col-xs-offset-8 {
+ margin-left: 66.66666667%;
+}
+
+.col-xs-offset-7 {
+ margin-left: 58.33333333%;
+}
+
+.col-xs-offset-6 {
+ margin-left: 50%;
+}
+
+.col-xs-offset-5 {
+ margin-left: 41.66666667%;
+}
+
+.col-xs-offset-4 {
+ margin-left: 33.33333333%;
+}
+
+.col-xs-offset-3 {
+ margin-left: 25%;
+}
+
+.col-xs-offset-2 {
+ margin-left: 16.66666667%;
+}
+
+.col-xs-offset-1 {
+ margin-left: 8.33333333%;
+}
+
+.col-xs-offset-0 {
+ margin-left: 0;
+}
+
+/*
+@media (min-width: 768px) {
+ .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {
+ float: left;
+ }
+ .col-sm-12 {
+ width: 100%;
+ }
+ .col-sm-11 {
+ width: 91.66666667%;
+ }
+ .col-sm-10 {
+ width: 83.33333333%;
+ }
+ .col-sm-9 {
+ width: 75%;
+ }
+ .col-sm-8 {
+ width: 66.66666667%;
+ }
+ .col-sm-7 {
+ width: 58.33333333%;
+ }
+ .col-sm-6 {
+ width: 50%;
+ }
+ .col-sm-5 {
+ width: 41.66666667%;
+ }
+ .col-sm-4 {
+ width: 33.33333333%;
+ }
+ .col-sm-3 {
+ width: 25%;
+ }
+ .col-sm-2 {
+ width: 16.66666667%;
+ }
+ .col-sm-1 {
+ width: 8.33333333%;
+ }
+ .col-sm-pull-12 {
+ right: 100%;
+ }
+ .col-sm-pull-11 {
+ right: 91.66666667%;
+ }
+ .col-sm-pull-10 {
+ right: 83.33333333%;
+ }
+ .col-sm-pull-9 {
+ right: 75%;
+ }
+ .col-sm-pull-8 {
+ right: 66.66666667%;
+ }
+ .col-sm-pull-7 {
+ right: 58.33333333%;
+ }
+ .col-sm-pull-6 {
+ right: 50%;
+ }
+ .col-sm-pull-5 {
+ right: 41.66666667%;
+ }
+ .col-sm-pull-4 {
+ right: 33.33333333%;
+ }
+ .col-sm-pull-3 {
+ right: 25%;
+ }
+ .col-sm-pull-2 {
+ right: 16.66666667%;
+ }
+ .col-sm-pull-1 {
+ right: 8.33333333%;
+ }
+ .col-sm-pull-0 {
+ right: auto;
+ }
+ .col-sm-push-12 {
+ left: 100%;
+ }
+ .col-sm-push-11 {
+ left: 91.66666667%;
+ }
+ .col-sm-push-10 {
+ left: 83.33333333%;
+ }
+ .col-sm-push-9 {
+ left: 75%;
+ }
+ .col-sm-push-8 {
+ left: 66.66666667%;
+ }
+ .col-sm-push-7 {
+ left: 58.33333333%;
+ }
+ .col-sm-push-6 {
+ left: 50%;
+ }
+ .col-sm-push-5 {
+ left: 41.66666667%;
+ }
+ .col-sm-push-4 {
+ left: 33.33333333%;
+ }
+ .col-sm-push-3 {
+ left: 25%;
+ }
+ .col-sm-push-2 {
+ left: 16.66666667%;
+ }
+ .col-sm-push-1 {
+ left: 8.33333333%;
+ }
+ .col-sm-push-0 {
+ left: auto;
+ }
+ .col-sm-offset-12 {
+ margin-left: 100%;
+ }
+ .col-sm-offset-11 {
+ margin-left: 91.66666667%;
+ }
+ .col-sm-offset-10 {
+ margin-left: 83.33333333%;
+ }
+ .col-sm-offset-9 {
+ margin-left: 75%;
+ }
+ .col-sm-offset-8 {
+ margin-left: 66.66666667%;
+ }
+ .col-sm-offset-7 {
+ margin-left: 58.33333333%;
+ }
+ .col-sm-offset-6 {
+ margin-left: 50%;
+ }
+ .col-sm-offset-5 {
+ margin-left: 41.66666667%;
+ }
+ .col-sm-offset-4 {
+ margin-left: 33.33333333%;
+ }
+ .col-sm-offset-3 {
+ margin-left: 25%;
+ }
+ .col-sm-offset-2 {
+ margin-left: 16.66666667%;
+ }
+ .col-sm-offset-1 {
+ margin-left: 8.33333333%;
+ }
+ .col-sm-offset-0 {
+ margin-left: 0;
+ }
+}
+@media (min-width: 992px) {
+ .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {
+ float: left;
+ }
+ .col-md-12 {
+ width: 100%;
+ }
+ .col-md-11 {
+ width: 91.66666667%;
+ }
+ .col-md-10 {
+ width: 83.33333333%;
+ }
+ .col-md-9 {
+ width: 75%;
+ }
+ .col-md-8 {
+ width: 66.66666667%;
+ }
+ .col-md-7 {
+ width: 58.33333333%;
+ }
+ .col-md-6 {
+ width: 50%;
+ }
+ .col-md-5 {
+ width: 41.66666667%;
+ }
+ .col-md-4 {
+ width: 33.33333333%;
+ }
+ .col-md-3 {
+ width: 25%;
+ }
+ .col-md-2 {
+ width: 16.66666667%;
+ }
+ .col-md-1 {
+ width: 8.33333333%;
+ }
+ .col-md-pull-12 {
+ right: 100%;
+ }
+ .col-md-pull-11 {
+ right: 91.66666667%;
+ }
+ .col-md-pull-10 {
+ right: 83.33333333%;
+ }
+ .col-md-pull-9 {
+ right: 75%;
+ }
+ .col-md-pull-8 {
+ right: 66.66666667%;
+ }
+ .col-md-pull-7 {
+ right: 58.33333333%;
+ }
+ .col-md-pull-6 {
+ right: 50%;
+ }
+ .col-md-pull-5 {
+ right: 41.66666667%;
+ }
+ .col-md-pull-4 {
+ right: 33.33333333%;
+ }
+ .col-md-pull-3 {
+ right: 25%;
+ }
+ .col-md-pull-2 {
+ right: 16.66666667%;
+ }
+ .col-md-pull-1 {
+ right: 8.33333333%;
+ }
+ .col-md-pull-0 {
+ right: auto;
+ }
+ .col-md-push-12 {
+ left: 100%;
+ }
+ .col-md-push-11 {
+ left: 91.66666667%;
+ }
+ .col-md-push-10 {
+ left: 83.33333333%;
+ }
+ .col-md-push-9 {
+ left: 75%;
+ }
+ .col-md-push-8 {
+ left: 66.66666667%;
+ }
+ .col-md-push-7 {
+ left: 58.33333333%;
+ }
+ .col-md-push-6 {
+ left: 50%;
+ }
+ .col-md-push-5 {
+ left: 41.66666667%;
+ }
+ .col-md-push-4 {
+ left: 33.33333333%;
+ }
+ .col-md-push-3 {
+ left: 25%;
+ }
+ .col-md-push-2 {
+ left: 16.66666667%;
+ }
+ .col-md-push-1 {
+ left: 8.33333333%;
+ }
+ .col-md-push-0 {
+ left: auto;
+ }
+ .col-md-offset-12 {
+ margin-left: 100%;
+ }
+ .col-md-offset-11 {
+ margin-left: 91.66666667%;
+ }
+ .col-md-offset-10 {
+ margin-left: 83.33333333%;
+ }
+ .col-md-offset-9 {
+ margin-left: 75%;
+ }
+ .col-md-offset-8 {
+ margin-left: 66.66666667%;
+ }
+ .col-md-offset-7 {
+ margin-left: 58.33333333%;
+ }
+ .col-md-offset-6 {
+ margin-left: 50%;
+ }
+ .col-md-offset-5 {
+ margin-left: 41.66666667%;
+ }
+ .col-md-offset-4 {
+ margin-left: 33.33333333%;
+ }
+ .col-md-offset-3 {
+ margin-left: 25%;
+ }
+ .col-md-offset-2 {
+ margin-left: 16.66666667%;
+ }
+ .col-md-offset-1 {
+ margin-left: 8.33333333%;
+ }
+ .col-md-offset-0 {
+ margin-left: 0;
+ }
+}
+@media (min-width: 1200px) {
+ .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {
+ float: left;
+ }
+ .col-lg-12 {
+ width: 100%;
+ }
+ .col-lg-11 {
+ width: 91.66666667%;
+ }
+ .col-lg-10 {
+ width: 83.33333333%;
+ }
+ .col-lg-9 {
+ width: 75%;
+ }
+ .col-lg-8 {
+ width: 66.66666667%;
+ }
+ .col-lg-7 {
+ width: 58.33333333%;
+ }
+ .col-lg-6 {
+ width: 50%;
+ }
+ .col-lg-5 {
+ width: 41.66666667%;
+ }
+ .col-lg-4 {
+ width: 33.33333333%;
+ }
+ .col-lg-3 {
+ width: 25%;
+ }
+ .col-lg-2 {
+ width: 16.66666667%;
+ }
+ .col-lg-1 {
+ width: 8.33333333%;
+ }
+ .col-lg-pull-12 {
+ right: 100%;
+ }
+ .col-lg-pull-11 {
+ right: 91.66666667%;
+ }
+ .col-lg-pull-10 {
+ right: 83.33333333%;
+ }
+ .col-lg-pull-9 {
+ right: 75%;
+ }
+ .col-lg-pull-8 {
+ right: 66.66666667%;
+ }
+ .col-lg-pull-7 {
+ right: 58.33333333%;
+ }
+ .col-lg-pull-6 {
+ right: 50%;
+ }
+ .col-lg-pull-5 {
+ right: 41.66666667%;
+ }
+ .col-lg-pull-4 {
+ right: 33.33333333%;
+ }
+ .col-lg-pull-3 {
+ right: 25%;
+ }
+ .col-lg-pull-2 {
+ right: 16.66666667%;
+ }
+ .col-lg-pull-1 {
+ right: 8.33333333%;
+ }
+ .col-lg-pull-0 {
+ right: auto;
+ }
+ .col-lg-push-12 {
+ left: 100%;
+ }
+ .col-lg-push-11 {
+ left: 91.66666667%;
+ }
+ .col-lg-push-10 {
+ left: 83.33333333%;
+ }
+ .col-lg-push-9 {
+ left: 75%;
+ }
+ .col-lg-push-8 {
+ left: 66.66666667%;
+ }
+ .col-lg-push-7 {
+ left: 58.33333333%;
+ }
+ .col-lg-push-6 {
+ left: 50%;
+ }
+ .col-lg-push-5 {
+ left: 41.66666667%;
+ }
+ .col-lg-push-4 {
+ left: 33.33333333%;
+ }
+ .col-lg-push-3 {
+ left: 25%;
+ }
+ .col-lg-push-2 {
+ left: 16.66666667%;
+ }
+ .col-lg-push-1 {
+ left: 8.33333333%;
+ }
+ .col-lg-push-0 {
+ left: auto;
+ }
+ .col-lg-offset-12 {
+ margin-left: 100%;
+ }
+ .col-lg-offset-11 {
+ margin-left: 91.66666667%;
+ }
+ .col-lg-offset-10 {
+ margin-left: 83.33333333%;
+ }
+ .col-lg-offset-9 {
+ margin-left: 75%;
+ }
+ .col-lg-offset-8 {
+ margin-left: 66.66666667%;
+ }
+ .col-lg-offset-7 {
+ margin-left: 58.33333333%;
+ }
+ .col-lg-offset-6 {
+ margin-left: 50%;
+ }
+ .col-lg-offset-5 {
+ margin-left: 41.66666667%;
+ }
+ .col-lg-offset-4 {
+ margin-left: 33.33333333%;
+ }
+ .col-lg-offset-3 {
+ margin-left: 25%;
+ }
+ .col-lg-offset-2 {
+ margin-left: 16.66666667%;
+ }
+ .col-lg-offset-1 {
+ margin-left: 8.33333333%;
+ }
+ .col-lg-offset-0 {
+ margin-left: 0;
+ }
+}
+*/
+
+table {
+ background-color: transparent;
+}
+
+caption {
+ padding-top: 8px;
+ padding-bottom: 8px;
+ color: #777;
+ text-align: left;
+}
+
+th {
+ text-align: left;
+}
+
+.table {
+ //width: 100%;
+ //max-width: 100%;
+ margin-bottom: 20px;
+}
+
+.table>thead>tr>th,
+.table>tbody>tr>th,
+.table>tfoot>tr>th,
+.table>thead>tr>td,
+.table>tbody>tr>td,
+.table>tfoot>tr>td {
+ padding: 8px;
+ line-height: 1.42857143;
+ vertical-align: top;
+ border-top: 1px solid #ddd;
+}
+
+.table>thead>tr>th {
+ vertical-align: middle;
+ border-bottom: 2px solid #ddd;
+}
+
+.table>caption+thead>tr:first-child>th,
+.table>colgroup+thead>tr:first-child>th,
+.table>thead:first-child>tr:first-child>th,
+.table>caption+thead>tr:first-child>td,
+.table>colgroup+thead>tr:first-child>td,
+.table>thead:first-child>tr:first-child>td {
+ border-top: 0;
+}
+
+.table>tbody+tbody {
+ border-top: 2px solid #ddd;
+}
+
+.table .table {
+ background-color: #fff;
+}
+
+.table-condensed>thead>tr>th,
+.table-condensed>tbody>tr>th,
+.table-condensed>tfoot>tr>th,
+.table-condensed>thead>tr>td,
+.table-condensed>tbody>tr>td,
+.table-condensed>tfoot>tr>td {
+ padding: 5px;
+}
+
+.table-bordered {
+ border: 1px solid #000;
+}
+
+.table-bordered>thead>tr>th,
+.table-bordered>tbody>tr>th,
+.table-bordered>tfoot>tr>th,
+.table-bordered>thead>tr>td,
+.table-bordered>tbody>tr>td,
+.table-bordered>tfoot>tr>td {
+ border: 1px solid #000;
+}
+
+.table-bordered>thead>tr>th,
+.table-bordered>thead>tr>td {
+ border-bottom-width: 2px;
+}
+
+.table-striped>tbody>tr:nth-child(odd) {
+ background-color: #f9f9f9;
+}
+
+.table-hover>tbody>tr:hover {
+ background-color: #f5f5f5;
+}
+
+table col[class*="col-"] {
+ position: static;
+ display: table-column;
+ float: none;
+}
+
+table td[class*="col-"],
+table th[class*="col-"] {
+ position: static;
+ display: table-cell;
+ float: none;
+}
+
+.table>thead>tr>td.active,
+.table>tbody>tr>td.active,
+.table>tfoot>tr>td.active,
+.table>thead>tr>th.active,
+.table>tbody>tr>th.active,
+.table>tfoot>tr>th.active,
+.table>thead>tr.active>td,
+.table>tbody>tr.active>td,
+.table>tfoot>tr.active>td,
+.table>thead>tr.active>th,
+.table>tbody>tr.active>th,
+.table>tfoot>tr.active>th {
+ background-color: #f5f5f5;
+}
+
+.table-hover>tbody>tr>td.active:hover,
+.table-hover>tbody>tr>th.active:hover,
+.table-hover>tbody>tr.active:hover>td,
+.table-hover>tbody>tr:hover>.active,
+.table-hover>tbody>tr.active:hover>th {
+ background-color: #e8e8e8;
+}
+
+.table>thead>tr>td.success,
+.table>tbody>tr>td.success,
+.table>tfoot>tr>td.success,
+.table>thead>tr>th.success,
+.table>tbody>tr>th.success,
+.table>tfoot>tr>th.success,
+.table>thead>tr.success>td,
+.table>tbody>tr.success>td,
+.table>tfoot>tr.success>td,
+.table>thead>tr.success>th,
+.table>tbody>tr.success>th,
+.table>tfoot>tr.success>th {
+ background-color: #dff0d8;
+}
+
+.table-hover>tbody>tr>td.success:hover,
+.table-hover>tbody>tr>th.success:hover,
+.table-hover>tbody>tr.success:hover>td,
+.table-hover>tbody>tr:hover>.success,
+.table-hover>tbody>tr.success:hover>th {
+ background-color: #d0e9c6;
+}
+
+.table>thead>tr>td.info,
+.table>tbody>tr>td.info,
+.table>tfoot>tr>td.info,
+.table>thead>tr>th.info,
+.table>tbody>tr>th.info,
+.table>tfoot>tr>th.info,
+.table>thead>tr.info>td,
+.table>tbody>tr.info>td,
+.table>tfoot>tr.info>td,
+.table>thead>tr.info>th,
+.table>tbody>tr.info>th,
+.table>tfoot>tr.info>th {
+ background-color: #d9edf7;
+}
+
+.table-hover>tbody>tr>td.info:hover,
+.table-hover>tbody>tr>th.info:hover,
+.table-hover>tbody>tr.info:hover>td,
+.table-hover>tbody>tr:hover>.info,
+.table-hover>tbody>tr.info:hover>th {
+ background-color: #c4e3f3;
+}
+
+.table>thead>tr>td.warning,
+.table>tbody>tr>td.warning,
+.table>tfoot>tr>td.warning,
+.table>thead>tr>th.warning,
+.table>tbody>tr>th.warning,
+.table>tfoot>tr>th.warning,
+.table>thead>tr.warning>td,
+.table>tbody>tr.warning>td,
+.table>tfoot>tr.warning>td,
+.table>thead>tr.warning>th,
+.table>tbody>tr.warning>th,
+.table>tfoot>tr.warning>th {
+ background-color: #fcf8e3;
+}
+
+.table-hover>tbody>tr>td.warning:hover,
+.table-hover>tbody>tr>th.warning:hover,
+.table-hover>tbody>tr.warning:hover>td,
+.table-hover>tbody>tr:hover>.warning,
+.table-hover>tbody>tr.warning:hover>th {
+ background-color: #faf2cc;
+}
+
+.table>thead>tr>td.danger,
+.table>tbody>tr>td.danger,
+.table>tfoot>tr>td.danger,
+.table>thead>tr>th.danger,
+.table>tbody>tr>th.danger,
+.table>tfoot>tr>th.danger,
+.table>thead>tr.danger>td,
+.table>tbody>tr.danger>td,
+.table>tfoot>tr.danger>td,
+.table>thead>tr.danger>th,
+.table>tbody>tr.danger>th,
+.table>tfoot>tr.danger>th {
+ background-color: #f2dede;
+}
+
+.table-hover>tbody>tr>td.danger:hover,
+.table-hover>tbody>tr>th.danger:hover,
+.table-hover>tbody>tr.danger:hover>td,
+.table-hover>tbody>tr:hover>.danger,
+.table-hover>tbody>tr.danger:hover>th {
+ background-color: #ebcccc;
+}
+
+.table-responsive {
+ min-height: .01%;
+ overflow-x: auto;
+}
+
+@media screen and (max-width: 767px) {
+ .table-responsive {
+ width: 100%;
+ margin-bottom: 15px;
+ overflow-y: hidden;
+ -ms-overflow-style: -ms-autohiding-scrollbar;
+ border: 1px solid #ddd;
+ }
+
+ .table-responsive>.table {
+ margin-bottom: 0;
+ }
+
+ .table-responsive>.table>thead>tr>th,
+ .table-responsive>.table>tbody>tr>th,
+ .table-responsive>.table>tfoot>tr>th,
+ .table-responsive>.table>thead>tr>td,
+ .table-responsive>.table>tbody>tr>td,
+ .table-responsive>.table>tfoot>tr>td {
+ white-space: nowrap;
+ }
+
+ .table-responsive>.table-bordered {
+ border: 0;
+ }
+
+ .table-responsive>.table-bordered>thead>tr>th:first-child,
+ .table-responsive>.table-bordered>tbody>tr>th:first-child,
+ .table-responsive>.table-bordered>tfoot>tr>th:first-child,
+ .table-responsive>.table-bordered>thead>tr>td:first-child,
+ .table-responsive>.table-bordered>tbody>tr>td:first-child,
+ .table-responsive>.table-bordered>tfoot>tr>td:first-child {
+ border-left: 0;
+ }
+
+ .table-responsive>.table-bordered>thead>tr>th:last-child,
+ .table-responsive>.table-bordered>tbody>tr>th:last-child,
+ .table-responsive>.table-bordered>tfoot>tr>th:last-child,
+ .table-responsive>.table-bordered>thead>tr>td:last-child,
+ .table-responsive>.table-bordered>tbody>tr>td:last-child,
+ .table-responsive>.table-bordered>tfoot>tr>td:last-child {
+ border-right: 0;
+ }
+
+ .table-responsive>.table-bordered>tbody>tr:last-child>th,
+ .table-responsive>.table-bordered>tfoot>tr:last-child>th,
+ .table-responsive>.table-bordered>tbody>tr:last-child>td,
+ .table-responsive>.table-bordered>tfoot>tr:last-child>td {
+ border-bottom: 0;
+ }
+}
+
+fieldset {
+ min-width: 0;
+ padding: 0;
+ margin: 0;
+ border: 0;
+}
+
+legend {
+ display: block;
+ width: 100%;
+ padding: 0;
+ margin-bottom: 20px;
+ font-size: 21px;
+ line-height: inherit;
+ color: #333;
+ border: 0;
+ border-bottom: 1px solid #e5e5e5;
+}
+
+label {
+ display: inline-block;
+ max-width: 100%;
+ margin-bottom: 5px;
+ font-weight: bold;
+}
+
+input[type="search"] {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+input[type="radio"],
+input[type="checkbox"] {
+ margin: 4px 0 0;
+ margin-top: 1px \9;
+ line-height: normal;
+}
+
+input[type="file"] {
+ display: block;
+}
+
+input[type="range"] {
+ display: block;
+ width: 100%;
+}
+
+select[multiple],
+select[size] {
+ height: auto;
+}
+
+input[type="file"]:focus,
+input[type="radio"]:focus,
+input[type="checkbox"]:focus {
+ outline: thin dotted;
+ outline: 5px auto -webkit-focus-ring-color;
+ outline-offset: -2px;
+}
+
+output {
+ display: block;
+ padding-top: 7px;
+ font-size: 14px;
+ line-height: 1.42857143;
+ color: #555;
+}
+
+.form-control {
+ display: block;
+ width: 100%;
+ height: 34px;
+ padding: 6px 12px;
+ font-size: 14px;
+ line-height: 1.42857143;
+ color: #000;
+ background-color: #fff;
+ background-image: none;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
+ -webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s;
+ -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
+ transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
+}
+
+.form-control:focus {
+ border-color: #66afe9;
+ outline: 0;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(102, 175, 233, .6);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(102, 175, 233, .6);
+}
+
+.form-control::-moz-placeholder {
+ color: #999;
+ opacity: 1;
+}
+
+.form-control:-ms-input-placeholder {
+ color: #999;
+}
+
+.form-control::-webkit-input-placeholder {
+ color: #999;
+}
+
+.form-control[disabled],
+.form-control[readonly],
+fieldset[disabled] .form-control {
+ cursor: not-allowed;
+ background-color: #eee;
+ opacity: 1;
+}
+
+textarea.form-control {
+ height: auto;
+}
+
+input[type="search"] {
+ -webkit-appearance: none;
+}
+
+input[type="date"],
+input[type="time"],
+input[type="datetime-local"],
+input[type="month"] {
+ line-height: 34px;
+ line-height: 1.42857143 \0;
+}
+
+input[type="date"].input-sm,
+input[type="time"].input-sm,
+input[type="datetime-local"].input-sm,
+input[type="month"].input-sm {
+ line-height: 30px;
+ line-height: 1.5 \0;
+}
+
+input[type="date"].input-lg,
+input[type="time"].input-lg,
+input[type="datetime-local"].input-lg,
+input[type="month"].input-lg {
+ line-height: 46px;
+ line-height: 1.33 \0;
+}
+
+_:-ms-fullscreen,
+:root input[type="date"],
+_:-ms-fullscreen,
+:root input[type="time"],
+_:-ms-fullscreen,
+:root input[type="datetime-local"],
+_:-ms-fullscreen,
+:root input[type="month"] {
+ line-height: 1.42857143;
+}
+
+_:-ms-fullscreen.input-sm,
+:root input[type="date"].input-sm,
+_:-ms-fullscreen.input-sm,
+:root input[type="time"].input-sm,
+_:-ms-fullscreen.input-sm,
+:root input[type="datetime-local"].input-sm,
+_:-ms-fullscreen.input-sm,
+:root input[type="month"].input-sm {
+ line-height: 1.5;
+}
+
+_:-ms-fullscreen.input-lg,
+:root input[type="date"].input-lg,
+_:-ms-fullscreen.input-lg,
+:root input[type="time"].input-lg,
+_:-ms-fullscreen.input-lg,
+:root input[type="datetime-local"].input-lg,
+_:-ms-fullscreen.input-lg,
+:root input[type="month"].input-lg {
+ line-height: 1.33;
+}
+
+.form-group {
+ margin-bottom: 15px;
+}
+
+.radio,
+.checkbox {
+ position: relative;
+ display: block;
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+
+.radio label,
+.checkbox label {
+ min-height: 20px;
+ padding-left: 20px;
+ margin-bottom: 0;
+ font-weight: normal;
+ cursor: pointer;
+}
+
+.radio input[type="radio"],
+.radio-inline input[type="radio"],
+.checkbox input[type="checkbox"],
+.checkbox-inline input[type="checkbox"] {
+ position: absolute;
+ margin-top: 4px \9;
+ margin-left: -20px;
+}
+
+.radio+.radio,
+.checkbox+.checkbox {
+ margin-top: -5px;
+}
+
+.radio-inline,
+.checkbox-inline {
+ display: inline-block;
+ padding-left: 20px;
+ margin-bottom: 0;
+ font-weight: normal;
+ vertical-align: middle;
+ cursor: pointer;
+}
+
+.radio-inline+.radio-inline,
+.checkbox-inline+.checkbox-inline {
+ margin-top: 0;
+ margin-left: 10px;
+}
+
+input[type="radio"][disabled],
+input[type="checkbox"][disabled],
+input[type="radio"].disabled,
+input[type="checkbox"].disabled,
+fieldset[disabled] input[type="radio"],
+fieldset[disabled] input[type="checkbox"] {
+ cursor: not-allowed;
+}
+
+.radio-inline.disabled,
+.checkbox-inline.disabled,
+fieldset[disabled] .radio-inline,
+fieldset[disabled] .checkbox-inline {
+ cursor: not-allowed;
+}
+
+.radio.disabled label,
+.checkbox.disabled label,
+fieldset[disabled] .radio label,
+fieldset[disabled] .checkbox label {
+ cursor: not-allowed;
+}
+
+.form-control-static {
+ padding-top: 7px;
+ padding-bottom: 7px;
+ margin-bottom: 0;
+}
+
+.form-control-static.input-lg,
+.form-control-static.input-sm {
+ padding-right: 0;
+ padding-left: 0;
+}
+
+.input-sm,
+.form-group-sm .form-control {
+ height: 30px;
+ padding: 5px 10px;
+ font-size: 12px;
+ line-height: 1.5;
+ border-radius: 3px;
+}
+
+select.input-sm,
+select.form-group-sm .form-control {
+ height: 30px;
+ line-height: 30px;
+}
+
+textarea.input-sm,
+textarea.form-group-sm .form-control,
+select[multiple].input-sm,
+select[multiple].form-group-sm .form-control {
+ height: auto;
+}
+
+.input-lg,
+.form-group-lg .form-control {
+ height: 46px;
+ padding: 10px 16px;
+ font-size: 18px;
+ line-height: 1.33;
+ border-radius: 6px;
+}
+
+select.input-lg,
+select.form-group-lg .form-control {
+ height: 46px;
+ line-height: 46px;
+}
+
+textarea.input-lg,
+textarea.form-group-lg .form-control,
+select[multiple].input-lg,
+select[multiple].form-group-lg .form-control {
+ height: auto;
+}
+
+.has-feedback {
+ position: relative;
+}
+
+.has-feedback .form-control {
+ padding-right: 42.5px;
+}
+
+.form-control-feedback {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 2;
+ display: block;
+ width: 34px;
+ height: 34px;
+ line-height: 34px;
+ text-align: center;
+ pointer-events: none;
+}
+
+.input-lg+.form-control-feedback {
+ width: 46px;
+ height: 46px;
+ line-height: 46px;
+}
+
+.input-sm+.form-control-feedback {
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+}
+
+.has-success .help-block,
+.has-success .control-label,
+.has-success .radio,
+.has-success .checkbox,
+.has-success .radio-inline,
+.has-success .checkbox-inline,
+.has-success.radio label,
+.has-success.checkbox label,
+.has-success.radio-inline label,
+.has-success.checkbox-inline label {
+ color: #3c763d;
+}
+
+.has-success .form-control {
+ border-color: #3c763d;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
+}
+
+.has-success .form-control:focus {
+ border-color: #2b542c;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168;
+}
+
+.has-success .input-group-addon {
+ color: #3c763d;
+ background-color: #dff0d8;
+ border-color: #3c763d;
+}
+
+.has-success .form-control-feedback {
+ color: #3c763d;
+}
+
+.has-warning .help-block,
+.has-warning .control-label,
+.has-warning .radio,
+.has-warning .checkbox,
+.has-warning .radio-inline,
+.has-warning .checkbox-inline,
+.has-warning.radio label,
+.has-warning.checkbox label,
+.has-warning.radio-inline label,
+.has-warning.checkbox-inline label {
+ color: #8a6d3b;
+}
+
+.has-warning .form-control {
+ border-color: #8a6d3b;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
+}
+
+.has-warning .form-control:focus {
+ border-color: #66512c;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b;
+}
+
+.has-warning .input-group-addon {
+ color: #8a6d3b;
+ background-color: #fcf8e3;
+ border-color: #8a6d3b;
+}
+
+.has-warning .form-control-feedback {
+ color: #8a6d3b;
+}
+
+.has-error .help-block,
+.has-error .control-label,
+.has-error .radio,
+.has-error .checkbox,
+.has-error .radio-inline,
+.has-error .checkbox-inline,
+.has-error.radio label,
+.has-error.checkbox label,
+.has-error.radio-inline label,
+.has-error.checkbox-inline label {
+ color: #a94442;
+}
+
+.has-error .form-control {
+ border-color: #a94442;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
+}
+
+.has-error .form-control:focus {
+ border-color: #843534;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483;
+}
+
+.has-error .input-group-addon {
+ color: #a94442;
+ background-color: #f2dede;
+ border-color: #a94442;
+}
+
+.has-error .form-control-feedback {
+ color: #a94442;
+}
+
+.has-feedback label~.form-control-feedback {
+ top: 25px;
+}
+
+.has-feedback label.sr-only~.form-control-feedback {
+ top: 0;
+}
+
+.help-block {
+ display: block;
+ margin-top: 5px;
+ margin-bottom: 10px;
+ color: #737373;
+}
+
+@media (min-width: 768px) {
+ .form-inline .form-group {
+ display: inline-block;
+ margin-bottom: 0;
+ vertical-align: middle;
+ }
+
+ .form-inline .form-control {
+ display: inline-block;
+ width: auto;
+ vertical-align: middle;
+ }
+
+ .form-inline .form-control-static {
+ display: inline-block;
+ }
+
+ .form-inline .input-group {
+ display: inline-table;
+ vertical-align: middle;
+ }
+
+ .form-inline .input-group .input-group-addon,
+ .form-inline .input-group .input-group-btn,
+ .form-inline .input-group .form-control {
+ width: auto;
+ }
+
+ .form-inline .input-group>.form-control {
+ width: 100%;
+ }
+
+ .form-inline .control-label {
+ margin-bottom: 0;
+ vertical-align: middle;
+ }
+
+ .form-inline .radio,
+ .form-inline .checkbox {
+ display: inline-block;
+ margin-top: 0;
+ margin-bottom: 0;
+ vertical-align: middle;
+ }
+
+ .form-inline .radio label,
+ .form-inline .checkbox label {
+ padding-left: 0;
+ }
+
+ .form-inline .radio input[type="radio"],
+ .form-inline .checkbox input[type="checkbox"] {
+ position: relative;
+ margin-left: 0;
+ }
+
+ .form-inline .has-feedback .form-control-feedback {
+ top: 0;
+ }
+}
+
+.form-horizontal .radio,
+.form-horizontal .checkbox,
+.form-horizontal .radio-inline,
+.form-horizontal .checkbox-inline {
+ padding-top: 7px;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+.form-horizontal .radio,
+.form-horizontal .checkbox {
+ min-height: 27px;
+}
+
+.form-horizontal .form-group {
+ margin-right: -15px;
+ margin-left: -15px;
+}
+
+.form-horizontal .control-label {
+ padding-top: 7px;
+ margin-bottom: 0;
+ text-align: right;
+}
+
+.form-horizontal .control-label.text-left{
+ text-align: left;
+}
+
+.form-horizontal .has-feedback .form-control-feedback {
+ right: 15px;
+}
+
+@media (min-width: 768px) {
+ .form-horizontal .form-group-lg .control-label {
+ padding-top: 14.3px;
+ }
+}
+
+@media (min-width: 768px) {
+ .form-horizontal .form-group-sm .control-label {
+ padding-top: 6px;
+ }
+}
+
+.btn {
+ display: inline-block;
+ padding: 6px 12px;
+ margin-bottom: 0;
+ font-size: 14px;
+ font-weight: normal;
+ line-height: 1.42857143;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: middle;
+ -ms-touch-action: manipulation;
+ touch-action: manipulation;
+ cursor: pointer;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ background-image: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+
+.btn:focus,
+.btn:active:focus,
+.btn.active:focus,
+.btn.focus,
+.btn:active.focus,
+.btn.active.focus {
+ outline: thin dotted;
+ outline: 5px auto -webkit-focus-ring-color;
+ outline-offset: -2px;
+}
+
+.btn:hover,
+.btn:focus,
+.btn.focus {
+ color: #333;
+ text-decoration: none;
+}
+
+.btn:active,
+.btn.active {
+ background-image: none;
+ outline: 0;
+ -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
+ box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
+}
+
+.btn.disabled,
+.btn[disabled],
+fieldset[disabled] .btn {
+ pointer-events: none;
+ cursor: not-allowed;
+ filter: alpha(opacity=65);
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ opacity: .65;
+}
+
+.btn-default {
+ color: #333;
+ background-color: #fff;
+ border-color: #ccc;
+}
+
+.btn-default:hover,
+.btn-default:focus,
+.btn-default.focus,
+.btn-default:active,
+.btn-default.active,
+.open>.dropdown-toggle.btn-default {
+ color: #333;
+ background-color: #e6e6e6;
+ border-color: #adadad;
+}
+
+.btn-default:active,
+.btn-default.active,
+.open>.dropdown-toggle.btn-default {
+ background-image: none;
+}
+
+.btn-default.disabled,
+.btn-default[disabled],
+fieldset[disabled] .btn-default,
+.btn-default.disabled:hover,
+.btn-default[disabled]:hover,
+fieldset[disabled] .btn-default:hover,
+.btn-default.disabled:focus,
+.btn-default[disabled]:focus,
+fieldset[disabled] .btn-default:focus,
+.btn-default.disabled.focus,
+.btn-default[disabled].focus,
+fieldset[disabled] .btn-default.focus,
+.btn-default.disabled:active,
+.btn-default[disabled]:active,
+fieldset[disabled] .btn-default:active,
+.btn-default.disabled.active,
+.btn-default[disabled].active,
+fieldset[disabled] .btn-default.active {
+ background-color: #fff;
+ border-color: #ccc;
+}
+
+.btn-default .badge {
+ color: #fff;
+ background-color: #333;
+}
+
+.btn-primary {
+ color: #fff;
+ background-color: #369;
+ border-color: #357ebd;
+}
+
+.btn-primary:hover,
+.btn-primary:focus,
+.btn-primary.focus,
+.btn-primary:active,
+.btn-primary.active,
+.open>.dropdown-toggle.btn-primary {
+ color: #fff;
+ background-color: #3071a9;
+ border-color: #285e8e;
+}
+
+.btn-primary:active,
+.btn-primary.active,
+.open>.dropdown-toggle.btn-primary {
+ background-image: none;
+}
+
+.btn-primary.disabled,
+.btn-primary[disabled],
+fieldset[disabled] .btn-primary,
+.btn-primary.disabled:hover,
+.btn-primary[disabled]:hover,
+fieldset[disabled] .btn-primary:hover,
+.btn-primary.disabled:focus,
+.btn-primary[disabled]:focus,
+fieldset[disabled] .btn-primary:focus,
+.btn-primary.disabled.focus,
+.btn-primary[disabled].focus,
+fieldset[disabled] .btn-primary.focus,
+.btn-primary.disabled:active,
+.btn-primary[disabled]:active,
+fieldset[disabled] .btn-primary:active,
+.btn-primary.disabled.active,
+.btn-primary[disabled].active,
+fieldset[disabled] .btn-primary.active {
+ background-color: #428bca;
+ border-color: #357ebd;
+}
+
+.btn-primary .badge {
+ color: #428bca;
+ background-color: #fff;
+}
+
+.btn-success {
+ color: #fff;
+ background-color: #5cb85c;
+ border-color: #4cae4c;
+}
+
+.btn-success:hover,
+.btn-success:focus,
+.btn-success.focus,
+.btn-success:active,
+.btn-success.active,
+.open>.dropdown-toggle.btn-success {
+ color: #fff;
+ background-color: #449d44;
+ border-color: #398439;
+}
+
+.btn-success:active,
+.btn-success.active,
+.open>.dropdown-toggle.btn-success {
+ background-image: none;
+}
+
+.btn-success.disabled,
+.btn-success[disabled],
+fieldset[disabled] .btn-success,
+.btn-success.disabled:hover,
+.btn-success[disabled]:hover,
+fieldset[disabled] .btn-success:hover,
+.btn-success.disabled:focus,
+.btn-success[disabled]:focus,
+fieldset[disabled] .btn-success:focus,
+.btn-success.disabled.focus,
+.btn-success[disabled].focus,
+fieldset[disabled] .btn-success.focus,
+.btn-success.disabled:active,
+.btn-success[disabled]:active,
+fieldset[disabled] .btn-success:active,
+.btn-success.disabled.active,
+.btn-success[disabled].active,
+fieldset[disabled] .btn-success.active {
+ background-color: #5cb85c;
+ border-color: #4cae4c;
+}
+
+.btn-success .badge {
+ color: #5cb85c;
+ background-color: #fff;
+}
+
+.btn-info {
+ color: #fff;
+ background-color: #5bc0de;
+ border-color: #46b8da;
+}
+
+.btn-info:hover,
+.btn-info:focus,
+.btn-info.focus,
+.btn-info:active,
+.btn-info.active,
+.open>.dropdown-toggle.btn-info {
+ color: #fff;
+ background-color: #31b0d5;
+ border-color: #269abc;
+}
+
+.btn-info:active,
+.btn-info.active,
+.open>.dropdown-toggle.btn-info {
+ background-image: none;
+}
+
+.btn-info.disabled,
+.btn-info[disabled],
+fieldset[disabled] .btn-info,
+.btn-info.disabled:hover,
+.btn-info[disabled]:hover,
+fieldset[disabled] .btn-info:hover,
+.btn-info.disabled:focus,
+.btn-info[disabled]:focus,
+fieldset[disabled] .btn-info:focus,
+.btn-info.disabled.focus,
+.btn-info[disabled].focus,
+fieldset[disabled] .btn-info.focus,
+.btn-info.disabled:active,
+.btn-info[disabled]:active,
+fieldset[disabled] .btn-info:active,
+.btn-info.disabled.active,
+.btn-info[disabled].active,
+fieldset[disabled] .btn-info.active {
+ background-color: #5bc0de;
+ border-color: #46b8da;
+}
+
+.btn-info .badge {
+ color: #5bc0de;
+ background-color: #fff;
+}
+
+.btn-warning {
+ color: #fff;
+ background-color: #f0ad4e;
+ border-color: #eea236;
+}
+
+.btn-warning:hover,
+.btn-warning:focus,
+.btn-warning.focus,
+.btn-warning:active,
+.btn-warning.active,
+.open>.dropdown-toggle.btn-warning {
+ color: #fff;
+ background-color: #ec971f;
+ border-color: #d58512;
+}
+
+.btn-warning:active,
+.btn-warning.active,
+.open>.dropdown-toggle.btn-warning {
+ background-image: none;
+}
+
+.btn-warning.disabled,
+.btn-warning[disabled],
+fieldset[disabled] .btn-warning,
+.btn-warning.disabled:hover,
+.btn-warning[disabled]:hover,
+fieldset[disabled] .btn-warning:hover,
+.btn-warning.disabled:focus,
+.btn-warning[disabled]:focus,
+fieldset[disabled] .btn-warning:focus,
+.btn-warning.disabled.focus,
+.btn-warning[disabled].focus,
+fieldset[disabled] .btn-warning.focus,
+.btn-warning.disabled:active,
+.btn-warning[disabled]:active,
+fieldset[disabled] .btn-warning:active,
+.btn-warning.disabled.active,
+.btn-warning[disabled].active,
+fieldset[disabled] .btn-warning.active {
+ background-color: #f0ad4e;
+ border-color: #eea236;
+}
+
+.btn-warning .badge {
+ color: #f0ad4e;
+ background-color: #fff;
+}
+
+.btn-danger {
+ color: #fff;
+ background-color: #d9534f;
+ border-color: #d43f3a;
+}
+
+.btn-danger:hover,
+.btn-danger:focus,
+.btn-danger.focus,
+.btn-danger:active,
+.btn-danger.active,
+.open>.dropdown-toggle.btn-danger {
+ color: #fff;
+ background-color: #c9302c;
+ border-color: #ac2925;
+}
+
+.btn-danger:active,
+.btn-danger.active,
+.open>.dropdown-toggle.btn-danger {
+ background-image: none;
+}
+
+.btn-danger.disabled,
+.btn-danger[disabled],
+fieldset[disabled] .btn-danger,
+.btn-danger.disabled:hover,
+.btn-danger[disabled]:hover,
+fieldset[disabled] .btn-danger:hover,
+.btn-danger.disabled:focus,
+.btn-danger[disabled]:focus,
+fieldset[disabled] .btn-danger:focus,
+.btn-danger.disabled.focus,
+.btn-danger[disabled].focus,
+fieldset[disabled] .btn-danger.focus,
+.btn-danger.disabled:active,
+.btn-danger[disabled]:active,
+fieldset[disabled] .btn-danger:active,
+.btn-danger.disabled.active,
+.btn-danger[disabled].active,
+fieldset[disabled] .btn-danger.active {
+ background-color: #d9534f;
+ border-color: #d43f3a;
+}
+
+.btn-danger .badge {
+ color: #d9534f;
+ background-color: #fff;
+}
+
+.btn-link {
+ font-weight: normal;
+ color: #428bca;
+ border-radius: 0;
+}
+
+.btn-link,
+.btn-link:active,
+.btn-link.active,
+.btn-link[disabled],
+fieldset[disabled] .btn-link {
+ background-color: transparent;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+}
+
+.btn-link,
+.btn-link:hover,
+.btn-link:focus,
+.btn-link:active {
+ border-color: transparent;
+}
+
+.btn-link:hover,
+.btn-link:focus {
+ color: #2a6496;
+ text-decoration: underline;
+ background-color: transparent;
+}
+
+.btn-link[disabled]:hover,
+fieldset[disabled] .btn-link:hover,
+.btn-link[disabled]:focus,
+fieldset[disabled] .btn-link:focus {
+ color: #777;
+ text-decoration: none;
+}
+
+.btn-lg,
+.btn-group-lg>.btn {
+ padding: 10px 16px;
+ font-size: 18px;
+ line-height: 1.33;
+ border-radius: 6px;
+}
+
+.btn-sm,
+.btn-group-sm>.btn {
+ padding: 5px 10px;
+ font-size: 12px;
+ line-height: 1.5;
+ border-radius: 3px;
+}
+
+.btn-xs,
+.btn-group-xs>.btn {
+ padding: 1px 5px;
+ font-size: 12px;
+ line-height: 1.5;
+ border-radius: 3px;
+}
+
+.btn-block {
+ display: block;
+ width: 100%;
+}
+
+.btn-block+.btn-block {
+ margin-top: 5px;
+}
+
+input[type="submit"].btn-block,
+input[type="reset"].btn-block,
+input[type="button"].btn-block {
+ width: 100%;
+}
+
+.fade {
+ opacity: 0;
+ -webkit-transition: opacity .15s linear;
+ -o-transition: opacity .15s linear;
+ transition: opacity .15s linear;
+}
+
+.fade.in {
+ opacity: 1;
+}
+
+.collapse {
+ display: none;
+ visibility: hidden;
+}
+
+.collapse.in {
+ display: block;
+ visibility: visible;
+}
+
+tr.collapse.in {
+ display: table-row;
+}
+
+tbody.collapse.in {
+ display: table-row-group;
+}
+
+.collapsing {
+ position: relative;
+ height: 0;
+ overflow: hidden;
+ -webkit-transition-timing-function: ease;
+ -o-transition-timing-function: ease;
+ transition-timing-function: ease;
+ -webkit-transition-duration: .35s;
+ -o-transition-duration: .35s;
+ transition-duration: .35s;
+ -webkit-transition-property: height, visibility;
+ -o-transition-property: height, visibility;
+ transition-property: height, visibility;
+}
+
+.caret {
+ display: inline-block;
+ width: 0;
+ height: 0;
+ margin-left: 2px;
+ vertical-align: middle;
+ border-top: 4px solid;
+ border-right: 4px solid transparent;
+ border-left: 4px solid transparent;
+}
+
+.dropdown {
+ position: relative;
+}
+
+.dropdown-toggle:focus {
+ outline: 0;
+}
+
+.dropdown-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 1000;
+ display: none;
+ float: left;
+ min-width: 160px;
+ padding: 5px 0;
+ margin: 2px 0 0;
+ font-size: 14px;
+ text-align: left;
+ list-style: none;
+ background-color: #fff;
+ -webkit-background-clip: padding-box;
+ background-clip: padding-box;
+ border: 1px solid #ccc;
+ border: 1px solid rgba(0, 0, 0, .15);
+ border-radius: 4px;
+ -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
+ box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
+}
+
+.dropdown-menu.pull-right {
+ right: 0;
+ left: auto;
+}
+
+.dropdown-menu .divider {
+ height: 1px;
+ margin: 9px 0;
+ overflow: hidden;
+ background-color: #e5e5e5;
+}
+
+.dropdown-menu>li>a {
+ display: block;
+ padding: 3px 20px;
+ clear: both;
+ font-weight: normal;
+ line-height: 1.42857143;
+ color: #333;
+ white-space: nowrap;
+}
+
+.dropdown-menu>li>a:hover,
+.dropdown-menu>li>a:focus {
+ color: #262626;
+ text-decoration: none;
+ background-color: #f5f5f5;
+}
+
+.dropdown-menu>.active>a,
+.dropdown-menu>.active>a:hover,
+.dropdown-menu>.active>a:focus {
+ color: #fff;
+ text-decoration: none;
+ background-color: #428bca;
+ outline: 0;
+}
+
+.dropdown-menu>.disabled>a,
+.dropdown-menu>.disabled>a:hover,
+.dropdown-menu>.disabled>a:focus {
+ color: #777;
+}
+
+.dropdown-menu>.disabled>a:hover,
+.dropdown-menu>.disabled>a:focus {
+ text-decoration: none;
+ cursor: not-allowed;
+ background-color: transparent;
+ background-image: none;
+ filter: progid:DXImageTransform.Microsoft.gradient(enabled=false);
+}
+
+.open>.dropdown-menu {
+ display: block;
+}
+
+.open>a {
+ outline: 0;
+}
+
+.dropdown-menu-right {
+ right: 0;
+ left: auto;
+}
+
+.dropdown-menu-left {
+ right: auto;
+ left: 0;
+}
+
+.dropdown-header {
+ display: block;
+ padding: 3px 20px;
+ font-size: 12px;
+ line-height: 1.42857143;
+ color: #777;
+ white-space: nowrap;
+}
+
+.dropdown-backdrop {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 990;
+}
+
+.pull-right>.dropdown-menu {
+ right: 0;
+ left: auto;
+}
+
+.dropup .caret,
+.navbar-fixed-bottom .dropdown .caret {
+ content: "";
+ border-top: 0;
+ border-bottom: 4px solid;
+}
+
+.dropup .dropdown-menu,
+.navbar-fixed-bottom .dropdown .dropdown-menu {
+ top: auto;
+ bottom: 100%;
+ margin-bottom: 1px;
+}
+
+@media (min-width: 768px) {
+ .navbar-right .dropdown-menu {
+ right: 0;
+ left: auto;
+ }
+
+ .navbar-right .dropdown-menu-left {
+ right: auto;
+ left: 0;
+ }
+}
+
+.btn-group,
+.btn-group-vertical {
+ position: relative;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.btn-group>.btn,
+.btn-group-vertical>.btn {
+ position: relative;
+ float: left;
+}
+
+.btn-group>.btn:hover,
+.btn-group-vertical>.btn:hover,
+.btn-group>.btn:focus,
+.btn-group-vertical>.btn:focus,
+.btn-group>.btn:active,
+.btn-group-vertical>.btn:active,
+.btn-group>.btn.active,
+.btn-group-vertical>.btn.active {
+ z-index: 2;
+}
+
+.btn-group>.btn:focus,
+.btn-group-vertical>.btn:focus {
+ outline: 0;
+}
+
+.btn-group .btn+.btn,
+.btn-group .btn+.btn-group,
+.btn-group .btn-group+.btn,
+.btn-group .btn-group+.btn-group {
+ margin-left: -1px;
+}
+
+.btn-toolbar {
+ margin-left: -5px;
+}
+
+.btn-toolbar .btn-group,
+.btn-toolbar .input-group {
+ float: left;
+}
+
+.btn-toolbar>.btn,
+.btn-toolbar>.btn-group,
+.btn-toolbar>.input-group {
+ margin-left: 5px;
+}
+
+.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {
+ border-radius: 0;
+}
+
+.btn-group>.btn:first-child {
+ margin-left: 0;
+}
+
+.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.btn-group>.btn:last-child:not(:first-child),
+.btn-group>.dropdown-toggle:not(:first-child) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.btn-group>.btn-group {
+ float: left;
+}
+
+.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn {
+ border-radius: 0;
+}
+
+.btn-group>.btn-group:first-child>.btn:last-child,
+.btn-group>.btn-group:first-child>.dropdown-toggle {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.btn-group>.btn-group:last-child>.btn:first-child {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.btn-group .dropdown-toggle:active,
+.btn-group.open .dropdown-toggle {
+ outline: 0;
+}
+
+.btn-group>.btn+.dropdown-toggle {
+ padding-right: 8px;
+ padding-left: 8px;
+}
+
+.btn-group>.btn-lg+.dropdown-toggle {
+ padding-right: 12px;
+ padding-left: 12px;
+}
+
+.btn-group.open .dropdown-toggle {
+ -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
+ box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
+}
+
+.btn-group.open .dropdown-toggle.btn-link {
+ -webkit-box-shadow: none;
+ box-shadow: none;
+}
+
+.btn .caret {
+ margin-left: 0;
+}
+
+.btn-lg .caret {
+ border-width: 5px 5px 0;
+ border-bottom-width: 0;
+}
+
+.dropup .btn-lg .caret {
+ border-width: 0 5px 5px;
+}
+
+.btn-group-vertical>.btn,
+.btn-group-vertical>.btn-group,
+.btn-group-vertical>.btn-group>.btn {
+ display: block;
+ float: none;
+ width: 100%;
+ max-width: 100%;
+}
+
+.btn-group-vertical>.btn-group>.btn {
+ float: none;
+}
+
+.btn-group-vertical>.btn+.btn,
+.btn-group-vertical>.btn+.btn-group,
+.btn-group-vertical>.btn-group+.btn,
+.btn-group-vertical>.btn-group+.btn-group {
+ margin-top: -1px;
+ margin-left: 0;
+}
+
+.btn-group-vertical>.btn:not(:first-child):not(:last-child) {
+ border-radius: 0;
+}
+
+.btn-group-vertical>.btn:first-child:not(:last-child) {
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.btn-group-vertical>.btn:last-child:not(:first-child) {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ border-bottom-left-radius: 4px;
+}
+
+.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn {
+ border-radius: 0;
+}
+
+.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,
+.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle {
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
+.btn-group-justified {
+ display: table;
+ width: 100%;
+ table-layout: fixed;
+ border-collapse: separate;
+}
+
+.btn-group-justified>.btn,
+.btn-group-justified>.btn-group {
+ display: table-cell;
+ float: none;
+ width: 1%;
+}
+
+.btn-group-justified>.btn-group .btn {
+ width: 100%;
+}
+
+.btn-group-justified>.btn-group .dropdown-menu {
+ left: auto;
+}
+
+[data-toggle="buttons"]>.btn input[type="radio"],
+[data-toggle="buttons"]>.btn-group>.btn input[type="radio"],
+[data-toggle="buttons"]>.btn input[type="checkbox"],
+[data-toggle="buttons"]>.btn-group>.btn input[type="checkbox"] {
+ position: absolute;
+ clip: rect(0, 0, 0, 0);
+ pointer-events: none;
+}
+
+.input-group {
+ position: relative;
+ display: table;
+ border-collapse: separate;
+}
+
+.input-group[class*="col-"] {
+ float: none;
+ padding-right: 0;
+ padding-left: 0;
+}
+
+.input-group .form-control {
+ position: relative;
+ z-index: 2;
+ float: left;
+ width: 100%;
+ margin-bottom: 0;
+}
+
+.input-group-lg>.form-control,
+.input-group-lg>.input-group-addon,
+.input-group-lg>.input-group-btn>.btn {
+ height: 46px;
+ padding: 10px 16px;
+ font-size: 18px;
+ line-height: 1.33;
+ border-radius: 6px;
+}
+
+select.input-group-lg>.form-control,
+select.input-group-lg>.input-group-addon,
+select.input-group-lg>.input-group-btn>.btn {
+ height: 46px;
+ line-height: 46px;
+}
+
+textarea.input-group-lg>.form-control,
+textarea.input-group-lg>.input-group-addon,
+textarea.input-group-lg>.input-group-btn>.btn,
+select[multiple].input-group-lg>.form-control,
+select[multiple].input-group-lg>.input-group-addon,
+select[multiple].input-group-lg>.input-group-btn>.btn {
+ height: auto;
+}
+
+.input-group-sm>.form-control,
+.input-group-sm>.input-group-addon,
+.input-group-sm>.input-group-btn>.btn {
+ height: 30px;
+ padding: 5px 10px;
+ font-size: 12px;
+ line-height: 1.5;
+ border-radius: 3px;
+}
+
+select.input-group-sm>.form-control,
+select.input-group-sm>.input-group-addon,
+select.input-group-sm>.input-group-btn>.btn {
+ height: 30px;
+ line-height: 30px;
+}
+
+textarea.input-group-sm>.form-control,
+textarea.input-group-sm>.input-group-addon,
+textarea.input-group-sm>.input-group-btn>.btn,
+select[multiple].input-group-sm>.form-control,
+select[multiple].input-group-sm>.input-group-addon,
+select[multiple].input-group-sm>.input-group-btn>.btn {
+ height: auto;
+}
+
+.input-group-addon,
+.input-group-btn,
+.input-group .form-control {
+ display: table-cell;
+}
+
+.input-group-addon:not(:first-child):not(:last-child),
+.input-group-btn:not(:first-child):not(:last-child),
+.input-group .form-control:not(:first-child):not(:last-child) {
+ border-radius: 0;
+}
+
+.input-group-addon,
+.input-group-btn {
+ width: 1%;
+ white-space: nowrap;
+ vertical-align: middle;
+}
+
+.input-group-addon {
+ padding: 6px 12px;
+ font-size: 14px;
+ font-weight: normal;
+ line-height: 1;
+ color: #555;
+ text-align: center;
+ background-color: #eee;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+}
+
+.input-group-addon.input-sm {
+ padding: 5px 10px;
+ font-size: 12px;
+ border-radius: 3px;
+}
+
+.input-group-addon.input-lg {
+ padding: 10px 16px;
+ font-size: 18px;
+ border-radius: 6px;
+}
+
+.input-group-addon input[type="radio"],
+.input-group-addon input[type="checkbox"] {
+ margin-top: 0;
+}
+
+.input-group .form-control:first-child,
+.input-group-addon:first-child,
+.input-group-btn:first-child>.btn,
+.input-group-btn:first-child>.btn-group>.btn,
+.input-group-btn:first-child>.dropdown-toggle,
+.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle),
+.input-group-btn:last-child>.btn-group:not(:last-child)>.btn {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.input-group-addon:first-child {
+ border-right: 0;
+}
+
+.input-group .form-control:last-child,
+.input-group-addon:last-child,
+.input-group-btn:last-child>.btn,
+.input-group-btn:last-child>.btn-group>.btn,
+.input-group-btn:last-child>.dropdown-toggle,
+.input-group-btn:first-child>.btn:not(:first-child),
+.input-group-btn:first-child>.btn-group:not(:first-child)>.btn {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.input-group-addon:last-child {
+ border-left: 0;
+}
+
+.input-group-btn {
+ position: relative;
+ font-size: 0;
+ white-space: nowrap;
+}
+
+.input-group-btn>.btn {
+ position: relative;
+}
+
+.input-group-btn>.btn+.btn {
+ margin-left: -1px;
+}
+
+.input-group-btn>.btn:hover,
+.input-group-btn>.btn:focus,
+.input-group-btn>.btn:active {
+ z-index: 2;
+}
+
+.input-group-btn:first-child>.btn,
+.input-group-btn:first-child>.btn-group {
+ margin-right: -1px;
+}
+
+.input-group-btn:last-child>.btn,
+.input-group-btn:last-child>.btn-group {
+ margin-left: -1px;
+}
+
+.nav {
+ padding-left: 0;
+ margin-bottom: 0;
+ list-style: none;
+}
+
+.nav>li {
+ margin-right: 10px;
+ position: relative;
+ display: block;
+}
+
+.nav>li>a {
+ position: relative;
+ display: block;
+ padding: 10px 15px;
+}
+
+.nav>li>a:hover,
+.nav>li>a:focus {
+ text-decoration: none;
+ background-color: #eee;
+}
+
+.nav>li.disabled>a {
+ color: #777;
+}
+
+.nav>li.disabled>a:hover,
+.nav>li.disabled>a:focus {
+ color: #777;
+ text-decoration: none;
+ cursor: not-allowed;
+ background-color: transparent;
+}
+
+.nav .open>a,
+.nav .open>a:hover,
+.nav .open>a:focus {
+ background-color: #eee;
+ border-color: #428bca;
+}
+
+.nav .nav-divider {
+ height: 1px;
+ margin: 9px 0;
+ overflow: hidden;
+ background-color: #e5e5e5;
+}
+
+.nav>li>a>img {
+ max-width: none;
+}
+
+.nav-tabs {
+ border-bottom: 1px solid #ddd;
+}
+
+.nav-tabs>li {
+ float: left;
+ margin-bottom: -1px;
+}
+
+.nav-tabs>li>a {
+ margin-right: 2px;
+ line-height: 1.42857143;
+ border: 1px solid transparent;
+ border-radius: 4px 4px 0 0;
+}
+
+.nav-tabs>li>a:hover {
+ border-color: #eee #eee #ddd;
+}
+
+.nav-tabs>li.active>a,
+.nav-tabs>li.active>a:hover,
+.nav-tabs>li.active>a:focus {
+ color: #555;
+ cursor: default;
+ background-color: #fff;
+ border: 1px solid #ddd;
+ border-bottom-color: transparent;
+}
+
+.nav-tabs.nav-justified {
+ width: 100%;
+ border-bottom: 0;
+}
+
+.nav-tabs.nav-justified>li {
+ float: none;
+}
+
+.nav-tabs.nav-justified>li>a {
+ margin-bottom: 5px;
+ text-align: center;
+}
+
+.nav-tabs.nav-justified>.dropdown .dropdown-menu {
+ top: auto;
+ left: auto;
+}
+
+.nav-tabs.nav-justified>li {
+ display: table-cell;
+ width: 1%;
+}
+
+.nav-tabs.nav-justified>li>a {
+ margin-bottom: 0;
+}
+
+.nav-tabs.nav-justified>li>a {
+ margin-right: 0;
+ border-radius: 4px;
+}
+
+.nav-tabs.nav-justified>.active>a,
+.nav-tabs.nav-justified>.active>a:hover,
+.nav-tabs.nav-justified>.active>a:focus {
+ border: 1px solid #ddd;
+}
+
+.nav-tabs.nav-justified>li>a {
+ border-bottom: 1px solid #ddd;
+ border-radius: 4px 4px 0 0;
+}
+
+.nav-tabs.nav-justified>.active>a,
+.nav-tabs.nav-justified>.active>a:hover,
+.nav-tabs.nav-justified>.active>a:focus {
+ border-bottom-color: #fff;
+}
+
+.nav-pills>li {
+ float: left;
+}
+
+.nav-pills>li>a {
+ border-radius: 4px;
+}
+
+.nav-pills>li+li {
+ margin-left: 2px;
+}
+
+.nav-pills>li.active>a,
+.nav-pills>li.active>a:hover,
+.nav-pills>li.active>a:focus {
+ color: #fff;
+ background-color: #3071a9;
+ /* Tab cell background color */
+}
+
+.nav-stacked>li {
+ float: none;
+}
+
+.nav-stacked>li+li {
+ margin-top: 2px;
+ margin-left: 0;
+}
+
+.nav-justified {
+ width: 100%;
+}
+
+.nav-justified>li {
+ float: none;
+}
+
+.nav-justified>li>a {
+ margin-bottom: 5px;
+ text-align: center;
+}
+
+.nav-justified>.dropdown .dropdown-menu {
+ top: auto;
+ left: auto;
+}
+
+.nav-justified>li {
+ display: table-cell;
+ width: 1%;
+}
+
+.nav-justified>li>a {
+ margin-bottom: 0;
+}
+
+.nav-tabs-justified {
+ border-bottom: 0;
+}
+
+.nav-tabs-justified>li>a {
+ margin-right: 0;
+ border-radius: 4px;
+}
+
+.nav-tabs-justified>.active>a,
+.nav-tabs-justified>.active>a:hover,
+.nav-tabs-justified>.active>a:focus {
+ border: 1px solid #ddd;
+}
+
+.nav-tabs-justified>li>a {
+ border-bottom: 1px solid #ddd;
+ border-radius: 4px 4px 0 0;
+}
+
+.nav-tabs-justified>.active>a,
+.nav-tabs-justified>.active>a:hover,
+.nav-tabs-justified>.active>a:focus {
+ border-bottom-color: #fff;
+}
+
+.tab-content>.tab-pane {
+ display: none;
+ visibility: hidden;
+}
+
+.tab-content>.active {
+ display: block;
+ visibility: visible;
+}
+
+.nav-tabs .dropdown-menu {
+ margin-top: -1px;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
+.navbar {
+ position: relative;
+ min-height: 30px;
+ border: 1px solid transparent;
+}
+
+/*
+@media (min-width: 768px) {
+ .navbar {
+ border-radius: 4px;
+ }
+}
+@media (min-width: 768px) {
+ .navbar-header {
+ float: left;
+ }
+}
+*/
+
+.navbar-collapse {
+ padding-right: 15px;
+ padding-left: 15px;
+ overflow-x: visible;
+ -webkit-overflow-scrolling: touch;
+ border-top: 1px solid transparent;
+ -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1);
+}
+
+.navbar-collapse.in {
+ overflow-y: auto;
+}
+
+/*
+@media (min-width: 768px) {
+ .navbar-collapse {
+ width: auto;
+ border-top: 0;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ }
+ .navbar-collapse.collapse {
+ display: block !important;
+ height: auto !important;
+ padding-bottom: 0;
+ overflow: visible !important;
+ visibility: visible !important;
+ }
+ .navbar-collapse.in {
+ overflow-y: visible;
+ }
+ .navbar-fixed-top .navbar-collapse,
+ .navbar-static-top .navbar-collapse,
+ .navbar-fixed-bottom .navbar-collapse {
+ padding-right: 0;
+ padding-left: 0;
+ }
+}
+*/
+
+.navbar-fixed-top .navbar-collapse,
+.navbar-fixed-bottom .navbar-collapse {
+ max-height: 340px;
+}
+
+@media (max-device-width: 480px) and (orientation: landscape) {
+
+ .navbar-fixed-top .navbar-collapse,
+ .navbar-fixed-bottom .navbar-collapse {
+ max-height: 200px;
+ }
+}
+
+.container>.navbar-header,
+.container-fluid>.navbar-header,
+.container>.navbar-collapse,
+.container-fluid>.navbar-collapse {
+ margin-right: -15px;
+ margin-left: -15px;
+}
+
+@media (min-width: 768px) {
+
+ .container>.navbar-header,
+ .container-fluid>.navbar-header,
+ .container>.navbar-collapse,
+ .container-fluid>.navbar-collapse {
+ margin-right: 0;
+ margin-left: 0;
+ }
+}
+
+.navbar-static-top {
+ z-index: 1000;
+ border-width: 0 0 1px;
+}
+
+@media (min-width: 768px) {
+ .navbar-static-top {
+ border-radius: 0;
+ }
+}
+
+.navbar-fixed-top,
+.navbar-fixed-bottom {
+ position: fixed;
+ right: 0;
+ left: 0;
+ z-index: 1030;
+}
+
+@media (min-width: 768px) {
+
+ .navbar-fixed-top,
+ .navbar-fixed-bottom {
+ border-radius: 0;
+ }
+}
+
+.navbar-fixed-top {
+ top: 0;
+ border-width: 0 0 1px;
+}
+
+.navbar-fixed-bottom {
+ bottom: 0;
+ margin-bottom: 0;
+ border-width: 1px 0 0;
+}
+
+.navbar-brand {
+ float: left;
+ height: 30px;
+ padding: 6px 15px;
+ font-size: 15px;
+ line-height: 18px;
+}
+
+.navbar-brand:hover,
+.navbar-brand:focus {
+ text-decoration: none;
+}
+
+.navbar-brand>img {
+ display: block;
+}
+
+@media (min-width: 768px) {
+
+ .navbar>.container .navbar-brand,
+ .navbar>.container-fluid .navbar-brand {
+ margin-left: -15px;
+ }
+}
+
+.navbar-toggle {
+ position: relative;
+ float: right;
+ padding: 9px 10px;
+ margin-top: 8px;
+ margin-right: 15px;
+ margin-bottom: 8px;
+ background-color: transparent;
+ background-image: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+
+.navbar-toggle:focus {
+ outline: 0;
+}
+
+.navbar-toggle .icon-bar {
+ display: block;
+ width: 22px;
+ height: 2px;
+ border-radius: 1px;
+}
+
+.navbar-toggle .icon-bar+.icon-bar {
+ margin-top: 4px;
+}
+
+@media (min-width: 768px) {
+ .navbar-toggle {
+ display: none;
+ }
+}
+
+.navbar-nav {
+ margin: 7.5px -15px;
+}
+
+.navbar-nav>li>a {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ line-height: 20px;
+}
+
+.navbar-nav>li,
+.navbar-nav {
+ float: left !important;
+}
+
+.navbar-nav.navbar-right:last-child {
+ margin-right: -15px !important;
+}
+
+.navbar-right {
+ float: right !important;
+}
+
+/*
+@media (max-width: 767px) {
+ .navbar-nav .open .dropdown-menu {
+ position: static;
+ float: none;
+ width: auto;
+ margin-top: 0;
+ background-color: transparent;
+ border: 0;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ }
+ .navbar-nav .open .dropdown-menu > li > a,
+ .navbar-nav .open .dropdown-menu .dropdown-header {
+ padding: 5px 15px 5px 25px;
+ }
+ .navbar-nav .open .dropdown-menu > li > a {
+ line-height: 20px;
+ }
+ .navbar-nav .open .dropdown-menu > li > a:hover,
+ .navbar-nav .open .dropdown-menu > li > a:focus {
+ background-image: none;
+ }
+}
+
+ .navbar-nav {
+ float: left;
+ margin: 0;
+ }
+ .navbar-nav > li {
+ float: left;
+ }
+ .navbar-nav > li > a {
+ padding-top: 15px;
+ padding-bottom: 15px;
+ }
+
+.navbar-form {
+ padding: 10px 15px;
+ margin-top: 8px;
+ margin-right: -15px;
+ margin-bottom: 8px;
+ margin-left: -15px;
+ border-top: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+ -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1);
+}
+@media (min-width: 768px) {
+ .navbar-form .form-group {
+ display: inline-block;
+ margin-bottom: 0;
+ vertical-align: middle;
+ }
+ .navbar-form .form-control {
+ display: inline-block;
+ width: auto;
+ vertical-align: middle;
+ }
+ .navbar-form .form-control-static {
+ display: inline-block;
+ }
+ .navbar-form .input-group {
+ display: inline-table;
+ vertical-align: middle;
+ }
+ .navbar-form .input-group .input-group-addon,
+ .navbar-form .input-group .input-group-btn,
+ .navbar-form .input-group .form-control {
+ width: auto;
+ }
+ .navbar-form .input-group > .form-control {
+ width: 100%;
+ }
+ .navbar-form .control-label {
+ margin-bottom: 0;
+ vertical-align: middle;
+ }
+ .navbar-form .radio,
+ .navbar-form .checkbox {
+ display: inline-block;
+ margin-top: 0;
+ margin-bottom: 0;
+ vertical-align: middle;
+ }
+ .navbar-form .radio label,
+ .navbar-form .checkbox label {
+ padding-left: 0;
+ }
+ .navbar-form .radio input[type="radio"],
+ .navbar-form .checkbox input[type="checkbox"] {
+ position: relative;
+ margin-left: 0;
+ }
+ .navbar-form .has-feedback .form-control-feedback {
+ top: 0;
+ }
+}
+@media (max-width: 767px) {
+ .navbar-form .form-group {
+ margin-bottom: 5px;
+ }
+ .navbar-form .form-group:last-child {
+ margin-bottom: 0;
+ }
+}
+@media (min-width: 768px) {
+ .navbar-form {
+ width: auto;
+ padding-top: 0;
+ padding-bottom: 0;
+ margin-right: 0;
+ margin-left: 0;
+ border: 0;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ }
+}
+*/
+
+.navbar-nav>li>.dropdown-menu {
+ margin-top: 0;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
+.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu {
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.navbar-btn {
+ margin-top: 8px;
+ margin-bottom: 8px;
+}
+
+.navbar-btn.btn-sm {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+
+.navbar-btn.btn-xs {
+ margin-top: 14px;
+ margin-bottom: 14px;
+}
+
+.navbar-text {
+ margin-top: 15px;
+ margin-bottom: 15px;
+}
+
+.navbar-text {
+ float: left;
+ margin-right: 15px;
+ margin-left: 15px;
+}
+
+
+.navbar-left {
+ float: left !important;
+}
+
+.navbar-right {
+ float: right !important;
+ margin-right: -15px;
+}
+
+.navbar-right~.navbar-right {
+ margin-right: 0;
+}
+
+.navbar-default {
+ background-color: #f8f8f8;
+ border-color: #e7e7e7;
+}
+
+.navbar-default .navbar-brand {
+ color: #777;
+}
+
+.navbar-default .navbar-brand:hover,
+.navbar-default .navbar-brand:focus {
+ color: #5e5e5e;
+ background-color: transparent;
+}
+
+.navbar-default .navbar-text {
+ color: #777;
+}
+
+.navbar-default .navbar-nav>li>a {
+ color: #777;
+}
+
+.navbar-default .navbar-nav>li>a:hover,
+.navbar-default .navbar-nav>li>a:focus {
+ color: #333;
+ background-color: transparent;
+}
+
+.navbar-default .navbar-nav>.active>a,
+.navbar-default .navbar-nav>.active>a:hover,
+.navbar-default .navbar-nav>.active>a:focus {
+ color: #555;
+ background-color: #e7e7e7;
+}
+
+.navbar-default .navbar-nav>.disabled>a,
+.navbar-default .navbar-nav>.disabled>a:hover,
+.navbar-default .navbar-nav>.disabled>a:focus {
+ color: #ccc;
+ background-color: transparent;
+}
+
+.navbar-default .navbar-toggle {
+ border-color: #ddd;
+}
+
+.navbar-default .navbar-toggle:hover,
+.navbar-default .navbar-toggle:focus {
+ background-color: #ddd;
+}
+
+.navbar-default .navbar-toggle .icon-bar {
+ background-color: #888;
+}
+
+.navbar-default .navbar-collapse,
+.navbar-default .navbar-form {
+ border-color: #e7e7e7;
+}
+
+.navbar-default .navbar-nav>.open>a,
+.navbar-default .navbar-nav>.open>a:hover,
+.navbar-default .navbar-nav>.open>a:focus {
+ color: #555;
+ background-color: #e7e7e7;
+}
+
+@media (max-width: 767px) {
+ .navbar-default .navbar-nav .open .dropdown-menu>li>a {
+ color: #777;
+ }
+
+ .navbar-default .navbar-nav .open .dropdown-menu>li>a:hover,
+ .navbar-default .navbar-nav .open .dropdown-menu>li>a:focus {
+ color: #333;
+ background-color: transparent;
+ }
+
+ .navbar-default .navbar-nav .open .dropdown-menu>.active>a,
+ .navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover,
+ .navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus {
+ color: #555;
+ background-color: #e7e7e7;
+ }
+
+ .navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,
+ .navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover,
+ .navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus {
+ color: #ccc;
+ background-color: transparent;
+ }
+}
+
+.navbar-default .navbar-link {
+ color: #777;
+}
+
+.navbar-default .navbar-link:hover {
+ color: #333;
+}
+
+.navbar-default .btn-link {
+ color: #777;
+}
+
+.navbar-default .btn-link:hover,
+.navbar-default .btn-link:focus {
+ color: #333;
+}
+
+.navbar-default .btn-link[disabled]:hover,
+fieldset[disabled] .navbar-default .btn-link:hover,
+.navbar-default .btn-link[disabled]:focus,
+fieldset[disabled] .navbar-default .btn-link:focus {
+ color: #ccc;
+}
+
+.navbar-inverse {
+ background-color: #336699;
+ border-color: #080808;
+}
+
+.navbar-inverse .navbar-brand {
+ color: #ffffff;
+}
+
+.navbar-inverse .navbar-brand:hover,
+.navbar-inverse .navbar-brand:focus {
+ color: #ffffff;
+ background-color: transparent;
+}
+
+.navbar-inverse .navbar-text {
+ color: #ffffff;
+}
+
+.navbar-inverse .navbar-nav>li>a {
+ color: #ffffff;
+}
+
+.navbar-inverse .navbar-nav>li>a:hover,
+.navbar-inverse .navbar-nav>li>a:focus {
+ color: #ffffff;
+ background-color: transparent;
+}
+
+.navbar-inverse .navbar-nav>.active>a,
+.navbar-inverse .navbar-nav>.active>a:hover,
+.navbar-inverse .navbar-nav>.active>a:focus {
+ color: #fff;
+ background-color: #080808;
+}
+
+.navbar-inverse .navbar-nav>.disabled>a,
+.navbar-inverse .navbar-nav>.disabled>a:hover,
+.navbar-inverse .navbar-nav>.disabled>a:focus {
+ color: #444;
+ background-color: transparent;
+}
+
+.navbar-inverse .navbar-toggle {
+ border-color: #333;
+}
+
+.navbar-inverse .navbar-toggle:hover,
+.navbar-inverse .navbar-toggle:focus {
+ background-color: #333;
+}
+
+.navbar-inverse .navbar-toggle .icon-bar {
+ background-color: #fff;
+}
+
+.navbar-inverse .navbar-collapse,
+.navbar-inverse .navbar-form {
+ border-color: #101010;
+}
+
+.navbar-inverse .navbar-nav>.open>a,
+.navbar-inverse .navbar-nav>.open>a:hover,
+.navbar-inverse .navbar-nav>.open>a:focus {
+ color: #fff;
+ background-color: #080808;
+}
+
+@media (max-width: 767px) {
+ .navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header {
+ border-color: #080808;
+ }
+
+ .navbar-inverse .navbar-nav .open .dropdown-menu .divider {
+ background-color: #080808;
+ }
+
+ .navbar-inverse .navbar-nav .open .dropdown-menu>li>a {
+ color: #9d9d9d;
+ }
+
+ .navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover,
+ .navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus {
+ color: #fff;
+ background-color: transparent;
+ }
+
+ .navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,
+ .navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover,
+ .navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus {
+ color: #fff;
+ background-color: #080808;
+ }
+
+ .navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,
+ .navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover,
+ .navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus {
+ color: #444;
+ background-color: transparent;
+ }
+}
+
+.navbar-inverse .navbar-link {
+ color: #9d9d9d;
+}
+
+.navbar-inverse .navbar-link:hover {
+ color: #fff;
+}
+
+.navbar-inverse .btn-link {
+ color: #9d9d9d;
+}
+
+.navbar-inverse .btn-link:hover,
+.navbar-inverse .btn-link:focus {
+ color: #fff;
+}
+
+.navbar-inverse .btn-link[disabled]:hover,
+fieldset[disabled] .navbar-inverse .btn-link:hover,
+.navbar-inverse .btn-link[disabled]:focus,
+fieldset[disabled] .navbar-inverse .btn-link:focus {
+ color: #444;
+}
+
+.breadcrumb {
+ padding: 8px 15px;
+ margin-bottom: 20px;
+ list-style: none;
+ background-color: #f5f5f5;
+ border-radius: 4px;
+}
+
+.breadcrumb>li {
+ display: inline-block;
+}
+
+.breadcrumb>li+li:before {
+ padding: 0 5px;
+ color: #ccc;
+ content: "/\00a0";
+}
+
+.breadcrumb>.active {
+ color: #777;
+}
+
+.pagination {
+ display: inline-block;
+ padding-left: 0;
+ margin: 20px 0;
+ border-radius: 4px;
+}
+
+.pagination>li {
+ display: inline;
+}
+
+.pagination>li>a,
+.pagination>li>span {
+ position: relative;
+ float: left;
+ padding: 6px 12px;
+ margin-left: -1px;
+ line-height: 1.42857143;
+ color: #428bca;
+ text-decoration: none;
+ background-color: #fff;
+ border: 1px solid #ddd;
+}
+
+.pagination>li:first-child>a,
+.pagination>li:first-child>span {
+ margin-left: 0;
+ border-top-left-radius: 4px;
+ border-bottom-left-radius: 4px;
+}
+
+.pagination>li:last-child>a,
+.pagination>li:last-child>span {
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+}
+
+.pagination>li>a:hover,
+.pagination>li>span:hover,
+.pagination>li>a:focus,
+.pagination>li>span:focus {
+ color: #2a6496;
+ background-color: #eee;
+ border-color: #ddd;
+}
+
+.pagination>.active>a,
+.pagination>.active>span,
+.pagination>.active>a:hover,
+.pagination>.active>span:hover,
+.pagination>.active>a:focus,
+.pagination>.active>span:focus {
+ z-index: 2;
+ color: #fff;
+ cursor: default;
+ background-color: #3071a9;
+ border-color: #428bca;
+}
+
+.pagination>.disabled>span,
+.pagination>.disabled>span:hover,
+.pagination>.disabled>span:focus,
+.pagination>.disabled>a,
+.pagination>.disabled>a:hover,
+.pagination>.disabled>a:focus {
+ color: #777;
+ cursor: not-allowed;
+ background-color: #fff;
+ border-color: #ddd;
+}
+
+.pagination-lg>li>a,
+.pagination-lg>li>span {
+ padding: 10px 16px;
+ font-size: 18px;
+}
+
+.pagination-lg>li:first-child>a,
+.pagination-lg>li:first-child>span {
+ border-top-left-radius: 6px;
+ border-bottom-left-radius: 6px;
+}
+
+.pagination-lg>li:last-child>a,
+.pagination-lg>li:last-child>span {
+ border-top-right-radius: 6px;
+ border-bottom-right-radius: 6px;
+}
+
+.pagination-sm>li>a,
+.pagination-sm>li>span {
+ padding: 5px 10px;
+ font-size: 12px;
+}
+
+.pagination-sm>li:first-child>a,
+.pagination-sm>li:first-child>span {
+ border-top-left-radius: 3px;
+ border-bottom-left-radius: 3px;
+}
+
+.pagination-sm>li:last-child>a,
+.pagination-sm>li:last-child>span {
+ border-top-right-radius: 3px;
+ border-bottom-right-radius: 3px;
+}
+
+.pager {
+ padding-left: 0;
+ margin: 20px 0;
+ text-align: center;
+ list-style: none;
+}
+
+.pager li {
+ display: inline;
+}
+
+.pager li>a,
+.pager li>span {
+ display: inline-block;
+ padding: 5px 14px;
+ background-color: #fff;
+ border: 1px solid #ddd;
+ border-radius: 15px;
+}
+
+.pager li>a:hover,
+.pager li>a:focus {
+ text-decoration: none;
+ background-color: #eee;
+}
+
+.pager .next>a,
+.pager .next>span {
+ float: right;
+}
+
+.pager .previous>a,
+.pager .previous>span {
+ float: left;
+}
+
+.pager .disabled>a,
+.pager .disabled>a:hover,
+.pager .disabled>a:focus,
+.pager .disabled>span {
+ color: #777;
+ cursor: not-allowed;
+ background-color: #fff;
+}
+
+.label {
+ display: inline;
+ padding: .2em .6em .3em;
+ font-size: 75%;
+ font-weight: bold;
+ line-height: 1;
+ color: #fff;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: baseline;
+ border-radius: .25em;
+}
+
+a.label:hover,
+a.label:focus {
+ color: #fff;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.label:empty {
+ display: none;
+}
+
+.btn .label {
+ position: relative;
+ top: -1px;
+}
+
+.label-default {
+ background-color: #777;
+}
+
+.label-default[href]:hover,
+.label-default[href]:focus {
+ background-color: #5e5e5e;
+}
+
+.label-primary {
+ background-color: #428bca;
+}
+
+.label-primary[href]:hover,
+.label-primary[href]:focus {
+ background-color: #3071a9;
+}
+
+.label-success {
+ background-color: #5cb85c;
+}
+
+.label-success[href]:hover,
+.label-success[href]:focus {
+ background-color: #449d44;
+}
+
+.label-info {
+ background-color: #5bc0de;
+}
+
+.label-info[href]:hover,
+.label-info[href]:focus {
+ background-color: #31b0d5;
+}
+
+.label-warning {
+ background-color: #f0ad4e;
+}
+
+.label-warning[href]:hover,
+.label-warning[href]:focus {
+ background-color: #ec971f;
+}
+
+.label-danger {
+ background-color: #d9534f;
+}
+
+.label-danger[href]:hover,
+.label-danger[href]:focus {
+ background-color: #c9302c;
+}
+
+.badge {
+ display: inline-block;
+ min-width: 10px;
+ padding: 3px 7px;
+ font-size: 12px;
+ font-weight: bold;
+ line-height: 1;
+ color: #fff;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: baseline;
+ background-color: #777;
+ border-radius: 10px;
+}
+
+.badge:empty {
+ display: none;
+}
+
+.btn .badge {
+ position: relative;
+ top: -1px;
+}
+
+.btn-xs .badge {
+ top: 0;
+ padding: 1px 5px;
+}
+
+a.badge:hover,
+a.badge:focus {
+ color: #fff;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+a.list-group-item.active>.badge,
+.nav-pills>.active>a>.badge {
+ color: #3071a9;
+ background-color: #fff;
+}
+
+.nav-pills>li>a>.badge {
+ margin-left: 3px;
+}
+
+.jumbotron {
+ margin-bottom: 10px;
+ color: inherit;
+ background-color: #eee;
+}
+
+.jumbotron h1,
+.jumbotron .h1 {
+ color: inherit;
+}
+
+.jumbotron p {
+ margin-bottom: 15px;
+ font-size: 21px;
+ font-weight: 200;
+}
+
+.jumbotron>hr {
+ border-top-color: #d5d5d5;
+}
+
+.container .jumbotron,
+.container-fluid .jumbotron {
+ border-radius: 6px;
+}
+
+.jumbotron .container {
+ max-width: 100%;
+}
+
+/*@media screen and (min-width: 768px) {
+ .jumbotron {
+ padding: 48px 0;
+ }
+ .container .jumbotron {
+ padding-right: 60px;
+ padding-left: 60px;
+ }
+ .jumbotron h1,
+ .jumbotron .h1 {
+ font-size: 63px;
+ }
+}*/
+
+.thumbnail {
+ display: block;
+ padding: 4px;
+ margin-bottom: 20px;
+ line-height: 1.42857143;
+ background-color: #fff;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ -webkit-transition: border .2s ease-in-out;
+ -o-transition: border .2s ease-in-out;
+ transition: border .2s ease-in-out;
+}
+
+.thumbnail>img,
+.thumbnail a>img {
+ margin-right: auto;
+ margin-left: auto;
+}
+
+a.thumbnail:hover,
+a.thumbnail:focus,
+a.thumbnail.active {
+ border-color: #428bca;
+}
+
+.thumbnail .caption {
+ padding: 9px;
+ color: #333;
+}
+
+.alert {
+ padding: 10px;
+ margin-bottom: 5px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+
+.alert h4 {
+ margin-top: 0;
+ color: inherit;
+}
+
+.alert .alert-link {
+ font-weight: bold;
+}
+
+.alert>p,
+.alert>ul {
+ margin-bottom: 0;
+}
+
+.alert>p+p {
+ margin-top: 5px;
+}
+
+.alert-dismissable,
+.alert-dismissible {
+ padding-right: 35px;
+}
+
+.alert-dismissable .close,
+.alert-dismissible .close {
+ position: relative;
+ top: -2px;
+ right: -21px;
+ color: inherit;
+}
+
+.alert-success {
+ color: #3c763d;
+ background-color: #dff0d8;
+ border-color: #d6e9c6;
+}
+
+.alert-success hr {
+ border-top-color: #c9e2b3;
+}
+
+.alert-success .alert-link {
+ color: #2b542c;
+}
+
+.alert-info {
+ color: #31708f;
+ background-color: #d9edf7;
+ border-color: #bce8f1;
+}
+
+.alert-info hr {
+ border-top-color: #a6e1ec;
+}
+
+.alert-info .alert-link {
+ color: #245269;
+}
+
+.alert-warning {
+ color: #8a6d3b;
+ background-color: #fcf8e3;
+ border-color: #faebcc;
+}
+
+.alert-warning hr {
+ border-top-color: #f7e1b5;
+}
+
+.alert-warning .alert-link {
+ color: #66512c;
+}
+
+.alert-danger {
+ color: #a94442;
+ background-color: #f2dede;
+ border-color: #ebccd1;
+}
+
+.alert-danger hr {
+ border-top-color: #e4b9c0;
+}
+
+.alert-danger .alert-link {
+ color: #843534;
+}
+
+@-webkit-keyframes progress-bar-stripes {
+ from {
+ background-position: 40px 0;
+ }
+
+ to {
+ background-position: 0 0;
+ }
+}
+
+@-o-keyframes progress-bar-stripes {
+ from {
+ background-position: 40px 0;
+ }
+
+ to {
+ background-position: 0 0;
+ }
+}
+
+@keyframes progress-bar-stripes {
+ from {
+ background-position: 40px 0;
+ }
+
+ to {
+ background-position: 0 0;
+ }
+}
+
+.progress {
+ height: 20px;
+ margin-bottom: 20px;
+ overflow: hidden;
+ background-color: #f5f5f5;
+ border-radius: 4px;
+ -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);
+}
+
+.progress-bar {
+ float: left;
+ width: 0;
+ height: 100%;
+ font-size: 12px;
+ line-height: 20px;
+ color: #fff;
+ text-align: center;
+ background-color: #428bca;
+ -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15);
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15);
+ -webkit-transition: width .6s ease;
+ -o-transition: width .6s ease;
+ transition: width .6s ease;
+}
+
+.progress-striped .progress-bar,
+.progress-bar-striped {
+ background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ -webkit-background-size: 40px 40px;
+ background-size: 40px 40px;
+}
+
+.progress.active .progress-bar,
+.progress-bar.active {
+ -webkit-animation: progress-bar-stripes 2s linear infinite;
+ -o-animation: progress-bar-stripes 2s linear infinite;
+ animation: progress-bar-stripes 2s linear infinite;
+}
+
+.progress-bar-success {
+ background-color: #5cb85c;
+}
+
+.progress-striped .progress-bar-success {
+ background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+}
+
+.progress-bar-info {
+ background-color: #5bc0de;
+}
+
+.progress-striped .progress-bar-info {
+ background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+}
+
+.progress-bar-warning {
+ background-color: #f0ad4e;
+}
+
+.progress-striped .progress-bar-warning {
+ background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+}
+
+.progress-bar-danger {
+ background-color: #d9534f;
+}
+
+.progress-striped .progress-bar-danger {
+ background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+}
+
+.media {
+ margin-top: 15px;
+}
+
+.media:first-child {
+ margin-top: 0;
+}
+
+.media-right,
+.media>.pull-right {
+ padding-left: 10px;
+}
+
+.media-left,
+.media>.pull-left {
+ padding-right: 10px;
+}
+
+.media-left,
+.media-right,
+.media-body {
+ display: table-cell;
+ vertical-align: top;
+}
+
+.media-middle {
+ vertical-align: middle;
+}
+
+.media-bottom {
+ vertical-align: bottom;
+}
+
+.media-heading {
+ margin-top: 0;
+ margin-bottom: 5px;
+}
+
+.media-list {
+ padding-left: 0;
+ list-style: none;
+}
+
+.list-group {
+ padding-left: 0;
+ margin-bottom: 20px;
+}
+
+.list-group-item {
+ position: relative;
+ display: block;
+ padding: 10px 15px;
+ margin-bottom: -1px;
+ background-color: #fff;
+ border: 1px solid #ddd;
+}
+
+.list-group-item:first-child {
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+}
+
+.list-group-item:last-child {
+ margin-bottom: 0;
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+}
+
+.list-group-item>.badge {
+ float: right;
+}
+
+.list-group-item>.badge+.badge {
+ margin-right: 5px;
+}
+
+a.list-group-item {
+ color: #555;
+}
+
+a.list-group-item .list-group-item-heading {
+ color: #333;
+}
+
+a.list-group-item:hover,
+a.list-group-item:focus {
+ color: #555;
+ text-decoration: none;
+ background-color: #f5f5f5;
+}
+
+.list-group-item.disabled,
+.list-group-item.disabled:hover,
+.list-group-item.disabled:focus {
+ color: #777;
+ cursor: not-allowed;
+ background-color: #eee;
+}
+
+.list-group-item.disabled .list-group-item-heading,
+.list-group-item.disabled:hover .list-group-item-heading,
+.list-group-item.disabled:focus .list-group-item-heading {
+ color: inherit;
+}
+
+.list-group-item.disabled .list-group-item-text,
+.list-group-item.disabled:hover .list-group-item-text,
+.list-group-item.disabled:focus .list-group-item-text {
+ color: #777;
+}
+
+.list-group-item.active,
+.list-group-item.active:hover,
+.list-group-item.active:focus {
+ z-index: 2;
+ color: #fff;
+ background-color: #428bca;
+ border-color: #428bca;
+}
+
+.list-group-item.active .list-group-item-heading,
+.list-group-item.active:hover .list-group-item-heading,
+.list-group-item.active:focus .list-group-item-heading,
+.list-group-item.active .list-group-item-heading>small,
+.list-group-item.active:hover .list-group-item-heading>small,
+.list-group-item.active:focus .list-group-item-heading>small,
+.list-group-item.active .list-group-item-heading>.small,
+.list-group-item.active:hover .list-group-item-heading>.small,
+.list-group-item.active:focus .list-group-item-heading>.small {
+ color: inherit;
+}
+
+.list-group-item.active .list-group-item-text,
+.list-group-item.active:hover .list-group-item-text,
+.list-group-item.active:focus .list-group-item-text {
+ color: #e1edf7;
+}
+
+.list-group-item-success {
+ color: #3c763d;
+ background-color: #dff0d8;
+}
+
+a.list-group-item-success {
+ color: #3c763d;
+}
+
+a.list-group-item-success .list-group-item-heading {
+ color: inherit;
+}
+
+a.list-group-item-success:hover,
+a.list-group-item-success:focus {
+ color: #3c763d;
+ background-color: #d0e9c6;
+}
+
+a.list-group-item-success.active,
+a.list-group-item-success.active:hover,
+a.list-group-item-success.active:focus {
+ color: #fff;
+ background-color: #3c763d;
+ border-color: #3c763d;
+}
+
+.list-group-item-info {
+ color: #31708f;
+ background-color: #d9edf7;
+}
+
+a.list-group-item-info {
+ color: #31708f;
+}
+
+a.list-group-item-info .list-group-item-heading {
+ color: inherit;
+}
+
+a.list-group-item-info:hover,
+a.list-group-item-info:focus {
+ color: #31708f;
+ background-color: #c4e3f3;
+}
+
+a.list-group-item-info.active,
+a.list-group-item-info.active:hover,
+a.list-group-item-info.active:focus {
+ color: #fff;
+ background-color: #31708f;
+ border-color: #31708f;
+}
+
+.list-group-item-warning {
+ color: #8a6d3b;
+ background-color: #fcf8e3;
+}
+
+a.list-group-item-warning {
+ color: #8a6d3b;
+}
+
+a.list-group-item-warning .list-group-item-heading {
+ color: inherit;
+}
+
+a.list-group-item-warning:hover,
+a.list-group-item-warning:focus {
+ color: #8a6d3b;
+ background-color: #faf2cc;
+}
+
+a.list-group-item-warning.active,
+a.list-group-item-warning.active:hover,
+a.list-group-item-warning.active:focus {
+ color: #fff;
+ background-color: #8a6d3b;
+ border-color: #8a6d3b;
+}
+
+.list-group-item-danger {
+ color: #a94442;
+ background-color: #f2dede;
+}
+
+a.list-group-item-danger {
+ color: #a94442;
+}
+
+a.list-group-item-danger .list-group-item-heading {
+ color: inherit;
+}
+
+a.list-group-item-danger:hover,
+a.list-group-item-danger:focus {
+ color: #a94442;
+ background-color: #ebcccc;
+}
+
+a.list-group-item-danger.active,
+a.list-group-item-danger.active:hover,
+a.list-group-item-danger.active:focus {
+ color: #fff;
+ background-color: #a94442;
+ border-color: #a94442;
+}
+
+.list-group-item-heading {
+ margin-top: 0;
+ margin-bottom: 5px;
+}
+
+.list-group-item-text {
+ margin-bottom: 0;
+ line-height: 1.3;
+}
+
+.panel {
+ margin-bottom: 20px;
+ background-color: #fff;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, .05);
+ box-shadow: 0 1px 1px rgba(0, 0, 0, .05);
+}
+
+.panel-body {
+ padding: 15px;
+}
+
+.panel-heading {
+ padding: 10px 15px;
+ border-bottom: 1px solid transparent;
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+ cursor: pointer;
+}
+
+.panel-heading>.dropdown .dropdown-toggle {
+ color: inherit;
+}
+
+.panel-title {
+ margin-top: 0;
+ margin-bottom: 0;
+ font-size: 16px;
+ color: inherit;
+}
+
+.panel-title>a {
+ color: inherit;
+}
+
+.panel-footer {
+ padding: 10px 15px;
+ background-color: #f5f5f5;
+ border-top: 1px solid #ddd;
+ border-bottom-right-radius: 3px;
+ border-bottom-left-radius: 3px;
+}
+
+.panel>.list-group,
+.panel>.panel-collapse>.list-group {
+ margin-bottom: 0;
+}
+
+.panel>.list-group .list-group-item,
+.panel>.panel-collapse>.list-group .list-group-item {
+ border-width: 1px 0;
+ border-radius: 0;
+}
+
+.panel>.list-group:first-child .list-group-item:first-child,
+.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child {
+ border-top: 0;
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+}
+
+.panel>.list-group:last-child .list-group-item:last-child,
+.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child {
+ border-bottom: 0;
+ border-bottom-right-radius: 3px;
+ border-bottom-left-radius: 3px;
+}
+
+.panel-heading+.list-group .list-group-item:first-child {
+ border-top-width: 0;
+}
+
+.list-group+.panel-footer {
+ border-top-width: 0;
+}
+
+.panel>.table,
+.panel>.table-responsive>.table,
+.panel>.panel-collapse>.table {
+ margin-bottom: 0;
+}
+
+.panel>.table caption,
+.panel>.table-responsive>.table caption,
+.panel>.panel-collapse>.table caption {
+ padding-right: 15px;
+ padding-left: 15px;
+}
+
+.panel>.table:first-child,
+.panel>.table-responsive:first-child>.table:first-child {
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+}
+
+.panel>.table:first-child>thead:first-child>tr:first-child,
+.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,
+.panel>.table:first-child>tbody:first-child>tr:first-child,
+.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child {
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+}
+
+.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,
+.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,
+.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,
+.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,
+.panel>.table:first-child>thead:first-child>tr:first-child th:first-child,
+.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,
+.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,
+.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child {
+ border-top-left-radius: 3px;
+}
+
+.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,
+.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,
+.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,
+.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,
+.panel>.table:first-child>thead:first-child>tr:first-child th:last-child,
+.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,
+.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,
+.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child {
+ border-top-right-radius: 3px;
+}
+
+.panel>.table:last-child,
+.panel>.table-responsive:last-child>.table:last-child {
+ border-bottom-right-radius: 3px;
+ border-bottom-left-radius: 3px;
+}
+
+.panel>.table:last-child>tbody:last-child>tr:last-child,
+.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,
+.panel>.table:last-child>tfoot:last-child>tr:last-child,
+.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child {
+ border-bottom-right-radius: 3px;
+ border-bottom-left-radius: 3px;
+}
+
+.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,
+.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,
+.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,
+.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,
+.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,
+.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,
+.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child,
+.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child {
+ border-bottom-left-radius: 3px;
+}
+
+.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,
+.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,
+.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,
+.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,
+.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,
+.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,
+.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child,
+.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child {
+ border-bottom-right-radius: 3px;
+}
+
+.panel>.panel-body+.table,
+.panel>.panel-body+.table-responsive,
+.panel>.table+.panel-body,
+.panel>.table-responsive+.panel-body {
+ border-top: 1px solid #ddd;
+}
+
+.panel>.table>tbody:first-child>tr:first-child th,
+.panel>.table>tbody:first-child>tr:first-child td {
+ border-top: 0;
+}
+
+.panel>.table-bordered,
+.panel>.table-responsive>.table-bordered {
+ border: 0;
+}
+
+.panel>.table-bordered>thead>tr>th:first-child,
+.panel>.table-responsive>.table-bordered>thead>tr>th:first-child,
+.panel>.table-bordered>tbody>tr>th:first-child,
+.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,
+.panel>.table-bordered>tfoot>tr>th:first-child,
+.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,
+.panel>.table-bordered>thead>tr>td:first-child,
+.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,
+.panel>.table-bordered>tbody>tr>td:first-child,
+.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,
+.panel>.table-bordered>tfoot>tr>td:first-child,
+.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child {
+ border-left: 0;
+}
+
+.panel>.table-bordered>thead>tr>th:last-child,
+.panel>.table-responsive>.table-bordered>thead>tr>th:last-child,
+.panel>.table-bordered>tbody>tr>th:last-child,
+.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,
+.panel>.table-bordered>tfoot>tr>th:last-child,
+.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,
+.panel>.table-bordered>thead>tr>td:last-child,
+.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,
+.panel>.table-bordered>tbody>tr>td:last-child,
+.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,
+.panel>.table-bordered>tfoot>tr>td:last-child,
+.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child {
+ border-right: 0;
+}
+
+.panel>.table-bordered>thead>tr:first-child>td,
+.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,
+.panel>.table-bordered>tbody>tr:first-child>td,
+.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,
+.panel>.table-bordered>thead>tr:first-child>th,
+.panel>.table-responsive>.table-bordered>thead>tr:first-child>th,
+.panel>.table-bordered>tbody>tr:first-child>th,
+.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th {
+ border-bottom: 0;
+}
+
+.panel>.table-bordered>tbody>tr:last-child>td,
+.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,
+.panel>.table-bordered>tfoot>tr:last-child>td,
+.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,
+.panel>.table-bordered>tbody>tr:last-child>th,
+.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,
+.panel>.table-bordered>tfoot>tr:last-child>th,
+.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th {
+ border-bottom: 0;
+}
+
+.panel>.table-responsive {
+ margin-bottom: 0;
+ border: 0;
+}
+
+.panel-group {
+ margin-bottom: 20px;
+}
+
+.panel-group .panel {
+ margin-bottom: 0;
+ border-radius: 4px;
+}
+
+.panel-group .panel+.panel {
+ margin-top: 5px;
+}
+
+.panel-group .panel-heading {
+ border-bottom: 0;
+}
+
+.panel-group .panel-heading+.panel-collapse>.panel-body,
+.panel-group .panel-heading+.panel-collapse>.list-group {
+ border-top: 1px solid #ddd;
+}
+
+.panel-group .panel-footer {
+ border-top: 0;
+}
+
+.panel-group .panel-footer+.panel-collapse .panel-body {
+ border-bottom: 1px solid #ddd;
+}
+
+.panel-default {
+ border-color: #ddd;
+}
+
+.panel-default>.panel-heading {
+ color: #333;
+ background-color: #f5f5f5;
+ border-color: #ddd;
+}
+
+.panel-default>.panel-heading+.panel-collapse>.panel-body {
+ border-top-color: #ddd;
+}
+
+.panel-default>.panel-heading .badge {
+ color: #f5f5f5;
+ background-color: #333;
+}
+
+.panel-default>.panel-footer+.panel-collapse>.panel-body {
+ border-bottom-color: #ddd;
+}
+
+.panel-primary {
+ border-color: #428bca;
+}
+
+.panel-primary>.panel-heading {
+ color: #fff;
+ background-color: #428bca;
+ border-color: #428bca;
+}
+
+.panel-primary>.panel-heading+.panel-collapse>.panel-body {
+ border-top-color: #428bca;
+}
+
+.panel-primary>.panel-heading .badge {
+ color: #428bca;
+ background-color: #fff;
+}
+
+.panel-primary>.panel-footer+.panel-collapse>.panel-body {
+ border-bottom-color: #428bca;
+}
+
+.panel-success {
+ border-color: #d6e9c6;
+}
+
+.panel-success>.panel-heading {
+ color: #3c763d;
+ background-color: #dff0d8;
+ border-color: #d6e9c6;
+}
+
+.panel-success>.panel-heading+.panel-collapse>.panel-body {
+ border-top-color: #d6e9c6;
+}
+
+.panel-success>.panel-heading .badge {
+ color: #dff0d8;
+ background-color: #3c763d;
+}
+
+.panel-success>.panel-footer+.panel-collapse>.panel-body {
+ border-bottom-color: #d6e9c6;
+}
+
+.panel-info {
+ border-color: #bce8f1;
+}
+
+.panel-info>.panel-heading {
+ color: #31708f;
+ background-color: #d9edf7;
+ border-color: #bce8f1;
+}
+
+.panel-info>.panel-heading+.panel-collapse>.panel-body {
+ border-top-color: #bce8f1;
+}
+
+.panel-info>.panel-heading .badge {
+ color: #d9edf7;
+ background-color: #31708f;
+}
+
+.panel-info>.panel-footer+.panel-collapse>.panel-body {
+ border-bottom-color: #bce8f1;
+}
+
+.panel-warning {
+ border-color: #faebcc;
+}
+
+.panel-warning>.panel-heading {
+ color: #8a6d3b;
+ background-color: #fcf8e3;
+ border-color: #faebcc;
+}
+
+.panel-warning>.panel-heading+.panel-collapse>.panel-body {
+ border-top-color: #faebcc;
+}
+
+.panel-warning>.panel-heading .badge {
+ color: #fcf8e3;
+ background-color: #8a6d3b;
+}
+
+.panel-warning>.panel-footer+.panel-collapse>.panel-body {
+ border-bottom-color: #faebcc;
+}
+
+.panel-danger {
+ border-color: #ebccd1;
+}
+
+.panel-danger>.panel-heading {
+ color: #a94442;
+ background-color: #f2dede;
+ border-color: #ebccd1;
+}
+
+.panel-danger>.panel-heading+.panel-collapse>.panel-body {
+ border-top-color: #ebccd1;
+}
+
+.panel-danger>.panel-heading .badge {
+ color: #f2dede;
+ background-color: #a94442;
+}
+
+.panel-danger>.panel-footer+.panel-collapse>.panel-body {
+ border-bottom-color: #ebccd1;
+}
+
+.embed-responsive {
+ position: relative;
+ display: block;
+ height: 0;
+ padding: 0;
+ overflow: hidden;
+}
+
+.embed-responsive .embed-responsive-item,
+.embed-responsive iframe,
+.embed-responsive embed,
+.embed-responsive object,
+.embed-responsive video {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border: 0;
+}
+
+.embed-responsive.embed-responsive-16by9 {
+ padding-bottom: 56.25%;
+}
+
+.embed-responsive.embed-responsive-4by3 {
+ padding-bottom: 75%;
+}
+
+.well {
+ min-height: 20px;
+ padding: 19px;
+ margin-bottom: 20px;
+ background-color: #f5f5f5;
+ border: 1px solid #e3e3e3;
+ border-radius: 4px;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05);
+}
+
+.well blockquote {
+ border-color: #ddd;
+ border-color: rgba(0, 0, 0, .15);
+}
+
+.well-lg {
+ padding: 24px;
+ border-radius: 6px;
+}
+
+.well-sm {
+ padding: 9px;
+ border-radius: 3px;
+}
+
+.close {
+ float: right;
+ font-size: 21px;
+ font-weight: bold;
+ line-height: 1;
+ color: #000;
+ text-shadow: 0 1px 0 #fff;
+ filter: alpha(opacity=20);
+ opacity: .2;
+}
+
+.close:hover,
+.close:focus {
+ color: #000;
+ text-decoration: none;
+ cursor: pointer;
+ filter: alpha(opacity=50);
+ opacity: .5;
+}
+
+button.close {
+ -webkit-appearance: none;
+ padding: 0;
+ cursor: pointer;
+ background: transparent;
+ border: 0;
+}
+
+.modal-open {
+ overflow: hidden;
+}
+
+.modal {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1040;
+ display: none;
+ overflow: hidden;
+ -webkit-overflow-scrolling: touch;
+ outline: 0;
+}
+
+.modal.fade .modal-dialog {
+ -webkit-transition: -webkit-transform .3s ease-out;
+ -o-transition: -o-transform .3s ease-out;
+ transition: transform .3s ease-out;
+ -webkit-transform: translate(0, -25%);
+ -ms-transform: translate(0, -25%);
+ -o-transform: translate(0, -25%);
+ transform: translate(0, -25%);
+}
+
+.modal.in .modal-dialog {
+ -webkit-transform: translate(0, 0);
+ -ms-transform: translate(0, 0);
+ -o-transform: translate(0, 0);
+ transform: translate(0, 0);
+}
+
+.modal-open .modal {
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.modal-dialog {
+ position: relative;
+ width: auto;
+ margin: 10px;
+}
+
+.modal-content {
+ position: relative;
+ background-color: #fff;
+ -webkit-background-clip: padding-box;
+ background-clip: padding-box;
+ border: 1px solid #999;
+ border: 1px solid rgba(0, 0, 0, .2);
+ border-radius: 6px;
+ outline: 0;
+ -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, .5);
+ box-shadow: 0 3px 9px rgba(0, 0, 0, .5);
+}
+
+.modal-backdrop {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background-color: #000;
+}
+
+.modal-backdrop.fade {
+ filter: alpha(opacity=0);
+ opacity: 0;
+}
+
+.modal-backdrop.in {
+ filter: alpha(opacity=50);
+ opacity: .5;
+}
+
+.modal-header {
+ min-height: 16.42857143px;
+ padding: 15px;
+ border-bottom: 1px solid #e5e5e5;
+}
+
+.modal-header .close {
+ margin-top: -2px;
+}
+
+.modal-title {
+ margin: 0;
+ line-height: 1.42857143;
+}
+
+.modal-body {
+ position: relative;
+ padding: 15px;
+}
+
+.modal-footer {
+ padding: 15px;
+ text-align: right;
+ border-top: 1px solid #e5e5e5;
+}
+
+.modal-footer .btn+.btn {
+ margin-bottom: 0;
+ margin-left: 5px;
+}
+
+.modal-footer .btn-group .btn+.btn {
+ margin-left: -1px;
+}
+
+.modal-footer .btn-block+.btn-block {
+ margin-left: 0;
+}
+
+.modal-scrollbar-measure {
+ position: absolute;
+ top: -9999px;
+ width: 50px;
+ height: 50px;
+ overflow: scroll;
+}
+
+@media (min-width: 768px) {
+ .modal-dialog {
+ width: 600px;
+ margin: 30px auto;
+ }
+
+ .modal-content {
+ -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
+ box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
+ }
+
+ .modal-sm {
+ width: 300px;
+ }
+}
+
+@media (min-width: 992px) {
+ .modal-lg {
+ width: 900px;
+ }
+}
+
+.tooltip {
+ position: absolute;
+ z-index: 1070;
+ display: block;
+ font-size: 12px;
+ line-height: 1.4;
+ visibility: visible;
+ filter: alpha(opacity=0);
+ opacity: 0;
+}
+
+.tooltip.in {
+ filter: alpha(opacity=90);
+ opacity: .9;
+}
+
+.tooltip.top {
+ padding: 5px 0;
+ margin-top: -3px;
+}
+
+.tooltip.right {
+ padding: 0 5px;
+ margin-left: 3px;
+}
+
+.tooltip.bottom {
+ padding: 5px 0;
+ margin-top: 3px;
+}
+
+.tooltip.left {
+ padding: 0 5px;
+ margin-left: -3px;
+}
+
+.tooltip-inner {
+ max-width: 200px;
+ padding: 3px 8px;
+ color: #fff;
+ text-align: center;
+ text-decoration: none;
+ background-color: #000;
+ border-radius: 4px;
+}
+
+.tooltip-arrow {
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-color: transparent;
+ border-style: solid;
+}
+
+.tooltip.top .tooltip-arrow {
+ bottom: 0;
+ left: 50%;
+ margin-left: -5px;
+ border-width: 5px 5px 0;
+ border-top-color: #000;
+}
+
+.tooltip.top-left .tooltip-arrow {
+ bottom: 0;
+ left: 5px;
+ border-width: 5px 5px 0;
+ border-top-color: #000;
+}
+
+.tooltip.top-right .tooltip-arrow {
+ right: 5px;
+ bottom: 0;
+ border-width: 5px 5px 0;
+ border-top-color: #000;
+}
+
+.tooltip.right .tooltip-arrow {
+ top: 50%;
+ left: 0;
+ margin-top: -5px;
+ border-width: 5px 5px 5px 0;
+ border-right-color: #000;
+}
+
+.tooltip.left .tooltip-arrow {
+ top: 50%;
+ right: 0;
+ margin-top: -5px;
+ border-width: 5px 0 5px 5px;
+ border-left-color: #000;
+}
+
+.tooltip.bottom .tooltip-arrow {
+ top: 0;
+ left: 50%;
+ margin-left: -5px;
+ border-width: 0 5px 5px;
+ border-bottom-color: #000;
+}
+
+.tooltip.bottom-left .tooltip-arrow {
+ top: 0;
+ left: 5px;
+ border-width: 0 5px 5px;
+ border-bottom-color: #000;
+}
+
+.tooltip.bottom-right .tooltip-arrow {
+ top: 0;
+ right: 5px;
+ border-width: 0 5px 5px;
+ border-bottom-color: #000;
+}
+
+.popover {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1060;
+ display: none;
+ max-width: 276px;
+ padding: 1px;
+ font-size: 14px;
+ font-weight: normal;
+ line-height: 1.42857143;
+ text-align: left;
+ white-space: normal;
+ background-color: #fff;
+ -webkit-background-clip: padding-box;
+ background-clip: padding-box;
+ border: 1px solid #ccc;
+ border: 1px solid rgba(0, 0, 0, .2);
+ border-radius: 6px;
+ -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, .2);
+ box-shadow: 0 5px 10px rgba(0, 0, 0, .2);
+}
+
+.popover.top {
+ margin-top: -10px;
+}
+
+.popover.right {
+ margin-left: 10px;
+}
+
+.popover.bottom {
+ margin-top: 10px;
+}
+
+.popover.left {
+ margin-left: -10px;
+}
+
+.popover-title {
+ padding: 8px 14px;
+ margin: 0;
+ font-size: 14px;
+ background-color: #f7f7f7;
+ border-bottom: 1px solid #ebebeb;
+ border-radius: 5px 5px 0 0;
+}
+
+.popover-content {
+ padding: 9px 14px;
+}
+
+.popover>.arrow,
+.popover>.arrow:after {
+ position: absolute;
+ display: block;
+ width: 0;
+ height: 0;
+ border-color: transparent;
+ border-style: solid;
+}
+
+.popover>.arrow {
+ border-width: 11px;
+}
+
+.popover>.arrow:after {
+ content: "";
+ border-width: 10px;
+}
+
+.popover.top>.arrow {
+ bottom: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-color: #999;
+ border-top-color: rgba(0, 0, 0, .25);
+ border-bottom-width: 0;
+}
+
+.popover.top>.arrow:after {
+ bottom: 1px;
+ margin-left: -10px;
+ content: " ";
+ border-top-color: #fff;
+ border-bottom-width: 0;
+}
+
+.popover.right>.arrow {
+ top: 50%;
+ left: -11px;
+ margin-top: -11px;
+ border-right-color: #999;
+ border-right-color: rgba(0, 0, 0, .25);
+ border-left-width: 0;
+}
+
+.popover.right>.arrow:after {
+ bottom: -10px;
+ left: 1px;
+ content: " ";
+ border-right-color: #fff;
+ border-left-width: 0;
+}
+
+.popover.bottom>.arrow {
+ top: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-width: 0;
+ border-bottom-color: #999;
+ border-bottom-color: rgba(0, 0, 0, .25);
+}
+
+.popover.bottom>.arrow:after {
+ top: 1px;
+ margin-left: -10px;
+ content: " ";
+ border-top-width: 0;
+ border-bottom-color: #fff;
+}
+
+.popover.left>.arrow {
+ top: 50%;
+ right: -11px;
+ margin-top: -11px;
+ border-right-width: 0;
+ border-left-color: #999;
+ border-left-color: rgba(0, 0, 0, .25);
+}
+
+.popover.left>.arrow:after {
+ right: 1px;
+ bottom: -10px;
+ content: " ";
+ border-right-width: 0;
+ border-left-color: #fff;
+}
+
+.carousel {
+ position: relative;
+}
+
+.carousel-inner {
+ position: relative;
+ width: 100%;
+ overflow: hidden;
+}
+
+.carousel-inner>.item {
+ position: relative;
+ display: none;
+ -webkit-transition: .6s ease-in-out left;
+ -o-transition: .6s ease-in-out left;
+ transition: .6s ease-in-out left;
+}
+
+.carousel-inner>.item>img,
+.carousel-inner>.item>a>img {
+ line-height: 1;
+}
+
+@media all and (transform-3d),
+(-webkit-transform-3d) {
+ .carousel-inner>.item {
+ -webkit-transition: -webkit-transform .6s ease-in-out;
+ -o-transition: -o-transform .6s ease-in-out;
+ transition: transform .6s ease-in-out;
+
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+ -webkit-perspective: 1000;
+ perspective: 1000;
+ }
+
+ .carousel-inner>.item.next,
+ .carousel-inner>.item.active.right {
+ left: 0;
+ -webkit-transform: translate3d(100%, 0, 0);
+ transform: translate3d(100%, 0, 0);
+ }
+
+ .carousel-inner>.item.prev,
+ .carousel-inner>.item.active.left {
+ left: 0;
+ -webkit-transform: translate3d(-100%, 0, 0);
+ transform: translate3d(-100%, 0, 0);
+ }
+
+ .carousel-inner>.item.next.left,
+ .carousel-inner>.item.prev.right,
+ .carousel-inner>.item.active {
+ left: 0;
+ -webkit-transform: translate3d(0, 0, 0);
+ transform: translate3d(0, 0, 0);
+ }
+}
+
+.carousel-inner>.active,
+.carousel-inner>.next,
+.carousel-inner>.prev {
+ display: block;
+}
+
+.carousel-inner>.active {
+ left: 0;
+}
+
+.carousel-inner>.next,
+.carousel-inner>.prev {
+ position: absolute;
+ top: 0;
+ width: 100%;
+}
+
+.carousel-inner>.next {
+ left: 100%;
+}
+
+.carousel-inner>.prev {
+ left: -100%;
+}
+
+.carousel-inner>.next.left,
+.carousel-inner>.prev.right {
+ left: 0;
+}
+
+.carousel-inner>.active.left {
+ left: -100%;
+}
+
+.carousel-inner>.active.right {
+ left: 100%;
+}
+
+.carousel-control {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 15%;
+ font-size: 20px;
+ color: #fff;
+ text-align: center;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
+ filter: alpha(opacity=50);
+ opacity: .5;
+}
+
+.carousel-control.left {
+ background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);
+ background-image: -o-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);
+ background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .5)), to(rgba(0, 0, 0, .0001)));
+ background-image: linear-gradient(to right, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);
+ background-repeat: repeat-x;
+}
+
+.carousel-control.right {
+ right: 0;
+ left: auto;
+ background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);
+ background-image: -o-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);
+ background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .0001)), to(rgba(0, 0, 0, .5)));
+ background-image: linear-gradient(to right, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);
+ background-repeat: repeat-x;
+}
+
+.carousel-control:hover,
+.carousel-control:focus {
+ color: #fff;
+ text-decoration: none;
+ filter: alpha(opacity=90);
+ outline: 0;
+ opacity: .9;
+}
+
+.carousel-control .icon-prev,
+.carousel-control .icon-next,
+.carousel-control .glyphicon-chevron-left,
+.carousel-control .glyphicon-chevron-right {
+ position: absolute;
+ top: 50%;
+ z-index: 5;
+ display: inline-block;
+}
+
+.carousel-control .icon-prev,
+.carousel-control .glyphicon-chevron-left {
+ left: 50%;
+ margin-left: -10px;
+}
+
+.carousel-control .icon-next,
+.carousel-control .glyphicon-chevron-right {
+ right: 50%;
+ margin-right: -10px;
+}
+
+.carousel-control .icon-prev,
+.carousel-control .icon-next {
+ width: 20px;
+ height: 20px;
+ margin-top: -10px;
+ font-family: serif;
+}
+
+.carousel-control .icon-prev:before {
+ content: '\2039';
+}
+
+.carousel-control .icon-next:before {
+ content: '\203a';
+}
+
+.carousel-indicators {
+ position: absolute;
+ bottom: 10px;
+ left: 50%;
+ z-index: 15;
+ width: 60%;
+ padding-left: 0;
+ margin-left: -30%;
+ text-align: center;
+ list-style: none;
+}
+
+.carousel-indicators li {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ margin: 1px;
+ text-indent: -999px;
+ cursor: pointer;
+ background-color: #000 \9;
+ background-color: rgba(0, 0, 0, 0);
+ border: 1px solid #fff;
+ border-radius: 10px;
+}
+
+.carousel-indicators .active {
+ width: 12px;
+ height: 12px;
+ margin: 0;
+ background-color: #fff;
+}
+
+.carousel-caption {
+ position: absolute;
+ right: 15%;
+ bottom: 20px;
+ left: 15%;
+ z-index: 10;
+ padding-top: 20px;
+ padding-bottom: 20px;
+ color: #fff;
+ text-align: center;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
+}
+
+.carousel-caption .btn {
+ text-shadow: none;
+}
+
+/*
+@media screen and (min-width: 768px) {
+ .carousel-control .glyphicon-chevron-left,
+ .carousel-control .glyphicon-chevron-right,
+ .carousel-control .icon-prev,
+ .carousel-control .icon-next {
+ width: 30px;
+ height: 30px;
+ margin-top: -15px;
+ font-size: 30px;
+ }
+ .carousel-control .glyphicon-chevron-left,
+ .carousel-control .icon-prev {
+ margin-left: -15px;
+ }
+ .carousel-control .glyphicon-chevron-right,
+ .carousel-control .icon-next {
+ margin-right: -15px;
+ }
+ .carousel-caption {
+ right: 20%;
+ left: 20%;
+ padding-bottom: 30px;
+ }
+ .carousel-indicators {
+ bottom: 20px;
+ }
+}
+*/
+.clearfix:before,
+.clearfix:after,
+.dl-horizontal dd:before,
+.dl-horizontal dd:after,
+.container:before,
+.container:after,
+.container-fluid:before,
+.container-fluid:after,
+.row:before,
+.row:after,
+.form-horizontal .form-group:before,
+.form-horizontal .form-group:after,
+.btn-toolbar:before,
+.btn-toolbar:after,
+.btn-group-vertical>.btn-group:before,
+.btn-group-vertical>.btn-group:after,
+.nav:before,
+.nav:after,
+.navbar:before,
+.navbar:after,
+.navbar-header:before,
+.navbar-header:after,
+.navbar-collapse:before,
+.navbar-collapse:after,
+.pager:before,
+.pager:after,
+.panel-body:before,
+.panel-body:after,
+.modal-footer:before,
+.modal-footer:after {
+ display: table;
+ content: " ";
+}
+
+.clearfix:after,
+.dl-horizontal dd:after,
+.container:after,
+.container-fluid:after,
+.row:after,
+.form-horizontal .form-group:after,
+.btn-toolbar:after,
+.btn-group-vertical>.btn-group:after,
+.nav:after,
+.navbar:after,
+.navbar-header:after,
+.navbar-collapse:after,
+.pager:after,
+.panel-body:after,
+.modal-footer:after {
+ clear: both;
+}
+
+.center-block {
+ display: block;
+ margin-right: auto;
+ margin-left: auto;
+}
+
+.pull-right {
+ float: right !important;
+}
+
+.pull-left {
+ float: left !important;
+}
+
+.hide {
+ display: none !important;
+}
+
+.show {
+ display: block !important;
+}
+
+.invisible {
+ visibility: hidden;
+}
+
+.text-hide {
+ font: 0/0 a;
+ color: transparent;
+ text-shadow: none;
+ background-color: transparent;
+ border: 0;
+}
+
+.hidden {
+ display: none !important;
+ visibility: hidden !important;
+}
+
+.affix {
+ position: fixed;
+}
+
+@-ms-viewport {
+ width: device-width;
+}
+
+.visible-xs,
+.visible-sm,
+.visible-md,
+.visible-lg {
+ display: none !important;
+}
+
+.visible-xs-block,
+.visible-xs-inline,
+.visible-xs-inline-block,
+.visible-sm-block,
+.visible-sm-inline,
+.visible-sm-inline-block,
+.visible-md-block,
+.visible-md-inline,
+.visible-md-inline-block,
+.visible-lg-block,
+.visible-lg-inline,
+.visible-lg-inline-block {
+ display: none !important;
+}
+
+/*
+@media (max-width: 767px) {
+ .visible-xs {
+ display: block !important;
+ }
+ table.visible-xs {
+ display: table;
+ }
+ tr.visible-xs {
+ display: table-row !important;
+ }
+ th.visible-xs,
+ td.visible-xs {
+ display: table-cell !important;
+ }
+}
+@media (max-width: 767px) {
+ .visible-xs-block {
+ display: block !important;
+ }
+}
+@media (max-width: 767px) {
+ .visible-xs-inline {
+ display: inline !important;
+ }
+}
+@media (max-width: 767px) {
+ .visible-xs-inline-block {
+ display: inline-block !important;
+ }
+}
+@media (min-width: 768px) and (max-width: 991px) {
+ .visible-sm {
+ display: block !important;
+ }
+ table.visible-sm {
+ display: table;
+ }
+ tr.visible-sm {
+ display: table-row !important;
+ }
+ th.visible-sm,
+ td.visible-sm {
+ display: table-cell !important;
+ }
+}
+@media (min-width: 768px) and (max-width: 991px) {
+ .visible-sm-block {
+ display: block !important;
+ }
+}
+@media (min-width: 768px) and (max-width: 991px) {
+ .visible-sm-inline {
+ display: inline !important;
+ }
+}
+@media (min-width: 768px) and (max-width: 991px) {
+ .visible-sm-inline-block {
+ display: inline-block !important;
+ }
+}
+@media (min-width: 992px) and (max-width: 1199px) {
+ .visible-md {
+ display: block !important;
+ }
+ table.visible-md {
+ display: table;
+ }
+ tr.visible-md {
+ display: table-row !important;
+ }
+ th.visible-md,
+ td.visible-md {
+ display: table-cell !important;
+ }
+}
+@media (min-width: 992px) and (max-width: 1199px) {
+ .visible-md-block {
+ display: block !important;
+ }
+}
+@media (min-width: 992px) and (max-width: 1199px) {
+ .visible-md-inline {
+ display: inline !important;
+ }
+}
+@media (min-width: 992px) and (max-width: 1199px) {
+ .visible-md-inline-block {
+ display: inline-block !important;
+ }
+}
+@media (min-width: 1200px) {
+ .visible-lg {
+ display: block !important;
+ }
+ table.visible-lg {
+ display: table;
+ }
+ tr.visible-lg {
+ display: table-row !important;
+ }
+ th.visible-lg,
+ td.visible-lg {
+ display: table-cell !important;
+ }
+}
+@media (min-width: 1200px) {
+ .visible-lg-block {
+ display: block !important;
+ }
+}
+@media (min-width: 1200px) {
+ .visible-lg-inline {
+ display: inline !important;
+ }
+}
+@media (min-width: 1200px) {
+ .visible-lg-inline-block {
+ display: inline-block !important;
+ }
+}
+@media (max-width: 767px) {
+ .hidden-xs {
+ display: none !important;
+ }
+}
+@media (min-width: 768px) and (max-width: 991px) {
+ .hidden-sm {
+ display: none !important;
+ }
+}
+@media (min-width: 992px) and (max-width: 1199px) {
+ .hidden-md {
+ display: none !important;
+ }
+}
+@media (min-width: 1200px) {
+ .hidden-lg {
+ display: none !important;
+ }
+}
+.visible-print {
+ display: none !important;
+}
+@media print {
+ .visible-print {
+ display: block !important;
+ }
+ table.visible-print {
+ display: table;
+ }
+ tr.visible-print {
+ display: table-row !important;
+ }
+ th.visible-print,
+ td.visible-print {
+ display: table-cell !important;
+ }
+}
+*/
+.visible-print-block {
+ display: none !important;
+}
+
+@media print {
+ .visible-print-block {
+ display: block !important;
+ }
+}
+
+.visible-print-inline {
+ display: none !important;
+}
+
+@media print {
+ .visible-print-inline {
+ display: inline !important;
+ }
+}
+
+.visible-print-inline-block {
+ display: none !important;
+}
+
+@media print {
+ .visible-print-inline-block {
+ display: inline-block !important;
+ }
+}
+
+@media print {
+ .hidden-print {
+ display: none !important;
+ }
+}
+
+.col-centered{
+ float: none;
+ margin: 0 auto;
+}
diff --git a/gn_auth/static/css/broken_links.css b/gn_auth/static/css/broken_links.css
new file mode 100644
index 0000000..676f32d
--- /dev/null
+++ b/gn_auth/static/css/broken_links.css
@@ -0,0 +1,5 @@
+
+.broken_link{
+ color:red;
+ text-decoration: underline;
+} \ No newline at end of file
diff --git a/gn_auth/static/css/colorbox.css b/gn_auth/static/css/colorbox.css
new file mode 100644
index 0000000..812dfd7
--- /dev/null
+++ b/gn_auth/static/css/colorbox.css
@@ -0,0 +1,238 @@
+/*
+ Colorbox Core Style:
+ The following CSS is consistent between example themes and should not be altered.
+*/
+#colorbox,
+#cboxOverlay,
+#cboxWrapper {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 9999;
+ overflow: hidden;
+}
+
+#cboxOverlay {
+ position: fixed;
+ width: 100%;
+ height: 100%;
+}
+
+#cboxMiddleLeft,
+#cboxBottomLeft {
+ clear: left;
+}
+
+#cboxContent {
+ position: relative;
+}
+
+#cboxLoadedContent {
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+#cboxTitle {
+ margin: 0;
+}
+
+#cboxLoadingOverlay,
+#cboxLoadingGraphic {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+#cboxPrevious,
+#cboxNext,
+#cboxClose,
+#cboxSlideshow {
+ cursor: pointer;
+}
+
+.cboxPhoto {
+ float: left;
+ margin: auto;
+ border: 0;
+ display: block;
+ max-width: none;
+ -ms-interpolation-mode: bicubic;
+}
+
+.cboxIframe {
+ width: 100%;
+ height: 100%;
+ display: block;
+ border: 0;
+}
+
+#colorbox,
+#cboxContent,
+#cboxLoadedContent {
+ box-sizing: content-box;
+ -moz-box-sizing: content-box;
+ -webkit-box-sizing: content-box;
+}
+
+/*
+ User Style:
+ Change the following styles to modify the appearance of Colorbox. They are
+ ordered & tabbed in a way that represents the nesting of the generated HTML.
+*/
+#cboxOverlay {
+ background: #fff;
+}
+
+#colorbox {
+ outline: 0;
+}
+
+#cboxTopLeft {
+ width: 25px;
+ height: 25px;
+ background: url(images/border1.png) no-repeat 0 0;
+}
+
+#cboxTopCenter {
+ height: 25px;
+ background: url(images/border1.png) repeat-x 0 -50px;
+}
+
+#cboxTopRight {
+ width: 25px;
+ height: 25px;
+ background: url(images/border1.png) no-repeat -25px 0;
+}
+
+#cboxBottomLeft {
+ width: 25px;
+ height: 25px;
+ background: url(images/border1.png) no-repeat 0 -25px;
+}
+
+#cboxBottomCenter {
+ height: 25px;
+ background: url(images/border1.png) repeat-x 0 -75px;
+}
+
+#cboxBottomRight {
+ width: 25px;
+ height: 25px;
+ background: url(images/border1.png) no-repeat -25px -25px;
+}
+
+#cboxMiddleLeft {
+ width: 25px;
+ background: url(images/border2.png) repeat-y 0 0;
+}
+
+#cboxMiddleRight {
+ width: 25px;
+ background: url(images/border2.png) repeat-y -25px 0;
+}
+
+#cboxContent {
+ background: #fff;
+ overflow: hidden;
+}
+
+.cboxIframe {
+ background: #fff;
+}
+
+#cboxError {
+ padding: 50px;
+ border: 1px solid #ccc;
+}
+
+#cboxLoadedContent {
+ margin-bottom: 20px;
+}
+
+#cboxTitle {
+ position: absolute;
+ bottom: 0px;
+ left: 0;
+ text-align: center;
+ width: 100%;
+ color: #999;
+}
+
+#cboxCurrent {
+ position: absolute;
+ bottom: 0px;
+ left: 100px;
+ color: #999;
+}
+
+#cboxLoadingOverlay {
+ background: #fff url(images/loading.gif) no-repeat 5px 5px;
+}
+
+/* these elements are buttons, and may need to have additional styles reset to avoid unwanted base styles */
+#cboxPrevious,
+#cboxNext,
+#cboxSlideshow,
+#cboxClose {
+ border: 0;
+ padding: 0;
+ margin: 0;
+ overflow: visible;
+ width: auto;
+ background: none;
+}
+
+/* avoid outlines on :active (mouseclick), but preserve outlines on :focus (tabbed navigating) */
+#cboxPrevious:active,
+#cboxNext:active,
+#cboxSlideshow:active,
+#cboxClose:active {
+ outline: 0;
+}
+
+#cboxSlideshow {
+ position: absolute;
+ bottom: 0px;
+ right: 42px;
+ color: #444;
+}
+
+#cboxPrevious {
+ position: absolute;
+ bottom: 0px;
+ left: 0;
+ color: #444;
+}
+
+#cboxNext {
+ position: absolute;
+ bottom: 0px;
+ left: 63px;
+ color: #444;
+}
+
+#cboxClose {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: block;
+ color: #444;
+}
+
+/*
+ The following fixes a problem where IE7 and IE8 replace a PNG's alpha transparency with a black fill
+ when an alpha filter (opacity change) is set on the element or ancestor element. This style is not applied to or needed in IE9.
+ See: http://jacklmoore.com/notes/ie-transparency-problems/
+*/
+.cboxIE #cboxTopLeft,
+.cboxIE #cboxTopCenter,
+.cboxIE #cboxTopRight,
+.cboxIE #cboxBottomLeft,
+.cboxIE #cboxBottomCenter,
+.cboxIE #cboxBottomRight,
+.cboxIE #cboxMiddleLeft,
+.cboxIE #cboxMiddleRight {
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#00FFFFFF, endColorstr=#00FFFFFF);
+}
diff --git a/gn_auth/static/css/docs.css b/gn_auth/static/css/docs.css
new file mode 100644
index 0000000..665559e
--- /dev/null
+++ b/gn_auth/static/css/docs.css
@@ -0,0 +1,1080 @@
+/* Add additional stylesheets below
+-------------------------------------------------- */
+/*
+ Bootstrap's documentation styles
+ Special styles for presenting Bootstrap's documentation and examples
+*/
+
+
+
+/* Body and structure
+-------------------------------------------------- */
+
+body {
+ position: relative;
+ padding-top: 0px;
+}
+
+/* Code in headings */
+h3 code {
+ font-size: 14px;
+ font-weight: normal;
+}
+
+
+
+/* Tweak navbar brand link to be super sleek
+-------------------------------------------------- */
+/*
+body > .navbar {
+ font-size: 12px;
+ font-weight: bold;
+}
+*/
+
+/* Change the docs' brand */
+
+body>.navbar .navbar-brand {
+ padding-right: 20px;
+ padding-left: 20px;
+ margin-left: 20px;
+ float: left;
+ font-weight: bold;
+ color: #ffffff;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, .1), 0 0 30px rgba(255, 255, 255, .125);
+ -webkit-transition: all .2s linear;
+ -moz-transition: all .2s linear;
+ transition: all .2s linear;
+}
+
+body>.navbar .brand:hover {
+ text-decoration: none;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, .1), 0 0 30px rgba(255, 255, 255, .4);
+}
+
+
+/* Sections
+-------------------------------------------------- */
+
+/* padding for in-page bookmarks and fixed navbar */
+section {
+ padding-top: 0px;
+}
+
+section>.page-header,
+section>.lead {
+ color: #5a5a5a;
+}
+
+section>ul li {
+ margin-bottom: 5px;
+}
+
+/* Separators (hr) */
+.bs-docs-separator {
+ margin: 40px 0 39px;
+}
+
+/* Faded out hr */
+hr.soften {
+ height: 1px;
+ margin: 70px 0;
+ background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0), rgba(0, 0, 0, .1), rgba(0, 0, 0, 0));
+ background-image: -moz-linear-gradient(left, rgba(0, 0, 0, 0), rgba(0, 0, 0, .1), rgba(0, 0, 0, 0));
+ background-image: -ms-linear-gradient(left, rgba(0, 0, 0, 0), rgba(0, 0, 0, .1), rgba(0, 0, 0, 0));
+ background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0), rgba(0, 0, 0, .1), rgba(0, 0, 0, 0));
+ border: 0;
+}
+
+
+
+/* Jumbotrons
+-------------------------------------------------- */
+
+/* Base class
+------------------------- */
+.jumbotron {
+ position: relative;
+ padding: 0px 0;
+ color: black;
+ text-align: left;
+ text-shadow: 0 1px 3px rgba(0, 0, 0, .4), 0 0 30px rgba(0, 0, 0, .075);
+ background: #d5d5d5;
+ /* Old browsers */
+
+}
+
+.jumbotron h1 {
+ font-size: 60px;
+ font-weight: bold;
+ letter-spacing: -1px;
+ line-height: 1;
+}
+
+.jumbotron p {
+ font-size: 20px;
+ font-weight: 300;
+ line-height: 20px;
+ margin-bottom: 10px;
+}
+
+/* Link styles (used on .masthead-links as well) */
+.jumbotron a {
+ color: #336699;
+ color: rgba(255, 255, 255, .5);
+ -webkit-transition: all .2s ease-in-out;
+ -moz-transition: all .2s ease-in-out;
+ transition: all .2s ease-in-out;
+}
+
+.jumbotron a:hover {
+ color: #336699;
+ text-shadow: 0 0 10px rgba(255, 255, 255, .25);
+}
+
+/* Download button */
+.masthead .btn {
+ padding: 14px 24px;
+ font-size: 24px;
+ font-weight: 200;
+ color: #fff;
+ /* redeclare to override the `.jumbotron a` */
+ border: 0;
+ -webkit-border-radius: 6px;
+ -moz-border-radius: 6px;
+ border-radius: 6px;
+ -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 5px rgba(0, 0, 0, .25);
+ -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 5px rgba(0, 0, 0, .25);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 5px rgba(0, 0, 0, .25);
+ -webkit-transition: none;
+ -moz-transition: none;
+ transition: none;
+}
+
+.masthead .btn:hover {
+ -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 5px rgba(0, 0, 0, .25);
+ -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 5px rgba(0, 0, 0, .25);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 5px rgba(0, 0, 0, .25);
+}
+
+.masthead .btn:active {
+ -webkit-box-shadow: inset 0 2px 4px rgba(0, 0, 0, .1), 0 1px 0 rgba(255, 255, 255, .1);
+ -moz-box-shadow: inset 0 2px 4px rgba(0, 0, 0, .1), 0 1px 0 rgba(255, 255, 255, .1);
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, .1), 0 1px 0 rgba(255, 255, 255, .1);
+}
+
+
+/* Pattern overlay
+------------------------- */
+.jumbotron .container {
+ position: relative;
+ z-index: 2;
+}
+
+.jumbotron:after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ /*background: url(../img/bs-docs-masthead-pattern.png) repeat center center;*/
+ opacity: .4;
+}
+
+/* Masthead (docs home)
+------------------------- */
+.masthead {
+ padding: 70px 0 80px;
+ margin-bottom: 0;
+ color: #fff;
+}
+
+.masthead h1 {
+ font-size: 120px;
+ line-height: 1;
+ letter-spacing: -2px;
+}
+
+.masthead p {
+ font-size: 40px;
+ font-weight: 200;
+ line-height: 1.25;
+}
+
+/* Textual links in masthead */
+.masthead-links {
+ margin: 0;
+ list-style: none;
+}
+
+.masthead-links li {
+ display: inline;
+ padding: 0 10px;
+ color: rgba(255, 255, 255, .25);
+}
+
+/* Social proof buttons from GitHub & Twitter */
+.bs-docs-social {
+ padding: 15px 0;
+ text-align: center;
+ background-color: #f5f5f5;
+ border-top: 1px solid #fff;
+ border-bottom: 1px solid #ddd;
+}
+
+/* Quick links on Home */
+.bs-docs-social-buttons {
+ margin-left: 0;
+ margin-bottom: 0;
+ padding-left: 0;
+ list-style: none;
+}
+
+.bs-docs-social-buttons li {
+ display: inline-block;
+ padding: 5px 8px;
+ line-height: 1;
+ *display: inline;
+ *zoom: 1;
+}
+
+/* Subhead (other pages)
+------------------------- */
+.subhead {
+ text-align: left;
+ border-bottom: 1px solid #ddd;
+}
+
+.subhead h1 {
+ font-size: 30px;
+}
+
+.subhead p {
+ margin-bottom: 10px;
+}
+
+.subhead .navbar {
+ display: none;
+}
+
+
+
+/* Marketing section of Overview
+-------------------------------------------------- */
+
+.marketing {
+ text-align: center;
+ color: #5a5a5a;
+}
+
+.marketing h1 {
+ margin: 60px 0 10px;
+ font-size: 60px;
+ font-weight: 200;
+ line-height: 1;
+ letter-spacing: -1px;
+}
+
+.marketing h2 {
+ font-weight: 200;
+ margin-bottom: 5px;
+}
+
+.marketing p {
+ font-size: 16px;
+ line-height: 1.5;
+}
+
+.marketing .marketing-byline {
+ margin-bottom: 40px;
+ font-size: 20px;
+ font-weight: 300;
+ line-height: 25px;
+ color: #999;
+}
+
+.marketing img {
+ display: block;
+ margin: 0 auto 30px;
+}
+
+
+
+/* Footer
+-------------------------------------------------- */
+
+.footer {
+ padding: 70px 0;
+ margin-top: 70px;
+ border-top: 1px solid #e5e5e5;
+ background-color: #f5f5f5;
+}
+
+.footer p {
+ margin-bottom: 0;
+ color: #777;
+}
+
+.footer-links {
+ margin: 10px 0;
+}
+
+.footer-links li {
+ display: inline;
+ margin-right: 10px;
+}
+
+
+
+/* Special grid styles
+-------------------------------------------------- */
+
+.show-grid {
+ margin-top: 10px;
+ margin-bottom: 20px;
+}
+
+.show-grid [class*="span"] {
+ background-color: #eee;
+ text-align: center;
+ -webkit-border-radius: 3px;
+ -moz-border-radius: 3px;
+ border-radius: 3px;
+ min-height: 40px;
+ line-height: 40px;
+}
+
+.show-grid:hover [class*="span"] {
+ background: #ddd;
+}
+
+.show-grid .show-grid {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+.show-grid .show-grid [class*="span"] {
+ background-color: #ccc;
+}
+
+
+
+/* Mini layout previews
+-------------------------------------------------- */
+.mini-layout {
+ border: 1px solid #ddd;
+ -webkit-border-radius: 6px;
+ -moz-border-radius: 6px;
+ border-radius: 6px;
+ -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
+ -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
+}
+
+.mini-layout,
+.mini-layout .mini-layout-body,
+.mini-layout.fluid .mini-layout-sidebar {
+ height: 300px;
+}
+
+.mini-layout {
+ margin-bottom: 20px;
+ padding: 9px;
+}
+
+.mini-layout div {
+ -webkit-border-radius: 3px;
+ -moz-border-radius: 3px;
+ border-radius: 3px;
+}
+
+.mini-layout .mini-layout-body {
+ background-color: #dceaf4;
+ margin: 0 auto;
+ width: 70%;
+}
+
+.mini-layout.fluid .mini-layout-sidebar,
+.mini-layout.fluid .mini-layout-header,
+.mini-layout.fluid .mini-layout-body {
+ float: left;
+}
+
+.mini-layout.fluid .mini-layout-sidebar {
+ background-color: #bbd8e9;
+ width: 20%;
+}
+
+.mini-layout.fluid .mini-layout-body {
+ width: 77.5%;
+ margin-left: 2.5%;
+}
+
+
+
+/* Download page
+-------------------------------------------------- */
+
+.download .page-header {
+ margin-top: 36px;
+}
+
+.page-header .toggle-all {
+ margin-top: 5px;
+}
+
+/* Space out h3s when following a section */
+.download h3 {
+ margin-bottom: 5px;
+}
+
+.download-builder input+h3,
+.download-builder .checkbox+h3 {
+ margin-top: 9px;
+}
+
+/* Fields for variables */
+.download-builder input[type=text] {
+ margin-bottom: 9px;
+ font-family: Menlo, Monaco, "Courier New", monospace;
+ font-size: 12px;
+ color: #d14;
+}
+
+.download-builder input[type=text]:focus {
+ background-color: #fff;
+}
+
+/* Custom, larger checkbox labels */
+.download .checkbox {
+ padding: 6px 10px 6px 25px;
+ font-size: 13px;
+ line-height: 18px;
+ color: #555;
+ background-color: #f9f9f9;
+ -webkit-border-radius: 3px;
+ -moz-border-radius: 3px;
+ border-radius: 3px;
+ cursor: pointer;
+}
+
+.download .checkbox:hover {
+ color: #333;
+ background-color: #f5f5f5;
+}
+
+.download .checkbox small {
+ font-size: 12px;
+ color: #777;
+}
+
+/* Variables section */
+#variables label {
+ margin-bottom: 0;
+}
+
+/* Giant download button */
+.download-btn {
+ margin: 36px 0 108px;
+}
+
+#download p,
+#download h4 {
+ max-width: 50%;
+ margin: 0 auto;
+ color: #999;
+ text-align: center;
+}
+
+#download h4 {
+ margin-bottom: 0;
+}
+
+#download p {
+ margin-bottom: 18px;
+}
+
+.download-btn .btn {
+ display: block;
+ width: auto;
+ padding: 19px 24px;
+ margin-bottom: 27px;
+ font-size: 30px;
+ line-height: 1;
+ text-align: center;
+ -webkit-border-radius: 6px;
+ -moz-border-radius: 6px;
+ border-radius: 6px;
+}
+
+
+
+/* Misc
+-------------------------------------------------- */
+
+/* Make tables spaced out a bit more */
+h2+table,
+h3+table,
+h4+table,
+h2+.row {
+ margin-top: 5px;
+}
+
+/* Example sites showcase */
+.example-sites {
+ xmargin-left: 20px;
+}
+
+.example-sites img {
+ max-width: 100%;
+ margin: 0 auto;
+}
+
+.scrollspy-example {
+ height: 200px;
+ overflow: auto;
+ position: relative;
+}
+
+
+/* Fake the :focus state to demo it */
+.focused {
+ border-color: rgba(82, 168, 236, .8);
+ -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .1), 0 0 8px rgba(82, 168, 236, .6);
+ -moz-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .1), 0 0 8px rgba(82, 168, 236, .6);
+ box-shadow: inset 0 1px 3px rgba(0, 0, 0, .1), 0 0 8px rgba(82, 168, 236, .6);
+ outline: 0;
+}
+
+/* For input sizes, make them display block */
+.docs-input-sizes select,
+.docs-input-sizes input[type=text] {
+ display: block;
+ margin-bottom: 9px;
+}
+
+/* Icons
+------------------------- */
+.the-icons {
+ margin-left: 0;
+ list-style: none;
+}
+
+.the-icons li {
+ float: left;
+ width: 25%;
+ line-height: 25px;
+}
+
+.the-icons i:hover {
+ background-color: rgba(255, 0, 0, .25);
+}
+
+/* Example page
+------------------------- */
+.bootstrap-examples p {
+ font-size: 13px;
+ line-height: 18px;
+}
+
+.bootstrap-examples .thumbnail {
+ margin-bottom: 9px;
+ background-color: #fff;
+}
+
+
+
+/* Bootstrap code examples
+-------------------------------------------------- */
+
+/* Base class */
+.bs-docs-example {
+ position: relative;
+ margin: 15px 0;
+ padding: 39px 19px 14px;
+ *padding-top: 19px;
+ background-color: #fff;
+ border: 1px solid #ddd;
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ border-radius: 4px;
+}
+
+
+/* Remove spacing between an example and it's code */
+.bs-docs-example+.prettyprint {
+ margin-top: -20px;
+ padding-top: 15px;
+}
+
+/* Tweak examples
+------------------------- */
+.bs-docs-example>p:last-child {
+ margin-bottom: 0;
+}
+
+.bs-docs-example .table,
+.bs-docs-example .progress,
+.bs-docs-example .well,
+.bs-docs-example .alert,
+.bs-docs-example .hero-unit,
+.bs-docs-example .pagination,
+.bs-docs-example .navbar,
+.bs-docs-example>.nav,
+.bs-docs-example blockquote {
+ margin-bottom: 5px;
+}
+
+.bs-docs-example .pagination {
+ margin-top: 0;
+}
+
+.bs-navbar-top-example,
+.bs-navbar-bottom-example {
+ z-index: 1;
+ padding: 0;
+ height: 90px;
+ overflow: hidden;
+ /* cut the drop shadows off */
+}
+
+.bs-navbar-top-example .navbar-fixed-top,
+.bs-navbar-bottom-example .navbar-fixed-bottom {
+ margin-left: 0;
+ margin-right: 0;
+}
+
+.bs-navbar-top-example {
+ -webkit-border-radius: 0 0 4px 4px;
+ -moz-border-radius: 0 0 4px 4px;
+ border-radius: 0 0 4px 4px;
+}
+
+.bs-navbar-top-example:after {
+ top: auto;
+ bottom: -1px;
+ -webkit-border-radius: 0 4px 0 4px;
+ -moz-border-radius: 0 4px 0 4px;
+ border-radius: 0 4px 0 4px;
+}
+
+.bs-navbar-bottom-example {
+ -webkit-border-radius: 4px 4px 0 0;
+ -moz-border-radius: 4px 4px 0 0;
+ border-radius: 4px 4px 0 0;
+}
+
+.bs-navbar-bottom-example .navbar {
+ margin-bottom: 0;
+}
+
+form.bs-docs-example {
+ padding-bottom: 19px;
+}
+
+/* Images */
+.bs-docs-example-images img {
+ margin: 10px;
+ display: inline-block;
+}
+
+/* Tooltips */
+.bs-docs-tooltip-examples {
+ text-align: center;
+ margin: 0 0 10px;
+ list-style: none;
+}
+
+.bs-docs-tooltip-examples li {
+ display: inline;
+ padding: 0 10px;
+}
+
+/* Popovers */
+.bs-docs-example-popover {
+ padding-bottom: 24px;
+ background-color: #f9f9f9;
+}
+
+.bs-docs-example-popover .popover {
+ position: relative;
+ display: block;
+ float: left;
+ width: 260px;
+ margin: 20px;
+}
+
+
+
+/* Responsive docs
+-------------------------------------------------- */
+
+/* Utility classes table
+------------------------- */
+.responsive-utilities th small {
+ display: block;
+ font-weight: normal;
+ color: #999;
+}
+
+.responsive-utilities tbody th {
+ font-weight: normal;
+}
+
+.responsive-utilities td {
+ text-align: center;
+}
+
+.responsive-utilities td.is-visible {
+ color: #468847;
+ background-color: #dff0d8 !important;
+}
+
+.responsive-utilities td.is-hidden {
+ color: #ccc;
+ background-color: #f9f9f9 !important;
+}
+
+/* Responsive tests
+------------------------- */
+.responsive-utilities-test {
+ margin-top: 5px;
+ margin-left: 0;
+ list-style: none;
+ overflow: hidden;
+ /* clear floats */
+}
+
+.responsive-utilities-test li {
+ position: relative;
+ float: left;
+ width: 25%;
+ height: 43px;
+ font-size: 14px;
+ font-weight: bold;
+ line-height: 43px;
+ color: #999;
+ text-align: center;
+ border: 1px solid #ddd;
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ border-radius: 4px;
+}
+
+.responsive-utilities-test li+li {
+ margin-left: 10px;
+}
+
+.responsive-utilities-test span {
+ position: absolute;
+ top: -1px;
+ left: -1px;
+ right: -1px;
+ bottom: -1px;
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ border-radius: 4px;
+}
+
+.responsive-utilities-test span {
+ color: #468847;
+ background-color: #dff0d8;
+ border: 1px solid #d6e9c6;
+}
+
+
+
+/* Sidenav for Docs
+-------------------------------------------------- */
+
+.bs-docs-sidenav {
+ width: 228px;
+ margin: 30px 0 0;
+ padding: 0;
+ background-color: #fff;
+ -webkit-border-radius: 6px;
+ -moz-border-radius: 6px;
+ border-radius: 6px;
+ -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, .065);
+ -moz-box-shadow: 0 1px 4px rgba(0, 0, 0, .065);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, .065);
+}
+
+.bs-docs-sidenav>li>a {
+ display: block;
+ *width: 190px;
+ margin: 0 0 -1px;
+ padding: 8px 14px;
+ border: 1px solid #e5e5e5;
+}
+
+.bs-docs-sidenav>li:first-child>a {
+ -webkit-border-radius: 6px 6px 0 0;
+ -moz-border-radius: 6px 6px 0 0;
+ border-radius: 6px 6px 0 0;
+}
+
+.bs-docs-sidenav>li:last-child>a {
+ -webkit-border-radius: 0 0 6px 6px;
+ -moz-border-radius: 0 0 6px 6px;
+ border-radius: 0 0 6px 6px;
+}
+
+.bs-docs-sidenav>.active>a {
+ position: relative;
+ z-index: 2;
+ padding: 9px 15px;
+ border: 0;
+ text-shadow: 0 1px 0 rgba(0, 0, 0, .15);
+ -webkit-box-shadow: inset 1px 0 0 rgba(0, 0, 0, .1), inset -1px 0 0 rgba(0, 0, 0, .1);
+ -moz-box-shadow: inset 1px 0 0 rgba(0, 0, 0, .1), inset -1px 0 0 rgba(0, 0, 0, .1);
+ box-shadow: inset 1px 0 0 rgba(0, 0, 0, .1), inset -1px 0 0 rgba(0, 0, 0, .1);
+}
+
+/* Chevrons */
+.bs-docs-sidenav .icon-chevron-right {
+ float: right;
+ margin-top: 2px;
+ margin-right: -6px;
+ opacity: .25;
+}
+
+.bs-docs-sidenav>li>a:hover {
+ background-color: #f5f5f5;
+}
+
+.bs-docs-sidenav a:hover .icon-chevron-right {
+ opacity: .5;
+}
+
+.bs-docs-sidenav .active .icon-chevron-right,
+.bs-docs-sidenav .active a:hover .icon-chevron-right {
+ background-image: url(../img/glyphicons-halflings-white.png);
+ opacity: 1;
+}
+
+.bs-docs-sidenav.affix {
+ top: 40px;
+}
+
+.bs-docs-sidenav.affix-bottom {
+ position: absolute;
+ top: auto;
+ bottom: 270px;
+}
+
+
+
+
+/* Responsive
+-------------------------------------------------- */
+
+/* Desktop large
+------------------------- */
+@media (min-width: 1200px) {
+ .bs-docs-container {
+ max-width: 970px;
+ }
+
+ .bs-docs-sidenav {
+ width: 258px;
+ }
+}
+
+/* Desktop
+------------------------- */
+@media (max-width: 980px) {
+
+ /* Unfloat brand */
+ body>.navbar-fixed-top .brand {
+ float: left;
+ margin-left: 0;
+ padding-left: 10px;
+ padding-right: 10px;
+ }
+
+ /* Inline-block quick links for more spacing */
+ .quick-links li {
+ display: inline-block;
+ margin: 5px;
+ }
+
+ /* When affixed, space properly */
+ .bs-docs-sidenav {
+ top: 0;
+ margin-top: 30px;
+ margin-right: 0;
+ }
+}
+
+/* Tablet to desktop
+------------------------- */
+@media (min-width: 768px) and (max-width: 980px) {
+
+ /* Remove any padding from the body */
+ body {
+ padding-top: 0;
+ }
+
+ /* Widen masthead and social buttons to fill body padding */
+ .jumbotron {
+ margin-top: -20px;
+ /* Offset bottom margin on .navbar */
+ }
+
+ /* Adjust sidenav width */
+ .bs-docs-sidenav {
+ width: 166px;
+ margin-top: 20px;
+ }
+
+ .bs-docs-sidenav.affix {
+ top: 0;
+ }
+}
+
+/* Tablet
+------------------------- */
+@media (max-width: 767px) {
+
+ /* Remove any padding from the body */
+ body {
+ padding-top: 0;
+ }
+
+ /* Widen masthead and social buttons to fill body padding */
+ .jumbotron {
+ padding: 40px 20px;
+ margin-top: -20px;
+ /* Offset bottom margin on .navbar */
+ margin-right: -20px;
+ margin-left: -20px;
+ }
+
+ .masthead h1 {
+ font-size: 90px;
+ }
+
+ .masthead p,
+ .masthead .btn {
+ font-size: 24px;
+ }
+
+ .marketing .span4 {
+ margin-bottom: 40px;
+ }
+
+ .bs-docs-social {
+ margin: 0 -20px;
+ }
+
+ /* Space out the show-grid examples */
+ .show-grid [class*="span"] {
+ margin-bottom: 5px;
+ }
+
+ /* Sidenav */
+ .bs-docs-sidenav {
+ width: auto;
+ margin-bottom: 20px;
+ }
+
+ .bs-docs-sidenav.affix {
+ position: static;
+ width: auto;
+ top: 0;
+ }
+
+ /* Unfloat the back to top link in footer */
+ .footer {
+ margin-left: -20px;
+ margin-right: -20px;
+ padding-left: 20px;
+ padding-right: 20px;
+ }
+
+ .footer p {
+ margin-bottom: 9px;
+ }
+}
+
+/* Landscape phones
+------------------------- */
+@media (max-width: 480px) {
+
+ /* Remove padding above jumbotron */
+ body {
+ padding-top: 0;
+ }
+
+ /* Change up some type stuff */
+ h2 small {
+ display: block;
+ }
+
+ /* Downsize the jumbotrons */
+ .jumbotron h1 {
+ font-size: 40px;
+ }
+
+ .jumbotron p,
+ .jumbotron .btn {
+ font-size: 20px;
+ }
+
+ .jumbotron .btn {
+ display: block;
+ margin: 0 auto;
+ }
+
+ /* center align subhead text like the masthead */
+ .subhead h1,
+ .subhead p {
+ text-align: left;
+ }
+
+ /* Marketing on home */
+ .marketing h1 {
+ font-size: 40px;
+ }
+
+ /* center example sites */
+ .example-sites {
+ margin-left: 0;
+ }
+
+ .example-sites>li {
+ float: none;
+ display: block;
+ max-width: 280px;
+ margin: 0 auto 18px;
+ text-align: center;
+ }
+
+ .example-sites .thumbnail>img {
+ max-width: 270px;
+ }
+
+ /* Do our best to make tables work in narrow viewports */
+ table code {
+ white-space: normal;
+ word-wrap: break-word;
+ word-break: break-all;
+ }
+
+ /* Modal example */
+ .modal-example .modal {
+ position: relative;
+ top: auto;
+ right: auto;
+ bottom: auto;
+ left: auto;
+ }
+
+ /* Unfloat the back to top in footer to prevent odd text wrapping */
+ .footer .pull-right {
+ float: none;
+ }
+} \ No newline at end of file
diff --git a/gn_auth/static/css/non-responsive.css b/gn_auth/static/css/non-responsive.css
new file mode 100644
index 0000000..a4bcddd
--- /dev/null
+++ b/gn_auth/static/css/non-responsive.css
@@ -0,0 +1,114 @@
+/* Template-specific stuff
+ *
+ * Customizations just for the template; these are not necessary for anything
+ * with disabling the responsiveness.
+ */
+
+/* Account for fixed navbar */
+body {
+ //min-width: 1200px;
+ padding-top: 70px;
+ padding-bottom: 30px;
+}
+
+/* Finesse the page header spacing */
+.page-header {
+ margin-bottom: 10px;
+}
+
+.page-header .lead {
+ margin-bottom: 10px;
+}
+
+
+/* Non-responsive overrides
+ *
+ * Utilitze the following CSS to disable the responsive-ness of the container,
+ * grid system, and navbar.
+ */
+
+/* Reset the container */
+.container {
+ width: 100%;
+ max-width: none !important;
+}
+
+
+.container .navbar-header,
+.container .navbar-collapse {
+ margin-right: 0;
+ margin-left: 0;
+}
+
+/* Always float the navbar header */
+.navbar-header {
+ float: left;
+}
+
+/* Undo the collapsing navbar */
+.navbar-collapse {
+ display: block !important;
+ height: auto !important;
+ padding-bottom: 0;
+ overflow: visible !important;
+}
+
+.navbar-toggle {
+ display: none;
+}
+
+.navbar-collapse {
+ border-top: 0;
+}
+
+/* Always apply the floated nav */
+.navbar-nav {
+ float: left;
+ margin: 0;
+}
+
+.navbar-nav>li {
+ float: left;
+}
+
+.navbar-nav>li>a {
+ padding: 5px;
+}
+
+/* Redeclare since we override the float above */
+.navbar-nav.navbar-right {
+ float: right;
+}
+
+/* Undo custom dropdowns */
+.navbar .navbar-nav .open .dropdown-menu {
+ position: absolute;
+ float: left;
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border: 1px solid rgba(0, 0, 0, .15);
+ border-width: 0 1px 1px;
+ border-radius: 0 0 4px 4px;
+ -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
+ box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
+}
+
+.navbar-default .navbar-nav .open .dropdown-menu>li>a {
+ color: #333;
+}
+
+.navbar .navbar-nav .open .dropdown-menu>li>a:hover,
+.navbar .navbar-nav .open .dropdown-menu>li>a:focus,
+.navbar .navbar-nav .open .dropdown-menu>.active>a,
+.navbar .navbar-nav .open .dropdown-menu>.active>a:hover,
+.navbar .navbar-nav .open .dropdown-menu>.active>a:focus {
+ color: #fff !important;
+ background-color: #3071a9 !important;
+}
+
+.navbar .navbar-nav .open .dropdown-menu>.disabled>a,
+.navbar .navbar-nav .open .dropdown-menu>.disabled>a:hover,
+.navbar .navbar-nav .open .dropdown-menu>.disabled>a:focus {
+ color: #999 !important;
+ background-color: transparent !important;
+} \ No newline at end of file
diff --git a/gn_auth/static/css/parsley.css b/gn_auth/static/css/parsley.css
new file mode 100644
index 0000000..7d24457
--- /dev/null
+++ b/gn_auth/static/css/parsley.css
@@ -0,0 +1,20 @@
+/* Adapted from parsleyjs.org/documentation.html#parsleyclasses */
+
+input.parsley-success, textarea.parsley-success {
+ color: #468847 !important;
+ background-color: #DFF0D8 !important;
+ border: 1px solid #D6E9C6 !important;
+}
+input.parsley-error, textarea.parsley-error {
+ color: #B94A48 !important;
+ background-color: #F2DEDE !important;
+ border: 1px solid #EED3D7 !important;
+}
+ul.parsley-error-list {
+ font-size: 11px;
+ margin: 2px;
+ list-style-type:none;
+}
+ul.parsley-error-list li {
+ line-height: 11px;
+} \ No newline at end of file
diff --git a/gn_auth/templates/404.html b/gn_auth/templates/404.html
deleted file mode 100644
index e17bfe8..0000000
--- a/gn_auth/templates/404.html
+++ /dev/null
@@ -1,13 +0,0 @@
-{%extends "base.html"%}
-
-{%block title%}404: Page Not Found{%endblock%}
-
-{%block pagetitle%}404: Could Not Find the Requested Page{%endblock%}
-
-{%block content%}
-
-<p>
- The page "<strong>{{page}}</strong>" does not exist on this server.
-</p>
-
-{%endblock%}
diff --git a/gn_auth/templates/base.html b/gn_auth/templates/base.html
index b452ca1..d80096d 100644
--- a/gn_auth/templates/base.html
+++ b/gn_auth/templates/base.html
@@ -5,24 +5,24 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>gn-auth: {%block title%}{%endblock%}</title>
+ <title>Authorization {%block title%}{%endblock%}</title>
<link rel="stylesheet" type="text/css"
- href="https://genenetwork.org/static/new/css/bootstrap-custom.css" />
+ href="{{url_for('static', filename='css/bootstrap-custom.css')}}" />
<link rel="stylesheet" type="text/css"
- href="https://genenetwork.org/static/new/css/non-responsive.css" />
+ href="{{url_for('static', filename='css/non-responsive.css')}}" />
<link rel="stylesheet" type="text/css"
href="{{url_for('static', filename='css/styles.css')}}" />
<link rel="stylesheet" type="text/css"
- href="https://genenetwork.org/static/new/css/docs.css" />
+ href="{{url_for('static', filename='css/docs.css')}}" />
<link rel="stylesheet" type="text/css"
- href="https://genenetwork.org/static/new/css/colorbox.css" />
+ href="{{url_for('static', filename='css/colorbox.css')}}" />
<link rel="stylesheet" type="text/css"
- href="https://genenetwork.org/static/new/css/parsley.css" />
+ href="{{url_for('static', filename='css/parsley.css')}}" />
<link rel="stylesheet" type="text/css"
- href="https://genenetwork.org/static/new/css/broken_links.css" />
+ href="{{url_for('static', filename='css/broken_links.css')}}" />
<link rel="stylesheet"
- href="https://genenetwork.org/static/new/css/autocomplete.css" />
+ href="{{url_for('static', filename='css/autocomplete.css')}}" />
{%block css%}{%endblock%}
</head>
@@ -39,7 +39,7 @@
style="font-weight: bold;">GeneNetwork</a>
</li>
<li>
- <a href="#">gn-auth: {%block pagetitle%}{%endblock%}</a>
+ <a href="#">{%block pagetitle%}{%endblock%}</a>
</li>
</ul>
</div>
diff --git a/gn_auth/templates/emails/forgot-password.html b/gn_auth/templates/emails/forgot-password.html
index e40ebb8..5f16a02 100644
--- a/gn_auth/templates/emails/forgot-password.html
+++ b/gn_auth/templates/emails/forgot-password.html
@@ -24,7 +24,7 @@
</p>
<p style="font-weight: bold;color: #ee55ee;">
- The link will expire in <strong>{{expiration_minutes}}</strong> minutes.
+ The link will expire in <strong>{{expiration_minutes}}</strong>.
</p>
<hr />
diff --git a/gn_auth/templates/emails/forgot-password.txt b/gn_auth/templates/emails/forgot-password.txt
index 55a4b13..68abf16 100644
--- a/gn_auth/templates/emails/forgot-password.txt
+++ b/gn_auth/templates/emails/forgot-password.txt
@@ -7,6 +7,6 @@ You (or someone pretending to be you) made a request to change your password. Pl
If you did not request to change your password, simply ignore this email.
-The link will expire {{expiration_minutes}} minutes.
+The link will expire in {{expiration_minutes}}.
Note that if you requested to change your password multiple times, only the latest/newest token will be valid.
diff --git a/gn_auth/templates/emails/verify-email.html b/gn_auth/templates/emails/verify-email.html
index 7f85c1c..11ae575 100644
--- a/gn_auth/templates/emails/verify-email.html
+++ b/gn_auth/templates/emails/verify-email.html
@@ -20,7 +20,7 @@
<p style="font-weight: bold;color: #ee55ee;">
Please note that the verification code will expire in
- <strong>{{expiration_minutes}}</strong> minutes after it was generated.
+ <strong>{{expiration_minutes}}</strong> after it was generated.
</p>
</body>
</html>
diff --git a/gn_auth/templates/emails/verify-email.txt b/gn_auth/templates/emails/verify-email.txt
index 281d682..ecfbfc0 100644
--- a/gn_auth/templates/emails/verify-email.txt
+++ b/gn_auth/templates/emails/verify-email.txt
@@ -9,4 +9,4 @@ If that does not work, please log in to GeneNetwork and copy the verification co
{{verification_code}}
-Please note that the verification code will expire {{expiration_minutes}} minutes after it was generated.
+Please note that the verification code will expire {{expiration_minutes}} after it was generated.
diff --git a/gn_auth/templates/http-error-4xx.html b/gn_auth/templates/http-error-4xx.html
new file mode 100644
index 0000000..16c4581
--- /dev/null
+++ b/gn_auth/templates/http-error-4xx.html
@@ -0,0 +1,20 @@
+{%extends "base.html"%}
+
+{%block title%}{{error.code}}: {{error.name}}{%endblock%}
+
+{%block pagetitle%}{{error.code}}: {{error.name}}{%endblock%}
+
+{%block content%}
+
+<dl>
+ <dt>status code</dt>
+ <dd>{{error.code}}: {{error.name}}</dd>
+
+ <dt><strong>URI</strong></dt>
+ <dd>{{page}}</dd>
+
+ <dt>error description</dt>
+ <dd>{{description}}</dd>
+</dl>
+
+{%endblock%}
diff --git a/gn_auth/templates/50x.html b/gn_auth/templates/http-error-5xx.html
index 859a232..859a232 100644
--- a/gn_auth/templates/50x.html
+++ b/gn_auth/templates/http-error-5xx.html
diff --git a/gn_auth/templates/oauth2/authorise-user.html b/gn_auth/templates/oauth2/authorise-user.html
index 2ef22af..f186167 100644
--- a/gn_auth/templates/oauth2/authorise-user.html
+++ b/gn_auth/templates/oauth2/authorise-user.html
@@ -2,44 +2,65 @@
{%block title%}Authorise User{%endblock%}
-{%block pagetitle%}Authenticate to the API Server{%endblock%}
+{%block pagetitle%}{%endblock%}
{%block content%}
{{flash_messages()}}
+<div class="container" style="min-width: 1250px;">
+ <form method="POST"
+ class="form-horizontal"
+ action="{{url_for(
+ 'oauth2.auth.authorise',
+ response_type=response_type,
+ client_id=client.client_id,
+ redirect_uri=redirect_uri)}}"
+ style="max-width: 700px;">
+ <legend style="margin-top: 20px;">Sign In</legend>
-<form method="POST" action="{{url_for(
- 'oauth2.auth.authorise',
- response_type=response_type,
- client_id=client.client_id,
- redirect_uri=redirect_uri)}}">
- <input type="hidden" name="response_type" value="{{response_type}}" />
- <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" />
- <input type="hidden" name="scope" value="{{scope | join(' ')}}" />
- <input type="hidden" name="client_id" value="{{client.client_id}}" />
+ <input type="hidden" name="response_type" value="{{response_type}}" />
+ <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" />
+ <input type="hidden" name="scope" value="{{scope | join(' ')}}" />
+ <input type="hidden" name="client_id" value="{{client.client_id}}" />
- <legend>User Credentials</legend>
- <div class="form-group">
- <label for="user:email" class="form-label">Email</label>
- <input type="email" name="user:email" id="user:email" required="required"
- class="form-control"/>
- </div>
+ <div class="form-group">
+ <label for="user:email" class="control-label col-xs-2"
+ style="text-align: left;">Email</label>
+ <div class="col-xs-10">
+ <input type="email"
+ name="user:email"
+ id="user:email"
+ required="required"
+ class="form-control" />
+ </div>
+ </div>
- <div class="form-group">
- <label for="user:password" class="form-label">Password</label>
- <input type="password" name="user:password" id="user:password"
- required="required" class="form-control" />
- </div>
+ <div class="form-group">
+ <label for="user:password" class="control-label col-xs-2"
+ style="text-align: left;">Password</label>
+ <div class="col-xs-10">
+ <input type="password"
+ name="user:password"
+ id="user:password"
+ required="required"
+ class="form-control" />
+ </div>
+ </div>
- <div class="form-group">
- <input type="submit" value="authorise" class="btn btn-primary" />
- {%if display_forgot_password%}
- <a href="{{url_for('oauth2.users.forgot_password',
- client_id=client.client_id,
- redirect_uri=redirect_uri,
- response_type=response_type)}}"
- title="Click here to change your password."
- class="form-text text-danger">Forgot Password</a>
- {%endif%}
- </div>
-</form>
+ <div class="form-group">
+ <div class="controls col-xs-offset-2 col-xs-10">
+ <input type="submit" value="Sign in" class="btn btn-primary" />
+ {%if display_forgot_password%}
+ <a href="{{url_for('oauth2.users.forgot_password',
+ client_id=client.client_id,
+ redirect_uri=redirect_uri,
+ response_type=response_type)}}"
+ title="Click here to change your password."
+ class="form-text text-danger">Forgot Password</a>
+ {%endif%}
+ </div>
+ </div>
+ <hr>
+ <a href="{{ source_uri }}/oauth2/user/register" class="btn btn-primary" role="button">Create a New Account</a>
+ </form>
+</div>
{%endblock%}
diff --git a/gn_auth/wsgi.py b/gn_auth/wsgi.py
index bb8abd2..b5c4499 100644
--- a/gn_auth/wsgi.py
+++ b/gn_auth/wsgi.py
@@ -1,16 +1,15 @@
"""Main entry point for project"""
import os
+import re
+import secrets
import sys
import uuid
import json
-import logging
from math import ceil
from pathlib import Path
-from typing import Callable
-from datetime import datetime
+from datetime import datetime, timezone
import click
-from flask import Flask
from yoyo import get_backend, read_migrations
from gn_auth import migrations
@@ -18,41 +17,21 @@ from gn_auth import create_app
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
-from gn_auth.auth.authentication.users import user_by_id, hash_password
-from gn_auth.auth.authorisation.users.admin.models import make_sys_admin
+from gn_auth.auth.authentication.users import (
+ user_by_id, hash_password, save_user, set_user_password)
+from gn_auth.auth.authorisation.roles.models import assign_default_roles
+from gn_auth.auth.authorisation.users.admin.models import (
+ make_sys_admin, grant_sysadmin_role)
+from gn_auth.auth.authorisation.users.models import delete_users_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import (
+ OAuth2Client, save_client, delete_client,
+ client as oauth2_client_by_id)
+from gn_auth.scripts import register_sys_admin as rsysadm# type: ignore[import]
+from gn_auth.auth.authorisation.roles.models import assign_user_role_by_name
+from gn_auth.auth.authorisation.resources.system.models import system_resource
-from scripts import register_sys_admin as rsysadm# type: ignore[import]
-
-def dev_loggers(appl: Flask) -> None:
- """Setup the logging handlers."""
- stderr_handler = logging.StreamHandler(stream=sys.stderr)
- appl.logger.addHandler(stderr_handler)
-
- root_logger = logging.getLogger()
- root_logger.addHandler(stderr_handler)
- root_logger.setLevel(appl.config["LOGLEVEL"])
-
-
-def gunicorn_loggers(appl: Flask) -> None:
- """Use gunicorn logging handlers for the application."""
- logger = logging.getLogger("gunicorn.error")
- appl.logger.handlers = logger.handlers
- appl.logger.setLevel(logger.level)
-
-
-def setup_loggers() -> Callable[[Flask], None]:
- """
- Setup the loggers according to the WSGI server used to run the application.
- """
- # https://datatracker.ietf.org/doc/html/draft-coar-cgi-v11-03#section-4.1.17
- # https://wsgi.readthedocs.io/en/latest/proposals-2.0.html#making-some-keys-required
- # https://peps.python.org/pep-3333/#id4
- software, *_version_and_comments = os.environ.get(
- "SERVER_SOFTWARE", "").split('/')
- return gunicorn_loggers if bool(software) else dev_loggers
-
-app = create_app(setup_logging=setup_loggers())
+app = create_app()
##### BEGIN: CLI Commands #####
@@ -159,6 +138,401 @@ def register_admin():
"""Register the administrator."""
rsysadm.register_admin(Path(app.config["AUTH_DB"]))
+
+_VALID_ROLES_ = ("system-admin", "none", "batch-editors",
+ "systemwide-docs-editor", "systemwide-data-curator")
+
+_TEST_EMAIL_DOMAIN_ = "regression-tests.genenetwork.org"
+
+
+def __normalise_name_for_email__(name: str) -> str:
+ """Lowercase and strip non-alphanumeric characters for use in an email."""
+ return re.sub(r"[^a-z0-9]", "", name.lower())
+
+
+def __create_one_user__(#pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor: db.DbCursor,
+ name: str,
+ email: str,
+ password: str,
+ role: str,
+ sys_resource_id: uuid.UUID
+) -> dict:
+ """Create a single user in the DB and return their credential record."""
+ user = save_user(cursor, email, name, verified=True)
+ set_user_password(cursor, user, password)
+ assign_default_roles(cursor, user)
+ match(role):
+ case "system-admin":
+ grant_sysadmin_role(cursor, user)
+ case "none":
+ pass
+ case _:
+ assign_user_role_by_name(cursor, user, sys_resource_id, role)
+
+ return {
+ "user_id": str(user.user_id),
+ "name": user.name,
+ "email": user.email,
+ "password": password,
+ "role": role,
+ }
+
+
+def __parse_user_spec__(spec: str) -> dict:
+ """Parse 'key=value,key=value,...' into a dict."""
+ result = {}
+ for part in spec.split(","):
+ key, _, value = part.partition("=")
+ if key.strip():
+ result[key.strip()] = value.strip()
+ return result
+
+
+def __write_output__(data: dict, output_path) -> None:
+ """Write JSON data to a file with 0644 permissions, or stdout."""
+ text = json.dumps(data, indent=2)
+ if output_path is None:
+ print(text)
+ return
+ fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
+ with os.fdopen(fd, "w") as outfile:
+ outfile.write(text)
+
+
+@app.cli.command()
+@click.option("--user", "user_specs", multiple=True,
+ help='User spec: "name=...,email=...,password=...,role=..."')
+@click.option("--output", "output_path", type=click.Path(), default=None,
+ help="Write credentials as JSON to this file (default: stdout)")
+def create_users(user_specs, output_path):
+ """Create one or more users with specified credentials and roles.
+
+ Each --user option takes a comma-separated key=value string with the
+ following keys: name, email, password, role.
+
+ Valid roles: system-admin, none, batch-editors, systemwide-docs-editor,
+ systemwide-data-curator.
+ """
+ if not user_specs:
+ print("No users specified.", file=sys.stderr)
+ sys.exit(1)
+
+ records = []
+ with db.connection(app.config["AUTH_DB"]) as conn, db.cursor(conn) as cursor:
+ sysresource = system_resource(conn)
+ for spec_str in user_specs:
+ spec = __parse_user_spec__(spec_str)
+ name = spec.get("name", "").strip()
+ email = spec.get("email", "").strip()
+ password = spec.get("password", "").strip()
+ role = spec.get("role", "none").strip()
+
+ if not name:
+ print(f"Missing 'name' in user spec: {spec_str!r}", file=sys.stderr)
+ sys.exit(1)
+ if not email:
+ print(f"Missing 'email' in user spec: {spec_str!r}", file=sys.stderr)
+ sys.exit(1)
+ if not password:
+ print(f"Missing 'password' in user spec: {spec_str!r}", file=sys.stderr)
+ sys.exit(1)
+ if role not in _VALID_ROLES_:
+ print(
+ f"Invalid role {role!r} in spec: {spec_str!r}. "
+ f"Valid roles: {_VALID_ROLES_}",
+ file=sys.stderr)
+ sys.exit(1)
+
+ records.append(
+ __create_one_user__(cursor,
+ name,
+ email,
+ password,
+ role,
+ sysresource.resource_id))
+
+ __write_output__({"users": records}, output_path)
+
+
+@app.cli.command()
+@click.option("--user-id", "user_ids", multiple=True, type=click.UUID,
+ help="UUID of a user to delete (repeatable)")
+def delete_users(user_ids):
+ """Delete one or more users by ID, bypassing policy checks.
+
+ Removes users unconditionally regardless of their roles or group
+ memberships. Use with care — intended for test teardown and administration.
+ """
+ if not user_ids:
+ print("No user IDs specified.", file=sys.stderr)
+ sys.exit(1)
+
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ deleted = delete_users_by_id(conn, tuple(user_ids))
+ print(f"Deleted {deleted} user(s).")
+
+
+@app.cli.command()
+@click.option("--session-timestamp", required=True,
+ help="Compact ISO 8601 UTC timestamp (e.g. 20260602T122700Z)")
+@click.option("--user", "user_specs", multiple=True,
+ help='User spec: "name=...,role=..."')
+@click.option("--output", "output_path", required=True, type=click.Path(),
+ help="Write credentials as JSON to this file (0600 permissions)")
+def create_test_users(session_timestamp, user_specs, output_path):
+ """Create ephemeral test users with auto-generated email and password.
+
+ Each --user option takes a comma-separated key=value string with the
+ following keys: name, role.
+
+ Email: <normalised-name><timestamp>@regression-tests.genenetwork.org
+ Password: randomly generated.
+
+ Output is written with 0644 permissions. Valid roles: system-admin, none,
+ batch-editors, systemwide-docs-editor, systemwide-data-curator.
+ """
+ if not user_specs:
+ print("No users specified.", file=sys.stderr)
+ sys.exit(1)
+
+ records = []
+ with db.connection(app.config["AUTH_DB"]) as conn, db.cursor(conn) as cursor:
+ sysresource = system_resource(conn)
+ for spec_str in user_specs:
+ spec = __parse_user_spec__(spec_str)
+ name = spec.get("name", "").strip()
+ role = spec.get("role", "none").strip()
+
+ if not name:
+ print(f"Missing 'name' in user spec: {spec_str!r}", file=sys.stderr)
+ sys.exit(1)
+ if role not in _VALID_ROLES_:
+ print(
+ f"Invalid role {role!r} in spec: {spec_str!r}. "
+ f"Valid roles: {_VALID_ROLES_}",
+ file=sys.stderr)
+ sys.exit(1)
+
+ email = (f"{__normalise_name_for_email__(name)}"
+ f"{session_timestamp}@{_TEST_EMAIL_DOMAIN_}")
+ password = secrets.token_urlsafe(32)
+
+ records.append(
+ __create_one_user__(cursor,
+ name,
+ email,
+ password,
+ role,
+ sysresource.resource_id))
+
+ __write_output__(
+ {"session_timestamp": session_timestamp, "users": records},
+ output_path)
+
+
+_DEFAULT_GRANT_TYPES_ = (
+ "password",
+ "authorization_code",
+ "refresh_token",
+ "urn:ietf:params:oauth:grant-type:jwt-bearer",
+)
+
+_DEFAULT_SCOPES_ = (
+ "profile", "group", "role", "resource",
+ "register-client", "user", "masquerade",
+ "migrate-data", "introspect",
+)
+
+
+def __create_one_client__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ conn,
+ client_name: str,
+ owner_user,
+ redirect_uris: tuple,
+ scopes: tuple = _DEFAULT_SCOPES_,
+ grant_types: tuple = _DEFAULT_GRANT_TYPES_,
+ jwks_uri: str = "",
+) -> dict:
+ """Create a single OAuth2 client and return its credential record."""
+ raw_secret = secrets.token_urlsafe(32)
+ the_client = OAuth2Client(
+ client_id=uuid.uuid4(),
+ client_secret=hash_password(raw_secret),
+ client_id_issued_at=datetime.now(tz=timezone.utc),
+ client_secret_expires_at=datetime.fromtimestamp(0),
+ client_metadata={
+ "client_name": client_name,
+ "token_endpoint_auth_method": [
+ "client_secret_post", "client_secret_basic"],
+ "client_type": "confidential",
+ "grant_types": list(grant_types),
+ "default_redirect_uri": redirect_uris[0] if redirect_uris else "",
+ "redirect_uris": list(redirect_uris),
+ "response_type": ["code", "token"],
+ "scope": list(scopes),
+ "public-jwks-uri": jwks_uri,
+ },
+ user=owner_user)
+ save_client(conn, the_client)
+ return {
+ "client_id": str(the_client.client_id),
+ "client_secret": raw_secret,
+ "client_name": client_name,
+ }
+
+
+@app.cli.command()
+@click.option("--name", "client_name", required=True,
+ help="Human-readable name for the OAuth2 client")
+@click.option("--owner-id", required=True, type=click.UUID,
+ help="UUID of the user who owns this client")
+@click.option("--redirect-uri", "redirect_uris", multiple=True,
+ help="Allowed redirect URI (repeatable)")
+@click.option("--scope", "scopes", multiple=True,
+ default=_DEFAULT_SCOPES_, show_default=False,
+ help="OAuth2 scope (repeatable; defaults to full scope set)")
+@click.option("--grant-type", "grant_types", multiple=True,
+ default=_DEFAULT_GRANT_TYPES_, show_default=False,
+ help="Grant type (repeatable; defaults to all standard types)")
+@click.option("--jwks-uri", default="",
+ help="URI to the client's public JWKS (optional)")
+@click.option("--output", "output_path", type=click.Path(), default=None,
+ help="Write credentials as JSON to this file (default: stdout)")
+def create_oauth2_client(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ client_name,
+ owner_id,
+ redirect_uris,
+ scopes,
+ grant_types,
+ jwks_uri,
+ output_path
+):
+ """Create an OAuth2 client with specified parameters.
+
+ Scopes and grant types default to the full standard set if not provided.
+ """
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ try:
+ owner = user_by_id(conn, owner_id)
+ except NotFoundError:
+ print(f"No user found with ID {owner_id}", file=sys.stderr)
+ sys.exit(1)
+ record = __create_one_client__(
+ conn, client_name, owner, redirect_uris, scopes, grant_types,
+ jwks_uri)
+
+ __write_output__({"client": record}, output_path)
+
+
+@app.cli.command()
+@click.option("--session-timestamp", required=True,
+ help="Compact ISO 8601 UTC timestamp (e.g. 20260602T122700Z)")
+@click.option("--users-file", required=True, type=click.Path(exists=True),
+ help="Credentials file produced by create-test-users")
+@click.option("--owner-role", default="system-admin", show_default=True,
+ help="Role of the user in users-file to assign as client owner")
+@click.option("--output", "output_path", required=True, type=click.Path(),
+ help="Write credentials as JSON to this file (0600 permissions)")
+def create_test_oauth2_client(session_timestamp, users_file, owner_role,
+ output_path):
+ """Create an ephemeral OAuth2 client for a test session.
+
+ Reads the credentials file produced by create-test-users to find the
+ owner. Client name and secret are auto-generated using the session
+ timestamp. Output is written with 0600 permissions.
+ """
+ with open(users_file, encoding="utf8") as f:
+ users_data = json.load(f)
+
+ owner_record = next(
+ (u for u in users_data.get("users", []) if u["role"] == owner_role),
+ None)
+ if owner_record is None:
+ print(
+ f"No user with role {owner_role!r} found in {users_file}",
+ file=sys.stderr)
+ sys.exit(1)
+
+ client_name = f"gn-test-client-{session_timestamp}"
+
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ try:
+ owner = user_by_id(conn, uuid.UUID(owner_record["user_id"]))
+ except NotFoundError:
+ print(
+ f"Owner user {owner_record['user_id']!r} not found in DB",
+ file=sys.stderr)
+ sys.exit(1)
+ record = __create_one_client__(
+ conn,
+ client_name,
+ owner,
+ tuple(),
+ scopes=(
+ "profile", "group", "role", "resource",
+ "register-client", "user", "masquerade"))
+
+ __write_output__(
+ {"session_timestamp": session_timestamp, "client": record},
+ output_path)
+
+
+@app.cli.command()
+@click.option("--credentials", "credentials_path", required=True,
+ type=click.Path(exists=True),
+ help="Credentials file produced by create-oauth2-client or "
+ "create-test-oauth2-client")
+def delete_oauth2_client(credentials_path):
+ """Delete an OAuth2 client using a credentials file.
+
+ Reads the client_id from the given credentials file and removes the
+ client and all associated tokens from the database.
+ """
+ with open(credentials_path, encoding="utf8") as f:
+ data = json.load(f)
+
+ client_id_str = data.get("client", {}).get("client_id")
+ if not client_id_str:
+ print("No client_id found in credentials file.", file=sys.stderr)
+ sys.exit(1)
+
+ client_id = uuid.UUID(client_id_str)
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ the_client = oauth2_client_by_id(conn, client_id)
+ if the_client.is_nothing():
+ print(f"No client found with ID {client_id}", file=sys.stderr)
+ sys.exit(1)
+ delete_client(conn, the_client.value)
+ os.unlink(credentials_path)
+ print(f"Deleted OAuth2 client {client_id}.")
+
+
+@app.cli.command()
+@click.option("--credentials", "credentials_path", required=True,
+ type=click.Path(exists=True),
+ help="Credentials file produced by create-test-users")
+def delete_test_users(credentials_path):
+ """Delete ephemeral test users using a credentials file.
+
+ Reads the credentials file produced by create-test-users and deletes
+ all listed users unconditionally, bypassing policy checks. Intended
+ for CI test teardown.
+ """
+ with open(credentials_path, encoding="utf8") as f:
+ data = json.load(f)
+
+ user_ids = tuple(
+ uuid.UUID(u["user_id"]) for u in data.get("users", []))
+ if not user_ids:
+ print("No users found in credentials file.", file=sys.stderr)
+ sys.exit(1)
+
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ deleted = delete_users_by_id(conn, user_ids)
+ os.unlink(credentials_path)
+ print(f"Deleted {deleted} user(s).")
+
##### END: CLI Commands #####
if __name__ == '__main__':
diff --git a/migrations/__init__.py b/migrations/__init__.py
deleted file mode 100644
index cedf48d..0000000
--- a/migrations/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Migrations package"""
diff --git a/mypy.ini b/mypy.ini
index 15c4b44..89a6200 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -71,4 +71,7 @@ ignore_missing_imports = True
ignore_missing_imports = True
[mypy-flask_cors.*]
+ignore_missing_imports = True
+
+[mypy-gn_libs.*]
ignore_missing_imports = True \ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..f5f624d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,71 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "gn-auth"
+# version = "1.0.1"
+dynamic = ["version"] # Read from git, or elsewhere
+description = "Authentication/Authorisation server for GeneNetwork Services."
+requires-python = ">= 3.10"
+authors = [
+{name = "Frederick M. Muriithi", email = "fredmanglis@gmail.com"},
+]
+dependencies = [
+"argon2-cffi>= 20.1.0",
+"click",
+"Flask>= 1.1.2",
+"mypy>= 0.790",
+"mypy-extensions>= 0.4.3",
+"mysqlclient>= 2.0.1",
+"pylint>= 2.5.3",
+"pymonad",
+"redis>= 3.5.3",
+"requests>= 2.25.1",
+"flask-cors", # with the `>= 3.0.9` specification, it breaks the build
+"gn-libs @ git+https://git.genenetwork.org/gn-libs"
+]
+maintainers = [
+{name = "Frederick M. Muriithi", email = "fredmanglis@gmail.com"},
+]
+license = "AGPL-3.0"
+readme = {file = "README.md", content-type = "text/markdown"}
+
+[project.urls]
+Homepage = "https://git.genenetwork.org/gn-auth/"
+Repository = "https://git.genenetwork.org/gn-auth/"
+
+[dependency-groups]# PEP 735
+tests = ["pytest"]
+checks = [{include-group = "tests"}, "mypy", "pylint", "vulture"]
+
+[tool.pylint.main]
+ignore = ["tests", "venv"]
+ignore-paths = ["^gn_auth/migrations/auth/.*"]
+ignore-imports = true
+disable = ["fixme", "duplicate-code", "no-else-return"]
+load-plugins = ["pylint.extensions.no_self_use"]
+
+[tool.vulture]
+ignore_decorators = [
+"@admin.before_request",
+"@admin.route",
+"@app.cli.command",
+"@auth.route",
+"@collections.route",
+"@data.route",
+"@genobp.route",
+"@groups.route",
+"@masq.route",
+"@misc.route",
+"@phenobp.route",
+"@phenosbp.route",
+"@popbp.route",
+"@privileges.route",
+"@resources.route",
+"@roles.route",
+"@system.route",
+"@users.route"
+]
+exclude = ["*/tests/unit/*", "*/gn_auth/settings.py", "*/gn_auth/migrations/*"]
+min_confidence = 60 \ No newline at end of file
diff --git a/scripts/__init__.py b/scripts/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/scripts/__init__.py
+++ /dev/null
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 41d118e..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,4 +0,0 @@
-[aliases]
-run_unit_tests = run_tests --type=unit
-run_integration_tests = run_tests --type=integration
-run_performance_tests = run_tests --type=performance
diff --git a/setup.py b/setup.py
deleted file mode 100755
index 023c2a2..0000000
--- a/setup.py
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env python
-"""Setup script for GeneNetwork Auth package."""
-from setuptools import setup, find_packages
-from setup_commands import RunTests
-
-LONG_DESCRIPTION = """
-gn-auth project is the authentication/authorisation server to be used
-across all GeneNetwork services.
-"""
-
-setup(author="Frederick M. Muriithi",
- author_email="fredmanglis@gmail.com",
- description=(
- "Authentication/Authorisation server for GeneNetwork Services."),
- install_requires=[
- "argon2-cffi>=20.1.0"
- "click"
- "Flask==1.1.2"
- "mypy==0.790"
- "mypy-extensions==0.4.3"
- "mysqlclient==2.0.1"
- "pylint==2.5.3"
- "pymonad"
- "redis==3.5.3"
- "requests==2.25.1"
- "flask-cors==3.0.9"
- "xapian-bindings"
- ],
- include_package_data=True,
- packages=find_packages(
- where=".",
- exclude=(
- "tests",
- "tests.*",
- "setup_commands",
- "setup_commands.*")),
- # `package_data` doesn't seem to work. Use MANIFEST.in instead
- scripts=[],
- license="AGPLV3",
- long_description=LONG_DESCRIPTION,
- long_description_content_type="text/markdown",
- name="gn-auth",
- url="https://github.com/genenetwork/gn-auth",
- version="0.0.1",
- tests_require=["pytest", "hypothesis"],
- cmdclass={
- "run_tests": RunTests # testing
- })
diff --git a/setup_commands/__init__.py b/setup_commands/__init__.py
deleted file mode 100644
index 967bb11..0000000
--- a/setup_commands/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Module for custom setup commands."""
-
-from .run_tests import RunTests
diff --git a/setup_commands/run_tests.py b/setup_commands/run_tests.py
deleted file mode 100644
index 1bb5dab..0000000
--- a/setup_commands/run_tests.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import os
-import sys
-from distutils.core import Command
-
-class RunTests(Command):
- """
- A custom command to run tests.
- """
- description = "Run the tests"
- test_types = (
- "all", "unit", "integration", "performance")
- user_options = [
- ("type=", None,
- f"""Specify the type of tests to run.
- Valid types are {tuple(test_types)}.
- Default is `all`.""")]
-
- def __init__(self, dist):
- """Initialise the command."""
- super().__init__(dist)
- self.command = "pytest"
-
- def initialize_options(self):
- """Initialise the default values of all the options."""
- self.type = "all"
-
- def finalize_options(self):
- """Set final value of all the options once they are processed."""
- if self.type not in RunTests.test_types:
- raise Exception(f"""
- Invalid test type (self.type) requested!
- Valid types are
- {tuple(RunTests.test_types)}""")
-
- if self.type != "all":
- self.command = f"pytest -m {self.type}_test"
- def run(self):
- """Run the chosen tests"""
- print(f"Running {self.type} tests")
- os.system(self.command)
diff --git a/tests/unit/auth/fixtures/group_fixtures.py b/tests/unit/auth/fixtures/group_fixtures.py
index 2e8cd9a..da1c4cd 100644
--- a/tests/unit/auth/fixtures/group_fixtures.py
+++ b/tests/unit/auth/fixtures/group_fixtures.py
@@ -1,5 +1,6 @@
"""Fixtures and utilities for group-related tests"""
import uuid
+import datetime
import pytest
@@ -7,8 +8,12 @@ from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authorisation.resources.groups import Group
from gn_auth.auth.authorisation.resources import Resource, ResourceCategory
+from .user_fixtures import TEST_USERS
from .resource_fixtures import TEST_RESOURCES
+
+_created_ = datetime.datetime.now()
+
TEST_GROUP_01 = Group(uuid.UUID("9988c21d-f02f-4d45-8966-22c968ac2fbf"),
"TheTestGroup", {})
TEST_GROUP_02 = Group(uuid.UUID("e37d59d7-c05e-4d67-b479-81e627d8d634"),
@@ -24,16 +29,20 @@ GROUPS_AS_RESOURCES = tuple({
"resource_id": res_id,
"resource_name": group.group_name,
"category_id": str(GROUP_CATEGORY.resource_category_id),
- "public": "0"
+ "public": "0",
+ "created_by": str(TEST_USERS[0].user_id),
+ "created_at": _created_.timestamp()
} for res_id, group in zip(
("38d1807d-105f-44a7-8327-7e2d973b6d8d",
"89458ef6-e090-4b53-8c2c-59eaf2785f11"),
TEST_GROUPS))
GROUP_RESOURCES = tuple(
- Resource(uuid.UUID(row["resource_id"]),
- row["resource_name"],
+ Resource(uuid.UUID(row["resource_id"]),# type: ignore[arg-type]
+ row["resource_name"],# type: ignore[arg-type]
GROUP_CATEGORY,
- False)
+ False,
+ created_by=TEST_USERS[0],
+ created_at=_created_)
for row in GROUPS_AS_RESOURCES)
@@ -46,7 +55,7 @@ def __gtuple__(cursor):
return tuple(dict(row) for row in cursor.fetchall())
@pytest.fixture(scope="function")
-def fxtr_group(conn_after_auth_migrations):# pylint: disable=[redefined-outer-name]
+def fxtr_group(conn_after_auth_migrations, fxtr_users):# pylint: disable=[redefined-outer-name, unused-argument]
"""Fixture: setup a test group."""
with db.cursor(conn_after_auth_migrations) as cursor:
cursor.executemany(
@@ -57,7 +66,7 @@ def fxtr_group(conn_after_auth_migrations):# pylint: disable=[redefined-outer-na
cursor.executemany(
"INSERT INTO resources "
- "VALUES(:resource_id, :resource_name, :category_id, :public)",
+ "VALUES(:resource_id, :resource_name, :category_id, :public, :created_by, :created_at)",
GROUPS_AS_RESOURCES)
cursor.executemany(
diff --git a/tests/unit/auth/fixtures/resource_fixtures.py b/tests/unit/auth/fixtures/resource_fixtures.py
index e06f64e..b570a49 100644
--- a/tests/unit/auth/fixtures/resource_fixtures.py
+++ b/tests/unit/auth/fixtures/resource_fixtures.py
@@ -1,11 +1,15 @@
"""Fixtures and utilities for resource-related tests"""
import uuid
+import datetime
import pytest
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authorisation.resources import Resource, ResourceCategory
+from .user_fixtures import TEST_USERS
+
+_created_ = datetime.datetime.now()
SYSTEM_CATEGORY = ResourceCategory(
uuid.UUID("aa3d787f-af6a-44fa-9b0b-c82d40e54ad2"),
@@ -15,48 +19,74 @@ SYSTEM_RESOURCE = Resource(
uuid.UUID("0248b289-b277-4eaa-8c94-88a434d14b6e"),
"GeneNetwork System",
SYSTEM_CATEGORY,
- True)
+ True,
+ resource_data=tuple(),
+ created_by=TEST_USERS[4],
+ created_at=_created_)
TEST_RESOURCES = (
Resource(uuid.UUID("26ad1668-29f5-439d-b905-84d551f85955"),
"ResourceG01R01",
ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"),
"genotype", "Genotype Dataset"),
- True),
+ True,
+ resource_data=tuple(),
+ created_by=TEST_USERS[0],
+ created_at=_created_),
Resource(uuid.UUID("2130aec0-fefd-434d-92fd-9ca342348b2d"),
"ResourceG01R02",
ResourceCategory(uuid.UUID("548d684b-d4d1-46fb-a6d3-51a56b7da1b3"),
"phenotype", "Phenotype (Publish) Dataset"),
- False),
+ False,
+ resource_data=tuple(),
+ created_by=TEST_USERS[0],
+ created_at=_created_),
Resource(uuid.UUID("e9a1184a-e8b4-49fb-b713-8d9cbeea5b83"),
"ResourceG01R03",
ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"),
"mrna", "mRNA Dataset"),
- False),
+ False,
+ resource_data=tuple(),
+ created_by=TEST_USERS[0],
+ created_at=_created_),
Resource(uuid.UUID("14496a1c-c234-49a2-978c-8859ea274054"),
"ResourceG02R01",
ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"),
"genotype", "Genotype Dataset"),
- False),
+ False,
+ resource_data=tuple(),
+ created_by=TEST_USERS[0],
+ created_at=_created_),
Resource(uuid.UUID("04ad9e09-94ea-4390-8a02-11f92999806b"),
"ResourceG02R02",
ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"),
"mrna", "mRNA Dataset"),
- True))
+ True,
+ resource_data=tuple(),
+ created_by=TEST_USERS[0],
+ created_at=_created_))
TEST_RESOURCES_PUBLIC = (SYSTEM_RESOURCE, TEST_RESOURCES[0], TEST_RESOURCES[4])
@pytest.fixture(scope="function")
-def fxtr_resources(conn_after_auth_migrations):
+def fxtr_resources(conn_after_auth_migrations, fxtr_users):# pylint: disable=[unused-argument]
"""fixture: setup test resources in the database"""
conn = conn_after_auth_migrations
with db.cursor(conn) as cursor:
cursor.executemany(
- "INSERT INTO resources VALUES (?,?,?,?)",
+ "INSERT INTO resources VALUES (?,?,?,?,?,?)",
((str(res.resource_id), res.resource_name,
str(res.resource_category.resource_category_id),
- 1 if res.public else 0) for res in TEST_RESOURCES))
+ 1 if res.public else 0,
+ str(res.created_by.user_id),
+ res.created_at.timestamp()) for res in TEST_RESOURCES))
+ cursor.execute(
+ "UPDATE resources SET created_by=?, created_at=? "
+ "WHERE resource_id=?",
+ (str(SYSTEM_RESOURCE.created_by.user_id),
+ SYSTEM_RESOURCE.created_at.timestamp(),
+ str(SYSTEM_RESOURCE.resource_id)))
yield (conn, TEST_RESOURCES)
diff --git a/tests/unit/auth/fixtures/role_fixtures.py b/tests/unit/auth/fixtures/role_fixtures.py
index 1858712..24e8e9f 100644
--- a/tests/unit/auth/fixtures/role_fixtures.py
+++ b/tests/unit/auth/fixtures/role_fixtures.py
@@ -108,7 +108,7 @@ def fxtr_resource_roles(fxtr_resources, fxtr_roles):# pylint: disable=[redefined
@pytest.fixture(scope="function")
-def fxtr_setup_group_leaders(fxtr_users):
+def fxtr_setup_group_leaders(fxtr_users, fxtr_group):# pylint: disable=[unused-argument]
"""Define what roles users have that target resources of type 'Group'."""
conn, users = fxtr_users
with db.cursor(conn) as cursor:
@@ -163,7 +163,7 @@ def fxtr_system_roles(fxtr_users):
@pytest.fixture(scope="function")
-def fxtr_resource_user_roles(# pylint: disable=[too-many-arguments, too-many-locals]
+def fxtr_resource_user_roles(# pylint: disable=[too-many-arguments, too-many-locals, too-many-positional-arguments]
fxtr_resources,
fxtr_users_in_group,
fxtr_resource_ownership,
diff --git a/tests/unit/auth/fixtures/user_fixtures.py b/tests/unit/auth/fixtures/user_fixtures.py
index 1cf0e20..0872142 100644
--- a/tests/unit/auth/fixtures/user_fixtures.py
+++ b/tests/unit/auth/fixtures/user_fixtures.py
@@ -1,28 +1,35 @@
"""Fixtures and utilities for user-related tests"""
import uuid
+import datetime
import pytest
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User, hash_password
+_created_ = datetime.datetime.now()
+
TEST_USERS = (
User(uuid.UUID("ecb52977-3004-469e-9428-2a1856725c7f"), "group@lead.er",
- "Group Leader"),
+ "Group Leader", created=_created_),
User(uuid.UUID("21351b66-8aad-475b-84ac-53ce528451e3"),
- "group@mem.ber01", "Group Member 01"),
+ "group@mem.ber01", "Group Member 01", created=_created_),
User(uuid.UUID("ae9c6245-0966-41a5-9a5e-20885a96bea7"),
- "group@mem.ber02", "Group Member 02"),
+ "group@mem.ber02", "Group Member 02", created=_created_),
User(uuid.UUID("9a0c7ce5-2f40-4e78-979e-bf3527a59579"),
- "unaff@iliated.user", "Unaffiliated User"))
+ "unaff@iliated.user", "Unaffiliated User", created=_created_),
+ User(uuid.UUID("60faf8a7-832b-471e-b6a0-bd4013f1fa0e"),
+ "sys@admin.user", "System Admin User", created=_created_))
@pytest.fixture(scope="function")
-def fxtr_users(conn_after_auth_migrations, fxtr_group):# pylint: disable=[redefined-outer-name, unused-argument]
+def fxtr_users(conn_after_auth_migrations):# pylint: disable=[redefined-outer-name, unused-argument]
"""Fixture: setup test users."""
- query = "INSERT INTO users(user_id, email, name) VALUES (?, ?, ?)"
+ query = (
+ "INSERT INTO users(user_id, email, name, created) VALUES (?, ?, ?, ?)")
with db.cursor(conn_after_auth_migrations) as cursor:
cursor.executemany(query, (
- (str(user.user_id), user.email, user.name) for user in TEST_USERS))
+ (str(user.user_id), user.email, user.name, user.created.timestamp())
+ for user in TEST_USERS))
yield (conn_after_auth_migrations, TEST_USERS)
diff --git a/tests/unit/auth/test_admin_user_roles.py b/tests/unit/auth/test_admin_user_roles.py
new file mode 100644
index 0000000..7ce0b1f
--- /dev/null
+++ b/tests/unit/auth/test_admin_user_roles.py
@@ -0,0 +1,262 @@
+"""Tests for admin role-assignment HTTP endpoints."""
+import pytest
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.authorisation.roles.models import assign_user_role_by_name
+
+from tests.unit.auth import conftest
+from tests.unit.auth.fixtures.resource_fixtures import SYSTEM_RESOURCE
+
+# Body used in all role-assign tests — assigning system-administrator on the
+# system resource is a real, migrations-seeded combination.
+_ASSIGN_BODY = {
+ "role_name": "system-administrator",
+ "resource_id": str(SYSTEM_RESOURCE.resource_id)
+}
+
+# Target user for assignment: unaff@iliated.user (no roles initially)
+_TARGET_USER = conftest.TEST_USERS[3]
+
+
+def _setup_admin_mock(conn, clients, mocker):
+ """Grant resource-owner role on SYSTEM_RESOURCE and mock the token.
+
+ resource-owner carries resource:user:assign-role, which is what the
+ endpoint checks. In production the caller would masquerade as the
+ resource owner; here we grant the role directly for test setup.
+ """
+ admin = conftest.TEST_USERS[4]
+ with db.cursor(conn) as cursor:
+ assign_user_role_by_name(
+ cursor, admin, SYSTEM_RESOURCE.resource_id, "resource-owner")
+ mocker.patch(
+ "gn_auth.auth.authorisation.users.views.require_oauth.acquire",
+ conftest.get_tokeniser(
+ admin,
+ tuple(c for c in clients if c.user == admin)[0]))
+ return admin
+
+
+@pytest.mark.unit_test
+def test_assign_role_no_token_returns_401(fxtr_app):
+ """
+ GIVEN: no Authorization header
+ WHEN: POST /auth/user/<uid>/roles/assign
+ THEN: 401 is returned
+ """
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/assign",
+ json=_ASSIGN_BODY)
+ assert res.status_code == 401
+
+
+@pytest.mark.unit_test
+def test_assign_role_non_admin_returns_403(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token belonging to a non-admin user
+ WHEN: POST /auth/user/<uid>/roles/assign
+ THEN: 403 is returned
+ """
+ _conn, clients = fxtr_oauth2_clients
+ user = conftest.TEST_USERS[3] # unaff@iliated.user — no privileges
+ mocker.patch(
+ "gn_auth.auth.authorisation.users.views.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(c for c in clients if c.user == user)[0]))
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/assign",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 403
+
+
+def _revoke_assigned_role(conn):
+ """Remove the role row written by the success tests.
+
+ Keeps the DB in the state the fixtures expect — no user_roles entry for
+ _TARGET_USER — so teardown and any subsequent queries are not surprised.
+ """
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "DELETE FROM user_roles "
+ "WHERE user_id=? "
+ "AND role_id=(SELECT role_id FROM roles WHERE role_name=?) "
+ "AND resource_id=?",
+ (str(_TARGET_USER.user_id),
+ _ASSIGN_BODY["role_name"],
+ _ASSIGN_BODY["resource_id"]))
+
+
+@pytest.mark.unit_test
+def test_assign_role_admin_returns_200(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid system-admin token and a valid role/resource body
+ WHEN: POST /auth/user/<uid>/roles/assign
+ THEN: 200 is returned
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ try:
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/assign",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 200
+ finally:
+ _revoke_assigned_role(conn)
+
+
+def _assign_target_role(conn):
+ """Pre-assign system-administrator to _TARGET_USER on SYSTEM_RESOURCE.
+
+ Required setup for revoke tests: the endpoint can only revoke what exists.
+ """
+ with db.cursor(conn) as cursor:
+ assign_user_role_by_name(
+ cursor, _TARGET_USER, SYSTEM_RESOURCE.resource_id,
+ _ASSIGN_BODY["role_name"])
+
+
+def _cleanup_target_role(conn):
+ """Remove _TARGET_USER's system-administrator row if still present.
+
+ No-op when the revoke endpoint already deleted it; guards against
+ test failures that leave the DB dirty.
+ """
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "DELETE FROM user_roles "
+ "WHERE user_id=? "
+ "AND role_id=(SELECT role_id FROM roles WHERE role_name=?) "
+ "AND resource_id=?",
+ (str(_TARGET_USER.user_id),
+ _ASSIGN_BODY["role_name"],
+ _ASSIGN_BODY["resource_id"]))
+
+
+@pytest.mark.unit_test
+def test_assign_role_persists_to_db(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid system-admin token and a valid role/resource body
+ WHEN: POST /auth/user/<uid>/roles/assign
+ THEN: the user_roles row is present in the DB for that user/role/resource
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ try:
+ with fxtr_app.test_client() as http:
+ http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/assign",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT COUNT(*) AS cnt FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ "WHERE user_roles.user_id=? "
+ "AND roles.role_name=? "
+ "AND user_roles.resource_id=?",
+ (str(_TARGET_USER.user_id),
+ _ASSIGN_BODY["role_name"],
+ _ASSIGN_BODY["resource_id"]))
+ assert cursor.fetchone()["cnt"] == 1
+ finally:
+ _revoke_assigned_role(conn)
+
+
+# ---------------------------------------------------------------------------
+# HTTP endpoint tests: POST /auth/user/<uid>/roles/revoke
+# ---------------------------------------------------------------------------
+
+@pytest.mark.unit_test
+def test_revoke_role_no_token_returns_401(fxtr_app):
+ """
+ GIVEN: no Authorization header
+ WHEN: POST /auth/user/<uid>/roles/revoke
+ THEN: 401 is returned
+ """
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/revoke",
+ json=_ASSIGN_BODY)
+ assert res.status_code == 401
+
+
+@pytest.mark.unit_test
+def test_revoke_role_non_admin_returns_403(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token belonging to a non-admin user
+ WHEN: POST /auth/user/<uid>/roles/revoke
+ THEN: 403 is returned
+ """
+ _conn, clients = fxtr_oauth2_clients
+ user = conftest.TEST_USERS[3] # unaff@iliated.user — no privileges
+ mocker.patch(
+ "gn_auth.auth.authorisation.users.views.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(c for c in clients if c.user == user)[0]))
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/revoke",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 403
+
+
+@pytest.mark.unit_test
+def test_revoke_role_admin_returns_200(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token with resource:user:assign-role and the target user
+ holds the role on the resource
+ WHEN: POST /auth/user/<uid>/roles/revoke
+ THEN: 200 is returned
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ _assign_target_role(conn)
+ try:
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/revoke",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 200
+ finally:
+ _cleanup_target_role(conn)
+
+
+@pytest.mark.unit_test
+def test_revoke_role_removes_from_db(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token with resource:user:assign-role and the target user
+ holds the role on the resource
+ WHEN: POST /auth/user/<uid>/roles/revoke
+ THEN: the user_roles row is absent from the DB
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ _assign_target_role(conn)
+ try:
+ with fxtr_app.test_client() as http:
+ http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/revoke",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT COUNT(*) AS cnt FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ "WHERE user_roles.user_id=? "
+ "AND roles.role_name=? "
+ "AND user_roles.resource_id=?",
+ (str(_TARGET_USER.user_id),
+ _ASSIGN_BODY["role_name"],
+ _ASSIGN_BODY["resource_id"]))
+ assert cursor.fetchone()["cnt"] == 0
+ finally:
+ _cleanup_target_role(conn)
diff --git a/tests/unit/auth/test_admin_users.py b/tests/unit/auth/test_admin_users.py
new file mode 100644
index 0000000..eaa5bcd
--- /dev/null
+++ b/tests/unit/auth/test_admin_users.py
@@ -0,0 +1,190 @@
+"""Tests for admin user-management: model functions and HTTP endpoints."""
+import pytest
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.authorisation.users.admin.models import (
+ create_verified_user,
+ grant_sysadmin_role)
+
+from tests.unit.auth import conftest
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _credential_count(conn, user_id: str) -> int:
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT COUNT(*) AS cnt FROM user_credentials WHERE user_id=?",
+ (user_id,))
+ return cursor.fetchone()["cnt"]
+
+
+@pytest.mark.unit_test
+def test_create_verified_user_sets_verified_flag(conn_after_auth_migrations):
+ """
+ GIVEN: a database with migrations applied
+ WHEN: create_verified_user is called with valid email, name and password
+ THEN: the returned user has verified=True and the flag is persisted in the DB
+ """
+ conn = conn_after_auth_migrations
+ user = create_verified_user(conn, "new@example.org", "New User", "s3cr3t")
+ assert user.verified is True
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT verified FROM users WHERE user_id=?", (str(user.user_id),))
+ row = cursor.fetchone()
+ assert row is not None
+ assert bool(row["verified"]) is True
+
+
+@pytest.mark.unit_test
+def test_create_verified_user_has_no_roles(conn_after_auth_migrations):
+ """
+ GIVEN: a database with migrations applied
+ WHEN: create_verified_user is called with valid email, name and password
+ THEN: the new user is assigned no roles
+ """
+ conn = conn_after_auth_migrations
+ user = create_verified_user(conn, "noroles@example.org", "No Roles User", "s3cr3t")
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT COUNT(*) AS cnt FROM user_roles WHERE user_id=?",
+ (str(user.user_id),))
+ row = cursor.fetchone()
+ assert row["cnt"] == 0
+
+
+@pytest.mark.unit_test
+def test_create_verified_user_stores_credentials(conn_after_auth_migrations):
+ """
+ GIVEN: a database with migrations applied
+ WHEN: create_verified_user is called with valid email, name and password
+ THEN: a password credential row is stored for the new user
+ """
+ conn = conn_after_auth_migrations
+ user = create_verified_user(conn, "creds@example.org", "Creds User", "s3cr3t")
+ assert _credential_count(conn, str(user.user_id)) == 1
+
+
+@pytest.mark.unit_test
+def test_create_verified_user_raises_on_duplicate_email(conn_after_auth_migrations):
+ """
+ GIVEN: a user already exists with a given email
+ WHEN: create_verified_user is called with the same email
+ THEN: an exception is raised
+ """
+ conn = conn_after_auth_migrations
+ create_verified_user(conn, "dupe@example.org", "First User", "s3cr3t")
+ with pytest.raises(Exception):
+ create_verified_user(conn, "dupe@example.org", "Second User", "s3cr3t")
+
+
+# ---------------------------------------------------------------------------
+# HTTP endpoint tests: POST /auth/system/administration/users/create
+# ---------------------------------------------------------------------------
+
+_NEW_USER_BODY = {"email": "newbie@example.org", "name": "Newbie", "password": "s3cr3t"}
+_CREATE_URL = "/auth/system/administration/users/create"
+
+
+@pytest.mark.unit_test
+def test_create_user_endpoint_no_token_returns_401(fxtr_app):
+ """
+ GIVEN: no Authorization header
+ WHEN: POST /auth/system/administration/users/create
+ THEN: 401 is returned
+ """
+ with fxtr_app.test_client() as http:
+ res = http.post(_CREATE_URL, json=_NEW_USER_BODY)
+ assert res.status_code == 401
+
+
+@pytest.mark.unit_test
+def test_create_user_endpoint_non_admin_returns_403(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token belonging to a non-admin user
+ WHEN: POST /auth/system/administration/users/create
+ THEN: 403 is returned
+ """
+ _conn, clients = fxtr_oauth2_clients
+ user = conftest.TEST_USERS[3] # unaff@iliated.user — no privileges
+ mocker.patch(
+ "gn_auth.auth.system.admin.users.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(c for c in clients if c.user == user)[0]))
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _CREATE_URL,
+ json=_NEW_USER_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 403
+
+
+def _setup_admin_mock(conn, clients, mocker):
+ """Grant sysadmin role and mock the token for sys@admin.user."""
+ admin = conftest.TEST_USERS[4]
+ with db.cursor(conn) as cursor:
+ grant_sysadmin_role(cursor, admin)
+ mocker.patch(
+ "gn_auth.auth.system.admin.users.require_oauth.acquire",
+ conftest.get_tokeniser(
+ admin,
+ tuple(c for c in clients if c.user == admin)[0]))
+ return admin
+
+
+@pytest.mark.unit_test
+def test_create_user_endpoint_admin_returns_201(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid system-admin token and a valid request body
+ WHEN: POST /auth/system/administration/users/create
+ THEN: 201 is returned
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _CREATE_URL,
+ json={**_NEW_USER_BODY, "password": "s3cr3tP4ssw0rd"},
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 201
+
+
+@pytest.mark.unit_test
+def test_create_user_endpoint_admin_returns_new_user(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid system-admin token and a valid request body
+ WHEN: POST /auth/system/administration/users/create
+ THEN: the response body contains the new user's email and name
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _CREATE_URL,
+ json={**_NEW_USER_BODY, "password": "s3cr3tP4ssw0rd"},
+ headers={"Authorization": "Bearer some-mocked-token"})
+ data = res.get_json()
+ assert data.get("email") == _NEW_USER_BODY["email"]
+ assert data.get("name") == _NEW_USER_BODY["name"]
+ assert res.status_code == 201
+
+
+@pytest.mark.unit_test
+def test_create_user_endpoint_short_password_returns_400(
+ fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid system-admin token and a request body with a short password
+ WHEN: POST /auth/system/administration/users/create
+ THEN: 400 is returned (password must be at least 8 characters)
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _CREATE_URL,
+ json={**_NEW_USER_BODY, "password": "short"},
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 400
diff --git a/tests/unit/auth/test_groups.py b/tests/unit/auth/test_groups.py
index 16df56e..6f1e8cd 100644
--- a/tests/unit/auth/test_groups.py
+++ b/tests/unit/auth/test_groups.py
@@ -27,7 +27,7 @@ PRIVILEGES = (
@pytest.mark.unit_test
@pytest.mark.parametrize("user", tuple(conftest.TEST_USERS[0:3]))
-def test_create_group_fails(# pylint: disable=[too-many-arguments]
+def test_create_group_fails(# pylint: disable=[too-many-arguments too-many-positional-arguments]
fxtr_app, auth_testdb_path, mocker, fxtr_resource_user_roles, fxtr_oauth2_clients, user):# pylint: disable=[unused-argument]
"""
GIVEN: an authenticated user
@@ -61,6 +61,8 @@ def __cleanup_create_group__(conn, user, group):
(str(user.user_id), str(grp_rsc["resource_id"])))
cursor.execute("DELETE FROM group_resources WHERE group_id=?",
(str(group.group_id),))
+ cursor.execute("DELETE FROM resources WHERE resource_id=?",
+ (grp_rsc["resource_id"],))
cursor.execute("DELETE FROM groups WHERE group_id=?",
(str(group.group_id),))
@@ -71,7 +73,7 @@ def __cleanup_create_group__(conn, user, group):
((conftest.TEST_USERS[3], Group(
UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_group",
{"group_description": "A test group"})),))
-def test_create_group_succeeds(# pylint: disable=[too-many-arguments, unused-argument]
+def test_create_group_succeeds(# pylint: disable=[too-many-arguments too-many-positional-arguments, unused-argument]
fxtr_app,
auth_testdb_path,
mocker,
@@ -102,7 +104,7 @@ def test_create_group_succeeds(# pylint: disable=[too-many-arguments, unused-arg
@pytest.mark.unit_test
@pytest.mark.parametrize("user", conftest.TEST_USERS[1:])
-def test_create_group_raises_exception_with_non_privileged_user(# pylint: disable=[too-many-arguments]
+def test_create_group_raises_exception_with_non_privileged_user(# pylint: disable=[too-many-arguments too-many-positional-arguments]
fxtr_app, auth_testdb_path, mocker, fxtr_users, fxtr_oauth2_clients, user):# pylint: disable=[unused-argument]
"""
GIVEN: an authenticated user, without appropriate privileges
diff --git a/tests/unit/auth/test_migrations_add_data_to_table.py b/tests/unit/auth/test_migrations_add_data_to_table.py
index d9e2ca4..0945a20 100644
--- a/tests/unit/auth/test_migrations_add_data_to_table.py
+++ b/tests/unit/auth/test_migrations_add_data_to_table.py
@@ -40,7 +40,7 @@ test_params = (
@pytest.mark.unit_test
@pytest.mark.parametrize("migration_file,query,query_params,data", test_params)
-def test_apply_insert(# pylint: disable=[too-many-arguments]
+def test_apply_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
auth_migrations_dir, backend, auth_testdb_path, migration_file, query,
query_params, data):
"""
@@ -65,7 +65,7 @@ def test_apply_insert(# pylint: disable=[too-many-arguments]
@pytest.mark.unit_test
@pytest.mark.parametrize("migration_file,query,query_params,data", test_params)
-def test_rollback_insert(# pylint: disable=[too-many-arguments]
+def test_rollback_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
auth_migrations_dir, backend, auth_testdb_path, migration_file, query,
query_params, data):
"""
diff --git a/tests/unit/auth/test_migrations_add_remove_columns.py b/tests/unit/auth/test_migrations_add_remove_columns.py
index af85652..15dc3a2 100644
--- a/tests/unit/auth/test_migrations_add_remove_columns.py
+++ b/tests/unit/auth/test_migrations_add_remove_columns.py
@@ -51,7 +51,7 @@ def rolled_back_successfully(adding: bool, result_str: str, column: str) -> bool
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,the_table,the_column,adding", TEST_PARAMS)
-def test_apply_add_remove_column(# pylint: disable=[too-many-arguments]
+def test_apply_add_remove_column(# pylint: disable=[too-many-arguments too-many-positional-arguments]
auth_migrations_dir, auth_testdb_path, backend, migration_file,
the_table, the_column, adding):
"""
@@ -84,7 +84,7 @@ def test_apply_add_remove_column(# pylint: disable=[too-many-arguments]
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,the_table,the_column,adding", TEST_PARAMS)
-def test_rollback_add_remove_column(# pylint: disable=[too-many-arguments]
+def test_rollback_add_remove_column(# pylint: disable=[too-many-arguments too-many-positional-arguments]
auth_migrations_dir, auth_testdb_path, backend, migration_file,
the_table, the_column, adding):
"""
diff --git a/tests/unit/auth/test_migrations_indexes.py b/tests/unit/auth/test_migrations_indexes.py
index 1c543c4..2d0997f 100644
--- a/tests/unit/auth/test_migrations_indexes.py
+++ b/tests/unit/auth/test_migrations_indexes.py
@@ -30,7 +30,7 @@ migrations_tables_and_indexes = (
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,the_table,the_index", migrations_tables_and_indexes)
-def test_index_created(# pylint: disable=[too-many-arguments]
+def test_index_created(# pylint: disable=[too-many-arguments too-many-positional-arguments]
auth_testdb_path, auth_migrations_dir, backend, migration_file,
the_table, the_index):
"""
@@ -61,7 +61,7 @@ def test_index_created(# pylint: disable=[too-many-arguments]
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,the_table,the_index", migrations_tables_and_indexes)
-def test_index_dropped(# pylint: disable=[too-many-arguments]
+def test_index_dropped(# pylint: disable=[too-many-arguments too-many-positional-arguments]
auth_testdb_path, auth_migrations_dir, backend, migration_file,
the_table, the_index):
"""
diff --git a/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py
index c34a549..a32cacb 100644
--- a/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py
+++ b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py
@@ -8,7 +8,7 @@ from gn_auth.migrations import get_migration, apply_migrations, rollback_migrati
from tests.unit.auth.conftest import (
apply_single_migration, rollback_single_migration, migrations_up_to)
-MIGRATION_PATH = "migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py"
+MIGRATION_PATH = "gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py"
@pytest.mark.unit_test
def test_apply_init_data(auth_testdb_path, auth_migrations_dir, backend):
diff --git a/tests/unit/auth/test_migrations_insert_data_into_empty_table.py b/tests/unit/auth/test_migrations_insert_data_into_empty_table.py
index 0cf9a1f..c699e81 100644
--- a/tests/unit/auth/test_migrations_insert_data_into_empty_table.py
+++ b/tests/unit/auth/test_migrations_insert_data_into_empty_table.py
@@ -16,7 +16,7 @@ test_params = (
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,table,row_count", test_params)
-def test_apply_insert(# pylint: disable=[too-many-arguments]
+def test_apply_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
auth_testdb_path, auth_migrations_dir, backend, migration_file,
table, row_count):
"""
@@ -45,7 +45,7 @@ def test_apply_insert(# pylint: disable=[too-many-arguments]
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,table,row_count", test_params)
-def test_rollback_insert(# pylint: disable=[too-many-arguments]
+def test_rollback_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
auth_testdb_path, auth_migrations_dir, backend, migration_file,
table, row_count):
"""
diff --git a/tests/unit/auth/test_privileges.py b/tests/unit/auth/test_privileges.py
index 619ccc1..41dae7f 100644
--- a/tests/unit/auth/test_privileges.py
+++ b/tests/unit/auth/test_privileges.py
@@ -24,7 +24,18 @@ PRIVILEGES = sorted(
Privilege("group:resource:view-resource",
"view a resource and use it in computations"),
Privilege("group:resource:edit-resource", "edit/update a resource"),
- Privilege("group:resource:delete-resource", "Delete a resource")),
+ Privilege("group:resource:delete-resource", "Delete a resource"),
+
+ Privilege("group:data:link-to-group",
+ "Allow linking data to only one specific group."),
+
+ # Role-management privileges
+ Privilege("resource:role:create-role",
+ "Create a new role on a specific resource"),
+ Privilege("resource:role:delete-role",
+ "Delete an existing role from a specific resource"),
+ Privilege("resource:role:edit-role",
+ "Edit an existing role on a specific resource")),
key=sort_key_privileges)
@pytest.mark.unit_test
diff --git a/tests/unit/auth/test_resources.py b/tests/unit/auth/test_resources.py
index 7f0b43d..81f967e 100644
--- a/tests/unit/auth/test_resources.py
+++ b/tests/unit/auth/test_resources.py
@@ -30,7 +30,7 @@ create_resource_failure = {
(Resource(
uuid.UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"),
"test_resource", resource_category, False),))))
-def test_create_resource(# pylint: disable=[too-many-arguments, unused-argument]
+def test_create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments, unused-argument]
mocker,
fxtr_users_in_group,
fxtr_resource_user_roles,
@@ -50,7 +50,7 @@ def test_create_resource(# pylint: disable=[too-many-arguments, unused-argument]
with db.cursor(conn) as cursor:
resource = create_resource(
- cursor, "test_resource", resource_category, user, _group, False)
+ conn, "test_resource", resource_category, user, _group, False)
assert resource == expected
# Cleanup
cursor.execute(
@@ -82,15 +82,14 @@ def test_create_resource_raises_for_unauthorised_users(
tuple(client for client in clients if client.user == user)[0]))
conn, _group, _users = fxtr_users_in_group
with pytest.raises(AuthorisationError):
- with db.cursor(conn) as cursor:
- assert create_resource(
- cursor,
- "test_resource",
- resource_category,
- user,
- _group,
- False
- ) == expected
+ assert create_resource(
+ conn,
+ "test_resource",
+ resource_category,
+ user,
+ _group,
+ False
+ ) == expected
def sort_key_resources(resource):
"""Sort-key for resources."""
@@ -115,19 +114,19 @@ def test_public_resources(fxtr_resources):
"user,expected",
tuple(zip(
conftest.TEST_USERS,
- (sorted(
+ ((sorted(
{res.resource_id: res for res in
((conftest.GROUP_RESOURCES[0],) +
conftest.TEST_RESOURCES_GROUP_01 +
conftest.TEST_RESOURCES_PUBLIC)}.values(),
- key=sort_key_resources),
- sorted(
+ key=sort_key_resources), 6),
+ (sorted(
{res.resource_id: res for res in
((conftest.TEST_RESOURCES_GROUP_01[1],) +
conftest.TEST_RESOURCES_PUBLIC)}.values()
,
- key=sort_key_resources),
- PUBLIC_RESOURCES, PUBLIC_RESOURCES))))
+ key=sort_key_resources), 4),
+ (PUBLIC_RESOURCES, 3), (PUBLIC_RESOURCES, 3)))))
def test_user_resources(fxtr_resource_user_roles, user, expected):
"""
GIVEN: some resources in the database
@@ -135,6 +134,10 @@ def test_user_resources(fxtr_resource_user_roles, user, expected):
THEN: list only the resources for which the user can access
"""
conn, *_others = fxtr_resource_user_roles
+ uresources, count = user_resources(conn, user)
+ eresources, ecount = expected
+ assert count == ecount
assert sorted(
- {res.resource_id: res for res in user_resources(conn, user)
- }.values(), key=sort_key_resources) == expected
+ {res.resource_id: res for res in uresources}.values(),
+ key=sort_key_resources
+ ) == eresources
diff --git a/tests/unit/auth/test_resources_roles.py b/tests/unit/auth/test_resources_roles.py
index 39a198f..e43f25c 100644
--- a/tests/unit/auth/test_resources_roles.py
+++ b/tests/unit/auth/test_resources_roles.py
@@ -63,7 +63,7 @@ def test_create_group_role(mocker, fxtr_users_in_group, fxtr_oauth2_clients, use
"user,expected", tuple(zip(conftest.TEST_USERS[0:1], (
Role(UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_role",
True, PRIVILEGES),))))
-def test_create_role(# pylint: disable=[too-many-arguments, unused-argument]
+def test_create_role(# pylint: disable=[too-many-arguments, too-many-positional-arguments, unused-argument]
fxtr_app,
auth_testdb_path,
mocker,
diff --git a/tests/unit/auth/test_roles.py b/tests/unit/auth/test_roles.py
index 251defb..b7512ef 100644
--- a/tests/unit/auth/test_roles.py
+++ b/tests/unit/auth/test_roles.py
@@ -26,7 +26,7 @@ PRIVILEGES = (
@pytest.mark.parametrize(
"user,expected", tuple(zip(conftest.TEST_USERS[1:], (
create_role_failure, create_role_failure, create_role_failure))))
-def test_create_role_raises_exception_for_unauthorised_users(# pylint: disable=[too-many-arguments, unused-argument]
+def test_create_role_raises_exception_for_unauthorised_users(# pylint: disable=[too-many-arguments, unused-argument, too-many-positional-arguments]
fxtr_app,
auth_testdb_path,
mocker,
@@ -115,6 +115,10 @@ def test_create_role_raises_exception_for_unauthorised_users(# pylint: disable=[
user_editable=False,
privileges=(
Privilege(
+ "group:data:link-to-group",
+ "Allow linking data to only one specific group."),
+
+ Privilege(
privilege_id="group:resource:create-resource",
privilege_description="Create a resource object"),
Privilege(
@@ -133,6 +137,15 @@ def test_create_role_raises_exception_for_unauthorised_users(# pylint: disable=[
privilege_id="group:user:remove-group-member",
privilege_description="Remove a user from a group"),
Privilege(
+ privilege_id="resource:role:create-role",
+ privilege_description="Create a new role on a specific resource"),
+ Privilege(
+ privilege_id="resource:role:delete-role",
+ privilege_description="Delete an existing role from a specific resource"),
+ Privilege(
+ privilege_id="resource:role:edit-role",
+ privilege_description="Edit an existing role on a specific resource"),
+ Privilege(
privilege_id="system:group:delete-group",
privilege_description="Delete a group"),
Privilege(
diff --git a/tests/unit/auth/test_system_admin_resources.py b/tests/unit/auth/test_system_admin_resources.py
new file mode 100644
index 0000000..76f3ca3
--- /dev/null
+++ b/tests/unit/auth/test_system_admin_resources.py
@@ -0,0 +1,94 @@
+"""Tests for system admin resource-ownership endpoints.
+
+Covers POST /auth/system/administration/resources/<id>/assign-owner
+ and POST /auth/system/administration/resources/<id>/revoke-owner.
+"""
+import pytest
+
+from tests.unit.auth import conftest
+from tests.unit.auth.fixtures.resource_fixtures import TEST_RESOURCES
+
+# Arbitrary target resource for the endpoint path — the privilege check fires
+# before any resource lookup, so the resource need not exist for 401/403 tests.
+_TARGET_RESOURCE = str(TEST_RESOURCES[0].resource_id)
+
+# Minimal body for both endpoints (the target user for ownership change).
+_BODY = {"user_id": str(conftest.TEST_USERS[3].user_id)}
+
+_ASSIGN_URL = f"/auth/system/administration/resources/{_TARGET_RESOURCE}/assign-owner"
+_REVOKE_URL = f"/auth/system/administration/resources/{_TARGET_RESOURCE}/revoke-owner"
+
+_NON_SYSADMIN = conftest.TEST_USERS[3] # unaff@iliated.user — no roles at all
+
+
+def _mock_token(mocker, user, clients):
+ """Patch require_oauth.acquire in the admin resources module."""
+ mocker.patch(
+ "gn_auth.auth.system.admin.resources.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(c for c in clients if c.user == user)[0]))
+
+
+# ---------------------------------------------------------------------------
+# No-token tests (401)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.unit_test
+def test_assign_owner_no_token_returns_401(fxtr_app):
+ """
+ GIVEN: no Authorization header
+ WHEN: POST .../assign-owner
+ THEN: 401 is returned
+ """
+ with fxtr_app.test_client() as http:
+ res = http.post(_ASSIGN_URL, json=_BODY)
+ assert res.status_code == 401
+
+
+@pytest.mark.unit_test
+def test_revoke_owner_no_token_returns_401(fxtr_app):
+ """
+ GIVEN: no Authorization header
+ WHEN: POST .../revoke-owner
+ THEN: 401 is returned
+ """
+ with fxtr_app.test_client() as http:
+ res = http.post(_REVOKE_URL, json=_BODY)
+ assert res.status_code == 401
+
+
+# ---------------------------------------------------------------------------
+# Non-sysadmin tests (403)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.unit_test
+def test_assign_owner_non_sysadmin_returns_403(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token for a user without system:resource:assign-owner
+ WHEN: POST .../assign-owner
+ THEN: 403 is returned
+ """
+ _conn, clients = fxtr_oauth2_clients
+ _mock_token(mocker, _NON_SYSADMIN, clients)
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _ASSIGN_URL, json=_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 403
+
+
+@pytest.mark.unit_test
+def test_revoke_owner_non_sysadmin_returns_403(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token for a user without system:resource:assign-owner
+ WHEN: POST .../revoke-owner
+ THEN: 403 is returned
+ """
+ _conn, clients = fxtr_oauth2_clients
+ _mock_token(mocker, _NON_SYSADMIN, clients)
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _REVOKE_URL, json=_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 403