diff options
Diffstat (limited to 'gn_auth')
79 files changed, 11869 insertions, 588 deletions
diff --git a/gn_auth/__init__.py b/gn_auth/__init__.py index 973110a..d6591e5 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 @@ -8,6 +10,7 @@ from flask import Flask from flask_cors import CORS from authlib.jose import JsonWebKey +from gn_auth import hooks from gn_auth.misc_views import misc from gn_auth.auth.views import oauth2 @@ -16,15 +19,22 @@ 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( setting for setting in ( "SECRET_KEY", "SQL_URI", "AUTH_DB", "AUTH_MIGRATIONS", - "OAUTH2_SCOPE") + "OAUTH2_SCOPES_SUPPORTED") if not ((setting in app.config) and bool(app.config[setting]))) if len(undefined) > 0: raise ConfigurationError( @@ -51,10 +61,52 @@ 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) -> 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) + + +_LOGGABLE_MODULES_ = ( + "gn_auth.errors", + "gn_auth.errors.common", + "gn_auth.errors.authlib", + "gn_auth.errors.http.http_4xx_errors", + "gn_auth.errors.http.http_5xx_errors" +) + + +def setup_logging(appl: 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('/') + if bool(software): + gunicorn_loggers(appl) + else: + dev_loggers(appl) + + loglevel = logging.getLevelName(appl.logger.getEffectiveLevel()) + for module_logger in _LOGGABLE_MODULES_: + logging.getLogger(module_logger).setLevel(loglevel) + + +def create_app(config: Optional[dict] = None) -> Flask: """Create and return a new flask application.""" app = Flask(__name__) @@ -87,5 +139,6 @@ def create_app( app.register_blueprint(oauth2, url_prefix="/auth") register_error_handlers(app) + hooks.register_hooks(app) return app 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 1f53186..c802091 100644 --- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py +++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py @@ -1,15 +1,21 @@ """JWT as Authorisation Grant""" import uuid +import time +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_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 class JWTBearerTokenGenerator(_JWTBearerTokenGenerator): @@ -19,23 +25,66 @@ 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 for key, value in tokendata.items() }, "sub": str(tokendata["sub"]), - "jti": str(uuid.uuid4()) + "jti": str(uuid.uuid4()), + "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 ): @@ -74,7 +123,9 @@ class JWTBearerGrant(_JWTBearerGrant): def resolve_client_key(self, client, headers, payload): """Resolve client key to decode assertion data.""" - return client.jwks().find_by_kid(headers["kid"]) + keyset = client.jwks() + __pk__("THE KEYSET =======>", keyset.keys) + return keyset.find_by_kid(headers["kid"]) def authenticate_user(self, subject): 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 2606ac6..71769e1 100644 --- a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py +++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py @@ -1,15 +1,50 @@ """Implement model for JWTBearerToken""" import uuid +import time +from typing import Optional from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken 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) class JWTBearerToken(_JWTBearerToken): """Overrides default JWTBearerToken class.""" def __init__(self, payload, header, options=None, params=None): + """Initialise the bearer token.""" + # TOD0: Maybe remove this init and make this a dataclass like the way + # 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"]))) + self.client = with_db_connection( + lambda conn: fetch_client( + conn, uuid.UUID(payload["oauth2_client_id"]) + ) + ).maybe(None, lambda _client: _client) + + + 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 8fac648..1639e2e 100644 --- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py +++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py @@ -1,18 +1,19 @@ """OAuth2 Client model.""" import json -import logging import datetime from uuid import UUID -from dataclasses import dataclass from functools import cached_property -from typing import Sequence, Optional +from dataclasses import asdict, dataclass +from typing import Any, Sequence, Optional import requests +from flask import current_app as app 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_auth.debug import __pk__ from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.errors import NotFoundError from gn_auth.auth.authentication.users import (User, @@ -62,23 +63,27 @@ 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!") + app.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( + app.logger.debug( "Could not connect to provided URI: %s", jwksuri, exc_info=True) except JSONDecodeError as _jsonerr: - logging.debug( + app.logger.debug( "Could not convert response to JSON", exc_info=True) except Exception as _exc:# pylint: disable=[broad-except] - logging.debug( + app.logger.debug( "Error retrieving the JWKs for the client.", exc_info=True) return KeySet([]) @@ -289,3 +294,25 @@ def delete_client( cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params) cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params) return the_client + + +def update_client_attribute( + client: OAuth2Client,# pylint: disable=[redefined-outer-name] + attribute: str, + value: Any +) -> OAuth2Client: + """Return a new OAuth2Client with the given attribute updated/changed.""" + attrs = { + attr: type(value) + for attr, value in asdict(client).items() + if attr != "client_id" + } + assert ( + attribute in attrs.keys() and isinstance(value, attrs[attribute])), ( + "Invalid attribute/value provided!") + return OAuth2Client( + client_id=client.client_id, + **{ + attr: (value if attr==attribute else getattr(client, attr)) + for attr in attrs + }) diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py index 9c885e2..8ecf923 100644 --- a/gn_auth/auth/authentication/oauth2/resource_server.py +++ b/gn_auth/auth/authentication/oauth2/resource_server.py @@ -43,6 +43,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) diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py index a8109b7..8ac5106 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: @@ -153,7 +139,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..0e2c4eb 100644 --- a/gn_auth/auth/authentication/oauth2/views.py +++ b/gn_auth/auth/authentication/oauth2/views.py @@ -77,7 +77,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( diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py index bdab8fa..ddb0add 100644 --- a/gn_auth/auth/authorisation/data/genotypes.py +++ b/gn_auth/auth/authorisation/data/genotypes.py @@ -3,9 +3,9 @@ import uuid from dataclasses import asdict from typing import Iterable +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.authorisation.checks import authorised_p @@ -22,8 +22,8 @@ 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, ...]: diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py index 60470a7..0cc644e 100644 --- a/gn_auth/auth/authorisation/data/mrna.py +++ b/gn_auth/auth/authorisation/data/mrna.py @@ -2,10 +2,11 @@ import uuid from dataclasses import asdict from typing import Iterable + +from gn_libs import mysqldb as gn3db from MySQLdb.cursors import DictCursor from gn_auth.auth.db import sqlite3 as authdb -from gn_auth.auth.db import mariadb as gn3db from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.authorisation.resources.groups.models import Group @@ -21,8 +22,8 @@ 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, ...]: diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py index 0a76237..3e45af3 100644 --- a/gn_auth/auth/authorisation/data/phenotypes.py +++ b/gn_auth/auth/authorisation/data/phenotypes.py @@ -3,16 +3,20 @@ import uuid from dataclasses import asdict from typing import Any, Iterable +from gn_libs import mysqldb as gn3db from MySQLdb.cursors import DictCursor from gn_auth.auth.db import sqlite3 as authdb -from gn_auth.auth.db import mariadb as gn3db +from gn_auth.auth.errors import AuthorisationError from gn_auth.auth.authorisation.checks import authorised_p -from gn_auth.auth.authorisation.resources.groups.models import Group +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.authorisation.resources.checks import authorised_for2 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") @@ -53,7 +57,7 @@ def linked_phenotype_data( "group(s)."), oauth2_scope="profile group resource") def ungrouped_phenotype_data( - authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection): + authconn: authdb.DbConnection, gn3conn: gn3db.Connection): """Retrieve phenotype data that is not linked to any user group.""" with gn3conn.cursor() as cursor: params = tuple( @@ -83,7 +87,7 @@ def ungrouped_phenotype_data( 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 +114,33 @@ 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, + group: Group, + traits: tuple[dict, ...] +) -> dict: """Link phenotype traits to a user group.""" + if not (authorised_for2(authconn, + user, + system_resource(authconn), + ("system:data:link-to-group",)) + or + authorised_for2(authconn, + user, + group_resource(authconn, group.group_id), + ("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 (" diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py index 7ed69e3..9123949 100644 --- a/gn_auth/auth/authorisation/data/views.py +++ b/gn_auth/auth/authorisation/data/views.py @@ -11,6 +11,9 @@ 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_auth import jobs from gn_auth.commands import run_async_cmd @@ -19,7 +22,6 @@ 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 @@ -33,8 +35,8 @@ from ..resources.models import ( 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.phenotypes import link_phenotype_data, pheno_traits_from_db from ..data.genotypes import link_genotype_data, ungrouped_genotype_data data = Blueprint("data", __name__) @@ -187,7 +189,7 @@ def __search_mrna__(): 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()): @@ -310,6 +312,7 @@ def link_mrna() -> Response: 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 +328,27 @@ 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( partial(__link__, **__values__(request_json())))) diff --git a/gn_auth/auth/authorisation/resources/base.py b/gn_auth/auth/authorisation/resources/base.py index ac93049..333ba0d 100644 --- a/gn_auth/auth/authorisation/resources/base.py +++ b/gn_auth/auth/authorisation/resources/base.py @@ -3,6 +3,8 @@ from uuid import UUID from dataclasses import dataclass from typing import Any, Sequence +import sqlite3 + @dataclass(frozen=True) class ResourceCategory: @@ -20,3 +22,15 @@ class Resource: resource_category: ResourceCategory public: bool resource_data: Sequence[dict[str, Any]] = tuple() + + +def resource_from_dbrow(row: sqlite3.Row): + """Convert an SQLite3 resultset row into a resource.""" + return Resource( + resource_id=UUID(row["resource_id"]), + resource_name=row["resource_name"], + resource_category=ResourceCategory( + UUID(row["resource_category_id"]), + row["resource_category_key"], + row["resource_category_description"]), + public=bool(int(row["public"]))) diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py index d8e3a9f..ce2b821 100644 --- a/gn_auth/auth/authorisation/resources/checks.py +++ b/gn_auth/auth/authorisation/resources/checks.py @@ -1,14 +1,21 @@ """Handle authorisation checks for resources""" -from uuid import UUID +import uuid +import warnings from functools import reduce from typing import Sequence +from gn_libs.privileges import check + +from .base import Resource + from ...db import sqlite3 as db from ...authentication.users import User +from ..privileges.models import db_row_to_privilege + def __organise_privileges_by_resource_id__(rows): def __organise__(privs, row): - resource_id = UUID(row["resource_id"]) + resource_id = uuid.UUID(row["resource_id"]) return { **privs, resource_id: (row["privilege_id"],) + privs.get( @@ -16,14 +23,18 @@ def __organise_privileges_by_resource_id__(rows): } return reduce(__organise__, rows, {}) + def authorised_for(conn: db.DbConnection, user: User, privileges: tuple[str, ...], - resource_ids: Sequence[UUID]) -> dict[UUID, bool]: + resource_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, bool]: """ Check whether `user` is authorised to access `resources` according to given `privileges`. """ + warnings.warn(DeprecationWarning( + f"The function `{__name__}.authorised_for` is deprecated. Please use " + f"`{__name__}.authorised_for_spec`")) with db.cursor(conn) as cursor: cursor.execute( ("SELECT ur.*, rp.privilege_id FROM " @@ -45,3 +56,67 @@ def authorised_for(conn: db.DbConnection, resource_id: resource_id in authorised for resource_id in resource_ids } + + +def authorised_for2( + conn: db.DbConnection, + user: User, + resource: Resource, + privileges: tuple[str, ...] +) -> bool: + """ + Check that `user` has **ALL** the specified privileges for the resource. + """ + warnings.warn(DeprecationWarning( + f"The function `{__name__}.authorised_for2` is deprecated. Please use " + f"`{__name__}.authorised_for_spec`")) + with db.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( + _query, + (str(resource.resource_id), str(user.user_id))) + _db_privileges = tuple( + db_row_to_privilege(row) for row in cursor.fetchall()) + + str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges) + return all((requested_privilege in str_privileges) + for requested_privilege in privileges) + + +def authorised_for_spec( + conn: db.DbConnection, + user_id: uuid.UUID, + resource_id: uuid.UUID, + auth_spec: str +) -> bool: + """ + 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: + _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( + _query, + (str(resource_id), str(user_id))) + _privileges = tuple(row["privilege_id"] for row in cursor.fetchall()) + return check(auth_spec, _privileges) diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py new file mode 100644 index 0000000..fd358f1 --- /dev/null +++ b/gn_auth/auth/authorisation/resources/common.py @@ -0,0 +1,48 @@ +"""Utilities common to more than one resource.""" +import uuid + +from gn_auth.auth.db import sqlite3 as db + +def assign_resource_owner_role( + cursor: db.DbCursor, + resource_id: uuid.UUID, + user_id: uuid.UUID +) -> dict: + """Assign `user` the 'Resource Owner' role for `resource`.""" + cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'") + role = cursor.fetchone() + params = { + "user_id": str(user_id), + "role_id": role["role_id"], + "resource_id": str(resource_id) + } + cursor.execute( + "INSERT INTO user_roles " + "VALUES (:user_id, :role_id, :resource_id) " + "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING", + params) + return params + + +def grant_access_to_sysadmins( + cursor: db.DbCursor, + resource_id: uuid.UUID, + system_resource_id: uuid.UUID +): + """Grant sysadmins access to resource identified by `resource_id`.""" + cursor.execute( + "SELECT role_id FROM roles WHERE role_name='system-administrator'") + sysadminroleid = cursor.fetchone()[0] + + cursor.execute(# Fetch sysadmin IDs. + "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=?", + (str(system_resource_id),)) + + cursor.executemany( + "INSERT INTO user_roles(user_id, role_id, resource_id) " + "VALUES (?, ?, ?) " + "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING", + tuple((row["user_id"], sysadminroleid, str(resource_id)) + for row in cursor.fetchall())) diff --git a/gn_auth/auth/authorisation/resources/genotypes/__init__.py b/gn_auth/auth/authorisation/resources/genotypes/__init__.py new file mode 100644 index 0000000..f401e28 --- /dev/null +++ b/gn_auth/auth/authorisation/resources/genotypes/__init__.py @@ -0,0 +1 @@ +"""Initialise a genotypes resources package.""" diff --git a/gn_auth/auth/authorisation/resources/genotype.py b/gn_auth/auth/authorisation/resources/genotypes/models.py index 206ab61..762ee7c 100644 --- a/gn_auth/auth/authorisation/resources/genotype.py +++ b/gn_auth/auth/authorisation/resources/genotypes/models.py @@ -5,9 +5,8 @@ from typing import Optional, Sequence import sqlite3 import gn_auth.auth.db.sqlite3 as db - -from .base import Resource -from .data import __attach_data__ +from gn_auth.auth.authorisation.resources.base import Resource +from gn_auth.auth.authorisation.resources.data import __attach_data__ def resource_data( @@ -28,14 +27,15 @@ def resource_data( def link_data_to_resource( conn: db.DbConnection, resource: Resource, - data_link_id: uuid.UUID) -> dict: - """Link Genotype data with a resource.""" + 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) @@ -67,3 +67,45 @@ def attach_resources_data( f"WHERE gr.resource_id IN ({placeholders})", tuple(str(resource.resource_id) for resource in resources)) return __attach_data__(cursor.fetchall(), resources) + + +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, + species_id: int, + population_id: int, + dataset_id: int, + dataset_name: str, + dataset_fullname: str, + dataset_shortname: str +) -> dict: + """Link the genotype identifier data to the genotype resource.""" + params = { + "resource_id": str(resource_id), + "group_id": str(group_id), + "data_link_id": str(uuid.uuid4()), + "species_id": species_id, + "population_id": population_id, + "dataset_id": dataset_id, + "dataset_name": dataset_name, + "dataset_fullname": dataset_fullname, + "dataset_shortname": dataset_shortname + } + cursor.execute( + "INSERT INTO linked_genotype_data " + "VALUES (" + ":data_link_id," + ":group_id," + ":species_id," + ":population_id," + ":dataset_id," + ":dataset_name," + ":dataset_fullname," + ":dataset_shortname" + ")", + params) + cursor.execute( + "INSERT INTO genotype_resources VALUES (:resource_id, :data_link_id)", + params) + return params diff --git a/gn_auth/auth/authorisation/resources/genotypes/views.py b/gn_auth/auth/authorisation/resources/genotypes/views.py new file mode 100644 index 0000000..2beed58 --- /dev/null +++ b/gn_auth/auth/authorisation/resources/genotypes/views.py @@ -0,0 +1,78 @@ +"""Genotype-resources-specific views.""" +import uuid + +from pymonad.either import Left, Right +from flask import jsonify, Blueprint, current_app as app + +from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.requests import request_json + +from gn_auth.auth.authorisation.resources.base import ResourceCategory +from gn_auth.auth.authorisation.resources.request_utils import check_form +from gn_auth.auth.authorisation.resources.groups.models import user_group + +from gn_auth.auth.authentication.oauth2.resource_server import require_oauth + +from gn_auth.auth.authorisation.resources.models import create_resource +from gn_auth.auth.authorisation.resources.common import ( + assign_resource_owner_role) + + +from .models import insert_and_link_data_to_resource + +genobp = Blueprint("genotypes", __name__) + +@genobp.route("genotypes/create", methods=["POST"]) +@require_oauth("profile group resource") +def create_geno_resource(): + """Create a new genotype resource.""" + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + cursor.execute("SELECT * FROM resource_categories " + "WHERE resource_category_key='genotype'") + row = cursor.fetchone() + + return check_form( + request_json(), + "species_id", + "population_id", + "dataset_id", + "dataset_name", + "dataset_fullname", + "dataset_shortname" + ).then( + lambda form: user_group(conn, _token.user).maybe( + Left("No user group found!"), + lambda group: Right({"formdata": form, "group": group})) + ).then( + lambda fdgrp: { + **fdgrp, + "resource": create_resource( + cursor, + f"Genotype — {fdgrp['formdata']['dataset_fullname']}", + ResourceCategory(uuid.UUID(row["resource_category_id"]), + row["resource_category_key"], + row["resource_category_description"]), + _token.user, + fdgrp["group"], + fdgrp["formdata"].get("public", "on") == "on")} + ).then( + lambda fdgrpres: { + **fdgrpres, + "owner_role": assign_resource_owner_role( + cursor, + fdgrpres["resource"].resource_id, + _token.user.user_id)} + ).then( + lambda fdgrpres: insert_and_link_data_to_resource( + cursor, + fdgrpres["resource"].resource_id, + fdgrpres["group"].group_id, + fdgrpres["formdata"]["species_id"], + fdgrpres["formdata"]["population_id"], + fdgrpres["formdata"]["dataset_id"], + fdgrpres["formdata"]["dataset_name"], + fdgrpres["formdata"]["dataset_fullname"], + fdgrpres["formdata"]["dataset_shortname"]) + ).either(lambda error: (jsonify(error), 400), jsonify) 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..a1937ce 100644 --- a/gn_auth/auth/authorisation/resources/groups/models.py +++ b/gn_auth/auth/authorisation/resources/groups/models.py @@ -8,14 +8,21 @@ 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_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.system.models import system_resource +from gn_auth.auth.authorisation.resources.common import ( + grant_access_to_sysadmins) +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 ( @@ -118,9 +125,10 @@ 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( @@ -131,18 +139,20 @@ def create_group( cursor.execute( "INSERT INTO resources VALUES " "(:resource_id, :resource_name, :resource_category_id, :public)", - group_resource) + _group_resource) cursor.execute( "INSERT INTO group_resources(resource_id, group_id) " "VALUES(:resource_id, :group_id)", - group_resource) + _group_resource) + grant_access_to_sysadmins(cursor, + _group_resource_id, + system_resource(conn).resource_id) 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 +243,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 +319,56 @@ 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") + grant_access_to_sysadmins(cursor, + grp_resource.resource_id, + system_resource(conn).resource_id) @authorised_p( @@ -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 new file mode 100644 index 0000000..2626f3e --- /dev/null +++ b/gn_auth/auth/authorisation/resources/inbredset/models.py @@ -0,0 +1,85 @@ +"""Functions to handle the low-level details regarding populations auth.""" +from uuid import UUID, uuid4 +from typing import Sequence, Optional + +import sqlite3 + +import gn_auth.auth.db.sqlite3 as db +from gn_auth.auth.authentication.users import User +from gn_auth.auth.authorisation.resources.base import Resource + + +def assign_inbredset_group_owner_role( + cursor: sqlite3.Cursor, + resource: Resource, + user: User +) -> Resource: + """ + Assign `user` as `InbredSet Group Owner` is resource category is + 'inbredset-group'. + """ + if resource.resource_category.resource_category_key == "inbredset-group": + cursor.execute( + "SELECT * FROM roles WHERE role_name='inbredset-group-owner'") + role = cursor.fetchone() + cursor.execute( + "INSERT INTO user_roles " + "VALUES(:user_id, :role_id, :resource_id) " + "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING", + { + "user_id": str(user.user_id), + "role_id": str(role["role_id"]), + "resource_id": str(resource.resource_id) + }) + + return resource + + +def link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + cursor: sqlite3.Cursor, + resource_id: UUID, + species_id: int, + population_id: int, + population_name: str, + population_fullname: str +) -> dict: + """Link a species population to a resource for auth purposes.""" + params = { + "resource_id": str(resource_id), + "data_link_id": str(uuid4()), + "species_id": species_id, + "population_id": population_id, + "population_name": population_name, + "population_fullname": population_fullname + } + cursor.execute( + "INSERT INTO linked_inbredset_groups " + "VALUES(" + " :data_link_id," + " :species_id," + " :population_id," + " :population_name," + " :population_fullname" + ")", + params) + cursor.execute( + "INSERT INTO inbredset_group_resources " + "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 444c442..9603b5b 100644 --- a/gn_auth/auth/authorisation/resources/inbredset/views.py +++ b/gn_auth/auth/authorisation/resources/inbredset/views.py @@ -1,12 +1,56 @@ """Views for InbredSet resources.""" -from flask import jsonify, Response, Blueprint +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.db.sqlite3 import with_db_connection +from gn_auth.auth.errors import NotFoundError +from gn_auth.auth.requests import request_json +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.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__) + -iset = Blueprint("inbredset", __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.") -@iset.route("/resource-id/<int:speciesid>/<int:inbredsetid>") + +@popbp.route("/populations/resource-id/<int:speciesid>/<int:inbredsetid>", + methods=["GET"]) def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response: """Retrieve the resource ID for resource attached to the inbredset.""" def __res_by_iset_id__(conn): @@ -20,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: @@ -34,3 +78,76 @@ def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response: resp.status_code = 404 return resp + + +@popbp.route("/populations/create", methods=["POST"]) +@require_oauth("profile group resource") +def create_population_resource(): + """Create a resource of type 'inbredset-group'.""" + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + + def __check_form__(form, usergroup) -> Either: + """Check form for errors.""" + errors: tuple[str, ...] = tuple() + + species_id = form.get("species_id") + if not bool(species_id): + errors = errors + ("Missing `species_id` value.",) + + population_id = form.get("population_id") + if not bool(population_id): + errors = errors + ("Missing `population_id` value.",) + + population_name = form.get("population_name") + if not bool(population_name): + errors = errors + ("Missing `population_name` value.",) + + population_fullname = form.get("population_fullname") + if not bool(population_fullname): + errors = errors + ("Missing `population_fullname` value.",) + + if bool(errors): + error_messages = "\n\t - ".join(errors) + return Left({ + "error": "Invalid Request Data!", + "error_description": error_messages + }) + + return Right({"formdata": form, "group": usergroup}) + + 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: { + **formdata, + "resource": create_resource( + cursor, + f"Population — {formdata['formdata']['population_name']}", + _token.user, + formdata["group"], + formdata["formdata"].get("public", "on") == "on")} + ).then( + lambda resource: { + **resource, + "resource": assign_inbredset_group_owner_role( + cursor, resource["resource"], _token.user)} + ).then( + lambda resource: link_data_to_resource( + cursor, + resource["resource"].resource_id, + resource["formdata"]["species_id"], + resource["formdata"]["population_id"], + resource["formdata"]["population_name"], + resource["formdata"]["population_fullname"]) + ).either( + lambda error: (jsonify(error), 400), + jsonify) diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py index c7c8352..31371fd 100644 --- a/gn_auth/auth/authorisation/resources/models.py +++ b/gn_auth/auth/authorisation/resources/models.py @@ -4,8 +4,6 @@ from uuid import UUID, uuid4 from functools import reduce, partial from typing import Dict, Sequence, Optional -import sqlite3 - 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 @@ -15,68 +13,42 @@ 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 -from .groups.models import Group, user_group, is_group_leader +from .system.models import system_resource +from .checks import authorised_for, authorised_for_spec +from .base import Resource, ResourceCategory, resource_from_dbrow +from .common import assign_resource_owner_role, grant_access_to_sysadmins +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, link_data_to_resource as mrna_link_data_to_resource, unlink_data_from_resource as mrna_unlink_data_from_resource) -from .genotype import ( +from .genotypes.models import ( resource_data as genotype_resource_data, attach_resources_data as genotype_attach_resources_data, link_data_to_resource as genotype_link_data_to_resource, unlink_data_from_resource as genotype_unlink_data_from_resource) -from .phenotype import ( +from .phenotypes.models import ( resource_data as phenotype_resource_data, attach_resources_data as phenotype_attach_resources_data, link_data_to_resource as phenotype_link_data_to_resource, unlink_data_from_resource as phenotype_unlink_data_from_resource) -from .errors import MissingGroupError - -def __assign_resource_owner_role__(cursor, resource, user): - """Assign `user` the 'Resource Owner' role for `resource`.""" - cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'") - role = cursor.fetchone() - cursor.execute( - "INSERT INTO user_roles " - "VALUES (:user_id, :role_id, :resource_id) " - "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING", - { - "user_id": str(user.user_id), - "role_id": role["role_id"], - "resource_id": str(resource.resource_id) - }) - - -def resource_from_dbrow(row: sqlite3.Row): - """Convert an SQLite3 resultset row into a resource.""" - return Resource( - resource_id=UUID(row["resource_id"]), - resource_name=row["resource_name"], - resource_category=ResourceCategory( - UUID(row["resource_category_id"]), - row["resource_category_key"], - row["resource_category_description"]), - public=bool(int(row["public"]))) - @authorised_p(("group:resource:create-resource",), error_description="Insufficient privileges to create a resource", oauth2_scope="profile resource") -def create_resource( - conn: db.DbConnection, resource_name: str, - resource_category: ResourceCategory, user: User, - public: bool) -> Resource: +def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + conn: db.DbConnection, + resource_name: str, + resource_category: ResourceCategory, + user: User, + group: Group, + public: bool +) -> Resource: """Create a resource item.""" with db.cursor(conn) as cursor: - group = user_group(conn, user).maybe( - False, lambda grp: grp)# type: ignore[misc, arg-type] - if not group: - raise MissingGroupError(# Not all resources require an owner group - "User with no group cannot create a resource.") resource = Resource(uuid4(), resource_name, resource_category, public) cursor.execute( "INSERT INTO resources VALUES (?, ?, ?, ?)", @@ -84,12 +56,40 @@ def create_resource( 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, user) + assign_resource_owner_role(cursor, resource.resource_id, user.user_id) + grant_access_to_sysadmins( + cursor, resource.resource_id, system_resource(conn).resource_id) + + return resource + + +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),)) - return resource def resource_category_by_id( conn: db.DbConnection, category_id: UUID) -> ResourceCategory: @@ -152,8 +152,10 @@ def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]: """List the resources available to the user""" with db.cursor(conn) as cursor: cursor.execute( - ("SELECT r.*, rc.resource_category_key, " - "rc.resource_category_description FROM user_roles AS ur " + ("SELECT DISTINCT(r.resource_id), r.resource_name, " + "r.resource_category_id, r.public, rc.resource_category_key, " + "rc.resource_category_description " + "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 " @@ -176,7 +178,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( @@ -204,9 +207,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}'.") @@ -224,8 +229,12 @@ 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",), @@ -240,7 +249,7 @@ def link_data_to_resource( "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): 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/phenotype.py b/gn_auth/auth/authorisation/resources/phenotype.py deleted file mode 100644 index 7005db3..0000000 --- a/gn_auth/auth/authorisation/resources/phenotype.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Phenotype data resources functions and utilities.""" -import uuid -from typing import Optional, Sequence - -import sqlite3 - -import gn_auth.auth.db.sqlite3 as db - -from .base import Resource -from .data import __attach_data__ - -def resource_data( - cursor: db.DbCursor, - resource_id: uuid.UUID, - offset: int = 0, - limit: Optional[int] = None) -> Sequence[sqlite3.Row]: - """Fetch data linked to a Phenotype resource""" - cursor.execute( - ("SELECT * FROM phenotype_resources AS pr " - "INNER JOIN linked_phenotype_data AS lpd " - "ON pr.data_link_id=lpd.data_link_id " - "WHERE pr.resource_id=?") + ( - f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""), - (str(resource_id),)) - return cursor.fetchall() - -def link_data_to_resource( - conn: db.DbConnection, - resource: Resource, - data_link_id: uuid.UUID) -> dict: - """Link Phenotype data with a resource.""" - with db.cursor(conn) as cursor: - params = { - "resource_id": str(resource.resource_id), - "data_link_id": str(data_link_id) - } - cursor.execute( - "INSERT INTO phenotype_resources VALUES" - "(:resource_id, :data_link_id)", - params) - return params - -def unlink_data_from_resource( - conn: db.DbConnection, - resource: Resource, - data_link_id: uuid.UUID) -> dict: - """Unlink data from Phenotype resources""" - with db.cursor(conn) as cursor: - cursor.execute("DELETE FROM phenotype_resources " - "WHERE resource_id=? AND data_link_id=?", - (str(resource.resource_id), str(data_link_id))) - return { - "resource_id": str(resource.resource_id), - "dataset_type": resource.resource_category.resource_category_key, - "data_link_id": str(data_link_id) - } - -def attach_resources_data( - cursor, resources: Sequence[Resource]) -> Sequence[Resource]: - """Attach linked data to Phenotype resources""" - placeholders = ", ".join(["?"] * len(resources)) - cursor.execute( - "SELECT * FROM phenotype_resources AS pr " - "INNER JOIN linked_phenotype_data AS lpd " - "ON pr.data_link_id=lpd.data_link_id " - f"WHERE pr.resource_id IN ({placeholders})", - tuple(str(resource.resource_id) for resource in resources)) - return __attach_data__(cursor.fetchall(), resources) diff --git a/gn_auth/auth/authorisation/resources/phenotypes/__init__.py b/gn_auth/auth/authorisation/resources/phenotypes/__init__.py new file mode 100644 index 0000000..0d4dbfa --- /dev/null +++ b/gn_auth/auth/authorisation/resources/phenotypes/__init__.py @@ -0,0 +1 @@ +"""The phenotypes package.""" diff --git a/gn_auth/auth/authorisation/resources/phenotypes/models.py b/gn_auth/auth/authorisation/resources/phenotypes/models.py new file mode 100644 index 0000000..0ef91ab --- /dev/null +++ b/gn_auth/auth/authorisation/resources/phenotypes/models.py @@ -0,0 +1,143 @@ +"""Phenotype data resources functions and utilities.""" +import uuid +from functools import reduce +from typing import Optional, Sequence + +import sqlite3 +from pymonad.maybe import Just, Maybe, Nothing +from pymonad.tools import monad_from_none_or_value + +import gn_auth.auth.db.sqlite3 as db +from gn_auth.auth.authorisation.resources.data import __attach_data__ +from gn_auth.auth.authorisation.resources.base import Resource, resource_from_dbrow + +def resource_data( + cursor: db.DbCursor, + resource_id: uuid.UUID, + offset: int = 0, + limit: Optional[int] = None) -> Sequence[sqlite3.Row]: + """Fetch data linked to a Phenotype resource""" + cursor.execute( + ("SELECT * FROM phenotype_resources AS pr " + "INNER JOIN linked_phenotype_data AS lpd " + "ON pr.data_link_id=lpd.data_link_id " + "WHERE pr.resource_id=?") + ( + f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""), + (str(resource_id),)) + return cursor.fetchall() + +def link_data_to_resource( + conn: db.DbConnection, + resource: Resource, + data_link_ids: tuple[uuid.UUID, ...] +) -> tuple[dict, ...]: + """Link Phenotype data with a resource.""" + with db.cursor(conn) as cursor: + params = tuple({ + "resource_id": str(resource.resource_id), + "data_link_id": str(data_link_id) + } for data_link_id in data_link_ids) + cursor.executemany( + "INSERT INTO phenotype_resources VALUES" + "(:resource_id, :data_link_id)", + params) + return params + +def unlink_data_from_resource( + conn: db.DbConnection, + resource: Resource, + data_link_id: uuid.UUID) -> dict: + """Unlink data from Phenotype resources""" + with db.cursor(conn) as cursor: + cursor.execute("DELETE FROM phenotype_resources " + "WHERE resource_id=? AND data_link_id=?", + (str(resource.resource_id), str(data_link_id))) + return { + "resource_id": str(resource.resource_id), + "dataset_type": resource.resource_category.resource_category_key, + "data_link_id": str(data_link_id) + } + +def attach_resources_data( + cursor, resources: Sequence[Resource]) -> Sequence[Resource]: + """Attach linked data to Phenotype resources""" + placeholders = ", ".join(["?"] * len(resources)) + cursor.execute( + "SELECT * FROM phenotype_resources AS pr " + "INNER JOIN linked_phenotype_data AS lpd " + "ON pr.data_link_id=lpd.data_link_id " + f"WHERE pr.resource_id IN ({placeholders})", + tuple(str(resource.resource_id) for resource in resources)) + return __attach_data__(cursor.fetchall(), resources) + + +def individual_linked_resource( + conn: db.DbConnection, + species_id: int, + population_id: int, + dataset_id: int, + xref_id: str) -> Maybe: + """Given the data details, return the linked resource, if one is defined.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT " + "rsc.*, rc.*, lpd.SpeciesId AS species_id, " + "lpd.InbredSetId AS population_id, lpd.PublishXRefId AS xref_id, " + "lpd.dataset_name, lpd.dataset_fullname, lpd.dataset_shortname " + "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 rsc ON pr.resource_id=rsc.resource_id " + "INNER JOIN resource_categories AS rc " + "ON rsc.resource_category_id=rc.resource_category_id " + "WHERE " + "(lpd.SpeciesId, lpd.InbredSetId, lpd.PublishFreezeId, lpd.PublishXRefId) = " + "(?, ?, ?, ?)", + (species_id, population_id, dataset_id, xref_id)) + return monad_from_none_or_value( + Nothing, Just, cursor.fetchone()).then(resource_from_dbrow) + + +def all_linked_resources( + conn: db.DbConnection, + species_id: int, + population_id: int, + dataset_id: int) -> Maybe: + """Given the data details, return the linked resource, if one is defined.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT rsc.*, rc.resource_category_key, " + "rc.resource_category_description, lpd.SpeciesId AS species_id, " + "lpd.InbredSetId AS population_id, lpd.PublishXRefId AS xref_id, " + "lpd.dataset_name, lpd.dataset_fullname, lpd.dataset_shortname " + "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 rsc " + "ON pr.resource_id=rsc.resource_id " + "INNER JOIN resource_categories AS rc " + "ON rsc.resource_category_id=rc.resource_category_id " + "WHERE " + "(lpd.SpeciesId, lpd.InbredSetId, lpd.PublishFreezeId) = (?, ?, ?)", + (species_id, population_id, dataset_id)) + + _rscdatakeys = ( + "species_id", "population_id", "xref_id", "dataset_name", + "dataset_fullname", "dataset_shortname") + def __organise__(resources, row): + _rscid = uuid.UUID(row["resource_id"]) + _resource = resources.get(_rscid, resource_from_dbrow(row)) + return { + **resources, + _rscid: Resource( + _resource.resource_id, + _resource.resource_name, + _resource.resource_category, + _resource.public, + _resource.resource_data + ( + {key: row[key] for key in _rscdatakeys},)) + } + results: dict[uuid.UUID, Resource] = reduce( + __organise__, cursor.fetchall(), {}) + if len(results) == 0: + return Nothing + return Just(tuple(results.values())) diff --git a/gn_auth/auth/authorisation/resources/phenotypes/views.py b/gn_auth/auth/authorisation/resources/phenotypes/views.py new file mode 100644 index 0000000..c0a5e81 --- /dev/null +++ b/gn_auth/auth/authorisation/resources/phenotypes/views.py @@ -0,0 +1,77 @@ +"""Views for the phenotype resources.""" +from pymonad.either import Left, Right +from flask import jsonify, Blueprint, current_app as app + +from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.requests import request_json +from gn_auth.auth.authorisation.resources.request_utils import check_form +from gn_auth.auth.authorisation.roles.models import user_roles_on_resource + +from gn_auth.auth.authentication.oauth2.resource_server import require_oauth + +from .models import all_linked_resources, individual_linked_resource + +phenobp = Blueprint("phenotypes", __name__) + +@phenobp.route("/phenotypes/individual/linked-resource", methods=["POST"]) +@require_oauth("profile group resource") +def get_individual_linked_resource(): + """Get the linked resource for a particular phenotype within the dataset. + + Phenotypes are a tad tricky. Each phenotype could technically be a resource + on its own, and thus a user could have access to only a subset of phenotypes + within the entire dataset.""" + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn): + return check_form( + request_json(), + "species_id", + "population_id", + "dataset_id", + "xref_id" + ).then( + lambda formdata: individual_linked_resource( + conn, + int(formdata["species_id"]), + int(formdata["population_id"]), + int(formdata["dataset_id"]), + formdata["xref_id"] + ).maybe(Left("No linked resource!"), + lambda lrsc: Right({ + "formdata": formdata, + "resource": lrsc + })) + ).then( + lambda fdlrsc: { + **fdlrsc, + "roles": user_roles_on_resource( + conn, _token.user.user_id, fdlrsc["resource"].resource_id) + } + ).either(lambda error: (jsonify(error), 400), + lambda res: jsonify({ + key: value for key, value in res.items() + if key != "formdata" + })) + + +@phenobp.route("/phenotypes/linked-resources", methods=["POST"]) +@require_oauth("profile group resource") +def get_all_linked_resources(): + """Get all the linked resources for all phenotypes within a dataset. + + See `get_individual_linked_resource(…)` documentation.""" + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn): + return check_form( + request_json(), + "species_id", + "population_id", + "dataset_id" + ).then( + lambda formdata: all_linked_resources( + conn, + int(formdata["species_id"]), + int(formdata["population_id"]), + int(formdata["dataset_id"])).maybe( + Left("No linked resource!"), Right) + ).either(lambda error: (jsonify(error), 400), jsonify) diff --git a/gn_auth/auth/authorisation/resources/request_utils.py b/gn_auth/auth/authorisation/resources/request_utils.py new file mode 100644 index 0000000..ade779e --- /dev/null +++ b/gn_auth/auth/authorisation/resources/request_utils.py @@ -0,0 +1,20 @@ +"""Some common utils for requests to the resources endpoints.""" +from functools import reduce + +from pymonad.either import Left, Right, Either + +def check_form(form, *fields) -> Either: + """Check form for errors""" + def __check_field__(errors, field): + if not bool(form.get(field)): + return errors + (f"Missing `{field}` value.",) + return errors + + errors: tuple[str, ...] = reduce(__check_field__, fields, tuple()) + if len(errors) > 0: + return Left({ + "error": "Invalid request data!", + "error_description": "\n\t - ".join(errors) + }) + + return Right(form) diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py index 7c176aa..303b0ac 100644 --- a/gn_auth/auth/authorisation/resources/system/models.py +++ b/gn_auth/auth/authorisation/resources/system/models.py @@ -4,11 +4,15 @@ from functools import reduce from typing import Sequence from gn_auth.auth.db 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 +28,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 +50,19 @@ 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: db.DbConnection) -> Resource: + """Retrieve the system resource.""" + with db.cursor(conn) as cursor: + 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!") diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py index 494fde9..a960ca3 100644 --- a/gn_auth/auth/authorisation/resources/views.py +++ b/gn_auth/auth/authorisation/resources/views.py @@ -39,16 +39,23 @@ 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 .groups.models import Group, user_group +from .checks import authorised_for, 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) -from .groups.models import Group + get_resource_id, delete_resource as _delete_resource) resources = Blueprint("resources", __name__) +resources.register_blueprint(popbp, url_prefix="/") +resources.register_blueprint(genobp, url_prefix="/") +resources.register_blueprint(phenobp, url_prefix="/") @resources.route("/categories", methods=["GET"]) @require_oauth("profile group resource") @@ -70,11 +77,17 @@ def create_resource() -> Response: db_uri = app.config["AUTH_DB"] 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] + if not group: + raise MissingGroupError(# Not all resources require an owner group + "User with no group cannot create a resource.") resource = _create_resource( conn, resource_name, resource_category_by_id(conn, resource_category_id), the_token.user, + group, (form.get("public") == "on")) return jsonify(asdict(resource)) except sqlite3.IntegrityError as sql3ie: @@ -86,7 +99,9 @@ def create_resource() -> Response: f"{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.""" @@ -123,7 +138,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( @@ -139,7 +154,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." @@ -147,8 +162,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: @@ -397,9 +415,18 @@ def resource_roles(resource_id: UUID) -> Response: "ON rp.privilege_id=p.privilege_id " "WHERE rr.resource_id=? AND rr.role_created_by=?", (str(resource_id), str(_token.user.user_id))) - results = cursor.fetchall() + user_created = db_rows_to_roles(cursor.fetchall()) - return db_rows_to_roles(results) + cursor.execute( + "SELECT ur.user_id, ur.resource_id, r.*, p.* 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 " + "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id " + "WHERE resource_id=? AND user_id=?", + (str(resource_id), str(_token.user.user_id))) + assigned_to_user = db_rows_to_roles(cursor.fetchall()) + + return assigned_to_user + user_created return jsonify(with_db_connection(__roles__)) @@ -647,3 +674,49 @@ 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 authorised_for_spec( + conn, + the_token.user.user_id, + resource_id, + "(OR group:resource:delete-resource system:resource:delete)"): + raise AuthorisationError("You do not have the appropriate " + "privileges 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: + app.logger.debug("Error!", exc_info=True) + return jsonify({ + "error": "ValueError", + "error-description": "An invalid identifier was provided" + }), 400 + except TypeError as _terr: + app.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 dc1dfdc..6faeaca 100644 --- a/gn_auth/auth/authorisation/roles/models.py +++ b/gn_auth/auth/authorisation/roles/models.py @@ -133,10 +133,10 @@ def user_roles(conn: db.DbConnection, user: User) -> Sequence[dict]: return tuple() -def user_resource_roles( +def user_roles_on_resource( conn: db.DbConnection, - user: User, - resource: Resource + user_id: UUID, + resource_id: UUID ) -> tuple[Role, ...]: """Retrieve all roles assigned to a user for a particular resource.""" with db.cursor(conn) as cursor: @@ -147,12 +147,22 @@ def user_resource_roles( "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 ur.user_id=? AND ur.resource_id=?", - (str(user.user_id), str(resource.resource_id))) + (str(user_id), str(resource_id))) return db_rows_to_roles(cursor.fetchall()) return tuple() +def user_resource_roles( + conn: db.DbConnection, + user: User, + resource: Resource +) -> tuple[Role, ...]: + "Retrieve roles a user has on a particular resource." + # TODO: Temporary placeholder to prevent system from breaking. + return user_roles_on_resource(conn, user.user_id, resource.resource_id) + + def user_role(conn: db.DbConnection, user: User, role_id: UUID) -> Either: """Retrieve a specific non-resource role assigned to the user.""" with db.cursor(conn) as cursor: @@ -261,7 +271,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/users/admin/models.py b/gn_auth/auth/authorisation/users/admin/models.py index 36f3c09..3d68932 100644 --- a/gn_auth/auth/authorisation/users/admin/models.py +++ b/gn_auth/auth/authorisation/users/admin/models.py @@ -1,23 +1,55 @@ """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 -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() - cursor.execute( + cursor.execute("SELECT resources.resource_id FROM resources") + cursor.executemany( "INSERT INTO user_roles VALUES (:user_id, :role_id, :resource_id)", - { + tuple({ "user_id": str(user.user_id), "role_id": admin_role["role_id"], - "resource_id": the_system["resource_id"] - }) + "resource_id": resource_id + } for resource_id in cursor.fetchall())) 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))) diff --git a/gn_auth/auth/authorisation/users/admin/ui.py b/gn_auth/auth/authorisation/users/admin/ui.py index 64e79a0..43ca0a2 100644 --- a/gn_auth/auth/authorisation/users/admin/ui.py +++ b/gn_auth/auth/authorisation/users/admin/ui.py @@ -1,6 +1,6 @@ """UI utilities for the auth system.""" from functools import wraps -from flask import flash, url_for, redirect +from flask import flash, request, url_for, redirect from gn_auth.session import logged_in, session_user, clear_session_info from gn_auth.auth.authorisation.resources.system.models import ( @@ -24,5 +24,5 @@ def is_admin(func): flash("Expected a system administrator.", "alert-danger") flash("You have been logged out of the system.", "alert-info") clear_session_info() - return redirect(url_for("oauth2.admin.login")) + return redirect(url_for("oauth2.admin.login", **dict(request.args))) return __admin__ diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py index 85aeb50..9bc1c36 100644 --- a/gn_auth/auth/authorisation/users/admin/views.py +++ b/gn_auth/auth/authorisation/users/admin/views.py @@ -30,6 +30,7 @@ from ....authentication.oauth2.models.oauth2client import ( save_client, OAuth2Client, oauth2_clients, + update_client_attribute, client as oauth2_client, delete_client as _delete_client) from ....authentication.users import ( @@ -97,7 +98,7 @@ def login(): expires=( datetime.now(tz=timezone.utc) + timedelta(minutes=int( app.config.get("SESSION_EXPIRY_MINUTES", 10))))) - return redirect(url_for(next_uri)) + return redirect(url_for(next_uri, **dict(request.args))) raise NotFoundError(error_message) except NotFoundError as _nfe: flash(error_message, "alert-danger") @@ -196,7 +197,7 @@ def register_client(): if request.method == "GET": return render_template( "admin/register-client.html", - scope=app.config["OAUTH2_SCOPE"], + scope=app.config["OAUTH2_SCOPES_SUPPORTED"], users=with_db_connection(__list_users__), granttypes=_FORM_GRANT_TYPES_, current_user=session.session_user()) @@ -261,7 +262,7 @@ def view_client(client_id: uuid.UUID): return render_template( "admin/view-oauth2-client.html", client=with_db_connection(partial(oauth2_client, client_id=client_id)), - scope=app.config["OAUTH2_SCOPE"], + scope=app.config["OAUTH2_SCOPES_SUPPORTED"], granttypes=_FORM_GRANT_TYPES_) @@ -321,3 +322,37 @@ def delete_client(): "successfully."), "alert-success") return redirect(url_for("oauth2.admin.list_clients")) + + +@admin.route("/clients/<uuid:client_id>/change-secret", methods=["GET", "POST"]) +@is_admin +def change_client_secret(client_id: uuid.UUID): + """Enable changing of a client's secret.""" + def __no_client__(): + # Calling the function causes the flash to be evaluated + # flash("No such client was found!", "alert-danger") + return redirect(url_for("oauth2.admin.list_clients")) + + with db.connection(app.config["AUTH_DB"]) as conn: + if request.method == "GET": + return oauth2_client( + conn, client_id=client_id + ).maybe(__no_client__(), lambda _client: render_template( + "admin/confirm-change-client-secret.html", + client=_client + )) + + _raw = random_string() + return oauth2_client( + conn, client_id=client_id + ).then( + lambda _client: save_client( + conn, + update_client_attribute( + _client, "client_secret", hash_password(_raw))) + ).then( + lambda _client: render_template( + "admin/registered-client.html", + client=_client, + client_secret=_raw) + ).maybe(__no_client__(), lambda resp: resp) diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py index b4a24f3..63443ef 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,7 +66,7 @@ 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) @@ -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"]): @@ -205,8 +205,10 @@ def add_traits(rconn: Redis, mod_col = tuple(coll for coll in ucolls if coll["id"] == collection_id) __raise_if_not_single_collection__(user, collection_id, mod_col) new_members = tuple(set(tuple(mod_col[0]["members"]) + traits)) + now = datetime.utcnow() new_coll = { **mod_col[0], + "changed": now, "members": new_members, "num_members": len(new_members) } @@ -233,8 +235,10 @@ def remove_traits(rconn: Redis, __raise_if_not_single_collection__(user, collection_id, mod_col) new_members = tuple( trait for trait in mod_col[0]["members"] if trait not in traits) + now = datetime.utcnow() new_coll = { **mod_col[0], + "changed": now, "members": new_members, "num_members": len(new_members) } diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py index eeae91d..f619c3d 100644 --- a/gn_auth/auth/authorisation/users/collections/views.py +++ b/gn_auth/auth/authorisation/users/collections/views.py @@ -113,6 +113,7 @@ def import_anonymous() -> Response: anon_id = UUID(request.json.get("anon_id"))#type: ignore[union-attr] anon_colls = user_collections(redisconn, User( anon_id, "anon@ymous.user", "Anonymous User")) + anon_colls = tuple(coll for coll in anon_colls if coll['num_members'] > 0) save_collections( redisconn, token.user, diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py index 8ac1a68..5c11f34 100644 --- a/gn_auth/auth/authorisation/users/masquerade/models.py +++ b/gn_auth/auth/authorisation/users/masquerade/models.py @@ -1,5 +1,4 @@ """Functions for handling masquerade.""" -import uuid from functools import wraps from datetime import datetime from authlib.jose import jwt @@ -10,14 +9,18 @@ from flask import current_app as app from gn_auth.auth.errors import ForbiddenAccess from gn_auth.auth.jwks import newest_jwk_with_rotation, jwks_directory +from gn_auth.auth.authentication.oauth2.grants.refresh_token_grant import ( + RefreshTokenGrant) +from gn_auth.auth.authentication.oauth2.models.jwtrefreshtoken import ( + JWTRefreshToken, + save_refresh_token) from ...roles.models import user_roles from ....db import sqlite3 as db from ....authentication.users import User -from ....authentication.oauth2.models.oauth2token import ( - OAuth2Token, save_token) +from ....authentication.oauth2.models.oauth2token import OAuth2Token -__FIVE_HOURS__ = (60 * 60 * 5) +__FIVE_HOURS__ = 60 * 60 * 5 def can_masquerade(func): """Security decorator.""" @@ -53,28 +56,30 @@ def masquerade_as( original_token: OAuth2Token, masqueradee: User) -> OAuth2Token: """Get a token that enables `masquerader` to act as `masqueradee`.""" - token_details = app.config["OAUTH2_SERVER"].generate_token( + scope = original_token.get_scope().replace( + # Do not allow more than one level of masquerading + "masquerade", "").strip() + new_token = app.config["OAUTH2_SERVER"].generate_token( client=original_token.client, - grant_type="authorization_code", + grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer", user=masqueradee, - expires_in=__FIVE_HOURS__, - include_refresh_token=True) - + expires_in=original_token.get_expires_in(), + include_refresh_token=True, + scope=scope) _jwt = jwt.decode( - original_token.access_token, + new_token["access_token"], newest_jwk_with_rotation( jwks_directory(app), int(app.config["JWKS_ROTATION_AGE_DAYS"]))) - new_token = OAuth2Token( - token_id=uuid.UUID(_jwt["jti"]), + save_refresh_token(conn, JWTRefreshToken( + token=new_token["refresh_token"], client=original_token.client, - token_type=token_details["token_type"], - access_token=token_details["access_token"], - refresh_token=token_details.get("refresh_token"), - scope=original_token.scope, + user=masqueradee, + issued_with=_jwt["jti"], + issued_at=datetime.fromtimestamp(_jwt["iat"]), + expires=datetime.fromtimestamp( + int(_jwt["iat"]) + RefreshTokenGrant.DEFAULT_EXPIRES_IN), + scope=scope, revoked=False, - issued_at=datetime.now(), - expires_in=token_details["expires_in"], - user=masqueradee) - save_token(conn, new_token) + parent_of=None)) return new_token diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py index 68f19ee..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,29 +21,26 @@ 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) return new_token - def __dump_token__(tok): - return { - key: value for key, value in asdict(tok).items() - if key in ("access_token", "refresh_token", "expires_in", - "token_type") - } + return jsonify({ "original": { - "user": asdict(token.user), - "token": __dump_token__(token) + "user": asdict(token.user) }, "masquerade_as": { "user": asdict(masq_user), - "token": __dump_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..d30bfd0 100644 --- a/gn_auth/auth/authorisation/users/models.py +++ b/gn_auth/auth/authorisation/users/models.py @@ -1,6 +1,7 @@ """Functions for acting on users.""" import uuid from functools import reduce +from datetime import datetime, timedelta from ..roles.models import Role from ..checks import authorised_p @@ -9,14 +10,79 @@ from ..privileges import Privilege from ...db import sqlite3 as db from ...authentication.users import User + +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): diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py index 4b56c3d..4061e07 100644 --- a/gn_auth/auth/authorisation/users/views.py +++ b/gn_auth/auth/authorisation/users/views.py @@ -1,11 +1,12 @@ """User authorisation endpoints.""" +import uuid import sqlite3 import secrets import traceback -from typing import Any -from functools import partial from dataclasses import asdict +from typing import Any, Sequence from urllib.parse import urljoin +from functools import reduce, partial from datetime import datetime, timedelta from email.headerregistry import Address from email_validator import validate_email, EmailNotValidError @@ -27,6 +28,9 @@ 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.system.models import system_resource + +from gn_auth.auth.authorisation.resources.checks import authorised_for2 from gn_auth.auth.authorisation.resources.models import ( user_resources as _user_resources) from gn_auth.auth.authorisation.roles.models import ( @@ -38,6 +42,7 @@ from gn_auth.auth.errors import ( NotFoundError, UsernameError, PasswordError, + AuthorisationError, UserRegistrationError) @@ -70,7 +75,7 @@ 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("/roles", methods=["GET"]) @@ -113,6 +118,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, @@ -124,7 +153,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, @@ -136,7 +165,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 " @@ -151,8 +181,8 @@ def send_verification_email( timedelta( minutes=expiration_minutes)).timestamp()) }) - send_message(smtp_user=current_app.config["SMTP_USER"], - smtp_passwd=current_app.config["SMTP_PASSWORD"], + send_message(smtp_user=current_app.config.get("SMTP_USER", ""), + smtp_passwd=current_app.config.get("SMTP_PASSWORD", ""), message=build_email_message( from_address=current_app.config["EMAIL_ADDRESS"], to_addresses=(user_address(user),), @@ -179,7 +209,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, @@ -195,7 +225,7 @@ def register_user() -> Response: current_app.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): @@ -305,9 +335,33 @@ def user_join_request_exists(): @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(): @@ -366,3 +420,303 @@ def send_verification_code(): }) resp.code = 400 return resp + + +def send_forgot_password_email( + conn, + user: User, + client_id: uuid.UUID, + redirect_uri: str, + response_type: str +): + """Send the 'forgot-password' email.""" + subject="GeneNetwork: Change Your Password" + token = secrets.token_urlsafe(64) + generated = datetime.now() + expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"] + def __render__(template): + return render_template(template, + subject=subject, + forgot_password_uri=urljoin( + request.url, + url_for("oauth2.users.change_password", + forgot_password_token=token, + client_id=client_id, + redirect_uri=redirect_uri, + response_type=response_type)), + expiration_minutes=display_minutes_for_humans( + expiration_minutes)) + + with db.cursor(conn) as cursor: + cursor.execute( + ("INSERT OR REPLACE INTO " + "forgot_password_tokens(user_id, token, generated, expires) " + "VALUES (:user_id, :token, :generated, :expires)"), + { + "user_id": str(user.user_id), + "token": token, + "generated": int(generated.timestamp()), + "expires": int( + (generated + + timedelta( + minutes=expiration_minutes)).timestamp()) + }) + + send_message(smtp_user=current_app.config["SMTP_USER"], + smtp_passwd=current_app.config["SMTP_PASSWORD"], + message=build_email_message( + from_address=current_app.config["EMAIL_ADDRESS"], + to_addresses=(user_address(user),), + subject=subject, + txtmessage=__render__("emails/forgot-password.txt"), + htmlmessage=__render__("emails/forgot-password.html")), + host=current_app.config["SMTP_HOST"], + port=current_app.config["SMTP_PORT"]) + + +@users.route("/forgot-password", methods=["GET", "POST"]) +def forgot_password(): + """Enable user to request password change.""" + if request.method == "GET": + return render_template("users/forgot-password.html", + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"]) + + form = request.form + email = form.get("email", "").strip() + if not bool(email): + flash("You MUST provide an email.", "alert-danger") + return redirect(url_for("oauth2.users.forgot_password")) + + with db.connection(current_app.config["AUTH_DB"]) as conn: + user = user_by_email(conn, form["email"]) + if not bool(user): + flash("We could not find an account with that email.", + "alert-danger") + return redirect(url_for("oauth2.users.forgot_password")) + + send_forgot_password_email(conn, + user, + request.args["client_id"], + request.args["redirect_uri"], + request.args["response_type"]) + return render_template("users/forgot-password-token-send-success.html", + email=form["email"]) + + +@users.route("/change-password/<forgot_password_token>", methods=["GET", "POST"]) +def change_password(forgot_password_token): + """Enable user to perform password change.""" + login_page = redirect(url_for("oauth2.auth.authorise", + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"])) + with (db.connection(current_app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + cursor.execute("DELETE FROM forgot_password_tokens WHERE expires<=?", + (int(datetime.now().timestamp()),)) + cursor.execute( + "SELECT fpt.*, u.email FROM forgot_password_tokens AS fpt " + "INNER JOIN users AS u ON fpt.user_id=u.user_id WHERE token=?", + (forgot_password_token,)) + token = cursor.fetchone() + if request.method == "GET": + if bool(token): + return render_template( + "users/change-password.html", + email=token["email"], + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"], + forgot_password_token=forgot_password_token) + flash("Invalid Token: We cannot change your password!", + "alert-danger") + return login_page + + password = request.form["password"] + confirm_password = request.form["confirm-password"] + change_password_page = redirect(url_for( + "oauth2.users.change_password", + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"], + forgot_password_token=forgot_password_token)) + if bool(password) and bool(confirm_password): + if password == confirm_password: + _user, _hashed_password = set_user_password( + cursor, user_by_email(conn, token["email"]), password) + cursor.execute( + "DELETE FROM forgot_password_tokens WHERE token=?", + (forgot_password_token,)) + flash("Password changed successfully!", "alert-success") + return login_page + + flash("Passwords do not match!", "alert-danger") + return change_password_page + + flash("Both the password and its confirmation MUST be provided!", + "alert-danger") + return change_password_page + + +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"]) +@require_oauth("profile user role") +def delete_users(): + """Delete the specified user.""" + with (require_oauth.acquire("profile") as _token, + db.connection(current_app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + if not authorised_for2(conn, + _token.user, + system_resource(conn), + ("system:user:delete-user",)): + raise AuthorisationError( + "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 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/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/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/views.py b/gn_auth/auth/views.py index 17fc94b..6867f38 100644 --- a/gn_auth/auth/views.py +++ b/gn_auth/auth/views.py @@ -11,7 +11,6 @@ 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 .authorisation.resources.inbredset.views import iset oauth2 = Blueprint("oauth2", __name__) @@ -24,4 +23,3 @@ 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(iset, url_prefix="/resource/inbredset") diff --git a/gn_auth/debug.py b/gn_auth/debug.py new file mode 100644 index 0000000..6b7173b --- /dev/null +++ b/gn_auth/debug.py @@ -0,0 +1,22 @@ +"""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..09862e3 --- /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["error_description"] + 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 new file mode 100644 index 0000000..bd7380b --- /dev/null +++ b/gn_auth/hooks.py @@ -0,0 +1,68 @@ +"""Authorisation hooks implementation""" +import functools +from typing import List + +from flask import request_finished +from flask import request, current_app + +from gn_auth.auth.db import sqlite3 as db + +def register_hooks(app): + """Initialise hooks system on the application.""" + request_finished.connect(edu_domain_hook, app) + + +def handle_register_request(func): + """Decorator for handling user registration hooks.""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + if request.method == "POST" and request.endpoint == "oauth2.users.register_user": + return func(*args, **kwargs) + else: + return lambda *args, **kwargs: None + return wrapper + + +@handle_register_request +def edu_domain_hook(_sender, response, **_extra): + """Hook to run whenever a user with a `.edu` domain registers.""" + if response.status_code >= 400: + return + data = request.get_json() + if data is None or "email" not in data or not data["email"].endswith("edu"): + return + registered_email = data["email"] + apply_edu_role(registered_email) + + +def apply_edu_role(email): + """Assign 'hook-role-from-edu-domain' to user.""" + with db.connection(current_app.config["AUTH_DB"]) as conn: + with db.cursor(conn) as cursor: + cursor.execute("SELECT user_id FROM users WHERE email= ?", (email,) ) + user_result = cursor.fetchone() + cursor.execute("SELECT role_id FROM roles WHERE role_name='hook-role-from-edu-domain'") + role_result = cursor.fetchone() + resource_ids = get_resources_for_edu_domain(cursor) + if user_result is None or role_result is None: + return + user_id = user_result[0] + role_id = role_result[0] + cursor.executemany( + "INSERT INTO user_roles(user_id, role_id, resource_id) " + "VALUES(:user_id, :role_id, :resource_id)", + tuple({ + "user_id": user_id, + "role_id": role_id, + "resource_id": resource_id + } for resource_id in resource_ids)) + + +def get_resources_for_edu_domain(cursor) -> List[int]: + """FIXME: I still haven't figured out how to get resources to be assigned to edu domain""" + resources_query = """ + SELECT resource_id FROM resources INNER JOIN resource_categories USING(resource_category_id) WHERE resource_categories.resource_category_key IN ('genotype', 'phenotype', 'mrna') + """ + cursor.execute(resources_query) + resource_ids = [x[0] for x in cursor.fetchall()] + return resource_ids 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/settings.py b/gn_auth/settings.py index 2a78be3..d59e997 100644 --- a/gn_auth/settings.py +++ b/gn_auth/settings.py @@ -21,9 +21,11 @@ REDIS_URI = "redis://localhost:6379/0" REDIS_JOB_QUEUE = "GN_AUTH::job-queue" # OAuth2 settings -OAUTH2_SCOPE = ( - "profile", "group", "role", "resource", "user", "masquerade", - "introspect") +OAUTH2_SCOPES_SUPPORTED = ( + # Used by Authlib's `authlib.integrations.flask_oauth2.AuthorizationServer` + # class to setup the supported scopes. + "profile", "group", "role", "resource", "register-client", "user", + "masquerade", "introspect", "migrate-data") CORS_ORIGINS = "*" CORS_HEADERS = [ @@ -43,3 +45,7 @@ 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 diff --git a/gn_auth/smtp.py b/gn_auth/smtp.py index 9dc0e5f..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,9 +40,9 @@ def build_email_message(# pylint: disable=[too-many-arguments] return msg -def send_message(# pylint: disable=[too-many-arguments] - smtp_user: str,# pylint: disable=[unused-argument] - smtp_passwd: str,# pylint: disable=[unused-argument] +def send_message(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + smtp_user: str, + smtp_passwd: str, message: EmailMessage, host: str = "", port: int = 587, @@ -54,4 +54,8 @@ def send_message(# pylint: disable=[too-many-arguments] logging.debug("Email to send:\n******\n%s\n******\n", message.as_string()) with smtplib.SMTP(host, port, local_hostname, timeout, source_address) as conn: conn.ehlo() + if bool(smtp_user) and bool(smtp_passwd): + conn.starttls() + conn.login(smtp_user, smtp_passwd) + conn.send_message(message) 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/admin/confirm-change-client-secret.html b/gn_auth/templates/admin/confirm-change-client-secret.html new file mode 100644 index 0000000..aa8ef81 --- /dev/null +++ b/gn_auth/templates/admin/confirm-change-client-secret.html @@ -0,0 +1,45 @@ +{%extends "base.html"%} + +{%block title%}gn-auth: View OAuth2 Client{%endblock%} + +{%block pagetitle%}View OAuth2 Client{%endblock%} + +{%block content%} +{{flash_messages()}} + +<h2>Change Oauth2 Client Secret</h2> + +<p>You are attempting to change the <strong>CLIENT_SECRET</strong> value for the + following client:</p> + +<table class="table"> + <tbody> + <tr> + <td><strong>Client ID</strong></td> + <td>{{client.client_id}}</td> + </tr> + <tr> + <td><strong>Client Name</strong></td> + <td>{{client.client_metadata.client_name}}</td> + </tr> + </tbody> +</table> + +<p>Are you absolutely sure you want to do this?<br /> + <small>Note that you'll need to update your configurations for the client and + restart it for the settings to take effect!</small></p> + +<form id="frm-change-client-secret" + method="POST" + action="{{url_for('oauth2.admin.change_client_secret', + client_id=client.client_id)}}"> + + <input type="hidden" name="client_id" value="{{client.client_id}}" /> + <input type="hidden" name="client_name" value="{{client.client_metadata.client_name}}" /> + + <div class="form-group"> + <input type="submit" class="btn btn-danger" value="generate new secret" /> + </div> +</form> + +{%endblock%} diff --git a/gn_auth/templates/admin/list-oauth2-clients.html b/gn_auth/templates/admin/list-oauth2-clients.html index ca0ee6d..6da5b2f 100644 --- a/gn_auth/templates/admin/list-oauth2-clients.html +++ b/gn_auth/templates/admin/list-oauth2-clients.html @@ -15,7 +15,7 @@ <th>Client Name</th> <th>Default Redirect URI</th> <th>Owner</th> - <th colspan="2">Actions</th> + <th colspan="3">Actions</th> </tr> </thead> @@ -43,6 +43,14 @@ class="btn btn-danger" /> </form> </td> + <td> + <a href="{{url_for('oauth2.admin.change_client_secret', + client_id=client.client_id)}}" + title="Change the client secret!" + class="btn btn-danger"> + Change Secret + </a> + </td> </tr> {%else%} <tr> 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 new file mode 100644 index 0000000..5f16a02 --- /dev/null +++ b/gn_auth/templates/emails/forgot-password.html @@ -0,0 +1,38 @@ +<html> + <head> + <meta charset="UTF-8" /> + <title>{{subject}}</title> + </head> + <body> + <p> + You (or someone pretending to be you) made a request to change your + password. Please follow the link below to change it. + </p> + + <p> + Click the button below to change your password + <a href="{{forgot_password_uri}}" + style="display: block;text-align: center;vertical-align: center;cursor: pointer;border-radius: 4px;background-color: #336699;border-color: #357ebd;color: white;text-decoration: none;font-size: large;width: 9em;text-transform: capitalize;margin: 1em 0 0 3em;box-shadow: 2px 2px rgba(0, 0, 0, 0.3);">Change my Password</a>.</p> + + <p> + Or copy the link below onto your browser's address bar:<br /><br /> + <span style="font-weight: bolder;">{{forgot_password_uri}}</span> + </p> + + <p> + If you did not request to change your password, simply ignore this email. + </p> + + <p style="font-weight: bold;color: #ee55ee;"> + The link will expire in <strong>{{expiration_minutes}}</strong>. + </p> + + <hr /> + <p> + <small> + Note that if you requested to change your password multiple times, only + the latest/newest token will be valid. + </small> + </p> + </body> +</html> diff --git a/gn_auth/templates/emails/forgot-password.txt b/gn_auth/templates/emails/forgot-password.txt new file mode 100644 index 0000000..68abf16 --- /dev/null +++ b/gn_auth/templates/emails/forgot-password.txt @@ -0,0 +1,12 @@ +{{subject}} +=============== + +You (or someone pretending to be you) made a request to change your password. Please copy the link below onto your browser to change your password: + +{{forgot_password_uri}} + +If you did not request to change your password, simply ignore this email. + +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 07edb73..f186167 100644 --- a/gn_auth/templates/oauth2/authorise-user.html +++ b/gn_auth/templates/oauth2/authorise-user.html @@ -2,41 +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')}}" - 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/templates/users/change-password.html b/gn_auth/templates/users/change-password.html new file mode 100644 index 0000000..f328255 --- /dev/null +++ b/gn_auth/templates/users/change-password.html @@ -0,0 +1,52 @@ +{%extends "base.html"%} + +{%block title%}gn-auth: Change Password{%endblock%} + +{%block pagetitle%}Change Password{%endblock%} + +{%block content%} +{{flash_messages()}} + +<div class="container-fluid"> + <div class="row"><h1>Change Password</h1></div> + + <div class="row"> + <form method="POST" + action="{{url_for('oauth2.users.change_password', + client_id=client_id, + redirect_uri=redirect_uri, + response_type=response_type, + forgot_password_token=forgot_password_token)}}"> + <div class="form-group"> + <p class="form-text text-info"> + Change the password for your account with the email + "<strong>{{email}}</strong>". + </p> + </div> + + <div class="form-group"> + <label for="txt-password" class="form-label">New Password</label> + <input type="password" + id="txt-password" + name="password" + class="form-control" + required="required" /> + </div> + + <div class="form-group"> + <label for="txt-confirm" class="form-label">Confirm New Password</label> + <input type="password" + id="txt-confirm" + name="confirm-password" + class="form-control" + required="required" /> + </div> + + <div class="form-group"> + <input type="submit" class="btn btn-danger" value="change password" /> + </div> + </form> + </div> + +</div> +{%endblock%} diff --git a/gn_auth/templates/users/forgot-password-token-send-success.html b/gn_auth/templates/users/forgot-password-token-send-success.html new file mode 100644 index 0000000..8782e8c --- /dev/null +++ b/gn_auth/templates/users/forgot-password-token-send-success.html @@ -0,0 +1,22 @@ +{%extends "base.html"%} + +{%block title%}gn-auth: Forgot Password{%endblock%} + +{%block pagetitle%}Forgot Password{%endblock%} + +{%block content%} +{{flash_messages()}} + +<div class="container-fluid"> + <div class="row"><h1>Forgot Password</h1></div> + + <div class="row"> + <p class="text-info" + style="font-size:1.5em;text-align:center;margin-top:2em;"> + We have sent an email to '<strong>{{email}}</strong>'. Please use the link + in the email we sent to change your password. + </p> + </div> + +</div> +{%endblock%} diff --git a/gn_auth/templates/users/forgot-password.html b/gn_auth/templates/users/forgot-password.html new file mode 100644 index 0000000..0455c69 --- /dev/null +++ b/gn_auth/templates/users/forgot-password.html @@ -0,0 +1,38 @@ +{%extends "base.html"%} + +{%block title%}gn-auth: Forgot Password{%endblock%} + +{%block pagetitle%}Forgot Password{%endblock%} + +{%block content%} +{{flash_messages()}} + +<div class="container-fluid"> + <div class="row"><h1>Forgot Password</h1></div> + + <div class="row"> + <form method="POST" + action="{{url_for('oauth2.users.forgot_password', + client_id=client_id, + redirect_uri=redirect_uri, + response_type=response_type)}}"> + <div class="form-group"> + <span> + Provide you email below, and we will send you a link you can use to + change your password. + </span> + </div> + + <div class="form-group"> + <label for="txt-email" class="form-label">Email</label> + <input type="email" name="email" id="txt-email" class="form-control" /> + </div> + + <div class="form-group"> + <input type="submit" class="btn btn-primary" value="Send Link" /> + </div> + </form> + </div> + +</div> +{%endblock%} diff --git a/gn_auth/wsgi.py b/gn_auth/wsgi.py index bb8abd2..e05ef0d 100644 --- a/gn_auth/wsgi.py +++ b/gn_auth/wsgi.py @@ -1,16 +1,12 @@ """Main entry point for project""" -import os import sys import uuid import json -import logging from math import ceil from pathlib import Path -from typing import Callable from datetime import datetime import click -from flask import Flask from yoyo import get_backend, read_migrations from gn_auth import migrations @@ -24,35 +20,7 @@ from gn_auth.auth.authorisation.users.admin.models import make_sys_admin 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 ##### |