diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b758a3 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +Radar Third Party CI Dashboard for OpenStack +===================== + +* Application Installation + * apt-get install libpq-dev libmysqlclient-dev + * apt-get install mysql-server + * apt-get install rabbitmq-server + * pip install --upgrade -r requirements.txt + * python setup.py build + * python setup.py install + +* Database + * CREATE user 'radar'@'localhost' IDENTIFIED BY 'radar'; + * GRANT ALL PRIVILEGES ON radar.* to 'radar'@'localhost'; + * FLUSH PRIVILEGES; + * radar-db-manage upgrade head + +* API + * radar-api + +* Update Daemon + * radar-update-daemon \ No newline at end of file diff --git a/etc/radar.conf.sample b/etc/radar.conf.sample new file mode 100644 index 0000000..8e85dd2 --- /dev/null +++ b/etc/radar.conf.sample @@ -0,0 +1,133 @@ +[DEFAULT] +# Default log level is INFO +# verbose and debug has the same result. +# One of them will set DEBUG log level output +# debug = True +# verbose = True + +# Where to store lock files +lock_path = $state_path/lock + +# Radar's working directory. Please ensure that the radar user has +# read/write access to this directory. +# working_directory = ~/.radar + +# log_format = %(asctime)s %(levelname)8s [%(name)s] %(message)s +# log_date_format = %Y-%m-%d %H:%M:%S + +# use_syslog -> syslog +# log_file and log_dir -> log_dir/log_file +# (not log_file) and log_dir -> log_dir/{binary_name}.log +# use_stderr -> stderr +# (not user_stderr) and (not log_file) -> stdout +# publish_errors -> notification system + +# use_syslog = False +# syslog_log_facility = LOG_USER + +# use_stderr = True +# log_file = +# log_dir = + +# publish_errors = False + +# Address to bind the API server +# bind_host = 0.0.0.0 + +# Port the bind the API server to +# bind_port = 8080 + +# OpenId Authentication endpoint +# openid_url = https://login.launchpad.net/+openid + +# Time in seconds before an access_token expires +# access_token_ttl = 3600 + +# Time in seconds before an refresh_token expires +# refresh_token_ttl = 604800 + +# List paging configuration options. +# page_size_maximum = 500 +# page_size_default = 100 + +# Enable notifications. This feature drives deferred processing, reporting, +# and subscriptions. +# enable_notifications = True + +[cors] +# W3C CORS configuration. For more information, see http://www.w3.org/TR/cors/ + +# List of permitted CORS domains. +# allowed_origins = https://radar.openstack.org, http://localhost:9000 + +# CORS browser options cache max age (in seconds) +# max_age=3600 + +[notifications] + +# Host of the rabbitmq server. +# rabbit_host=localhost + +# The RabbitMQ login method +# rabbit_login_method = AMQPLAIN + +# The RabbitMQ userid. +# rabbit_userid = guest + +# The RabbitMQ password. +# rabbit_password = guest + +# The RabbitMQ broker port where a single node is used. +# rabbit_port = 5672 + +# The virtual host within which our queues and exchanges live. +# rabbit_virtual_host = / + +# Application name that binds to rabbit. +# rabbit_application_name=radar + +# The name of the topic exchange to which radar will broadcast its events. +# rabbit_exchange_name=radar + +# The name of the queue that will be created for API events. +# rabbit_event_queue_name=radar_events + +[database] +# This line MUST be changed to actually run radar +# Example: +# connection = mysql://radar:radar@127.0.0.1:3306/radar +# Replace 127.0.0.1 above with the IP address of the database used by the +# main radar server. (Leave it as is if the database runs on this host.) +# connection=sqlite:// + +# The SQLAlchemy connection string used to connect to the slave database +# slave_connection = + +# Database reconnection retry times - in event connectivity is lost +# set to -1 implies an infinite retry count +# max_retries = 10 + +# Database reconnection interval in seconds - if the initial connection to the +# database fails +# retry_interval = 10 + +# Minimum number of SQL connections to keep open in a pool +# min_pool_size = 1 + +# Maximum number of SQL connections to keep open in a pool +# max_pool_size = 10 + +# Timeout in seconds before idle sql connections are reaped +# idle_timeout = 3600 + +# If set, use this value for max_overflow with sqlalchemy +# max_overflow = 20 + +# Verbosity of SQL debugging information. 0=None, 100=Everything +# connection_debug = 100 + +# Add python stack traces to SQL as comment strings +# connection_trace = True + +# If set, use this value for pool_timeout with sqlalchemy +# pool_timeout = 10 diff --git a/radar/__init__.py b/radar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/api/__init__.py b/radar/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/api/app.py b/radar/api/app.py new file mode 100644 index 0000000..c20fd8f --- /dev/null +++ b/radar/api/app.py @@ -0,0 +1,151 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from oslo.config import cfg +import pecan +from wsgiref import simple_server + +from radar.api.auth.token_storage import impls as storage_impls +from radar.api.auth.token_storage import storage +from radar.api import config as api_config +from radar.api.middleware.cors_middleware import CORSMiddleware +from radar.api.middleware import token_middleware +from radar.api.middleware import user_id_hook +from radar.api.v1.search import impls as search_engine_impls +from radar.api.v1.search import search_engine +from radar.notifications.notification_hook import NotificationHook +from radar.openstack.common.gettextutils import _LI # noqa +from radar.openstack.common import log +from radar.plugin.user_preferences import initialize_user_preferences + +CONF = cfg.CONF + +LOG = log.getLogger(__name__) + +API_OPTS = [ + cfg.StrOpt('bind_host', + default='0.0.0.0', + help='API host'), + cfg.IntOpt('bind_port', + default=8080, + help='API port'), + cfg.BoolOpt('enable_notifications', + default=False, + help='Enable Notifications') +] +CORS_OPTS = [ + cfg.ListOpt('allowed_origins', + default=None, + help='List of permitted CORS origins.'), + cfg.IntOpt('max_age', + default=3600, + help='Maximum cache age of CORS preflight requests.') +] +CONF.register_opts(API_OPTS) +CONF.register_opts(CORS_OPTS, 'cors') + + +def get_pecan_config(): + # Set up the pecan configuration + filename = api_config.__file__.replace('.pyc', '.py') + return pecan.configuration.conf_from_file(filename) + + +def setup_app(pecan_config=None): + if not pecan_config: + pecan_config = get_pecan_config() + + # Setup logging + cfg.set_defaults(log.log_opts, + default_log_levels=[ + 'radar=INFO', + 'radar.openstack.common.db=WARN', + 'sqlalchemy=WARN' + ]) + log.setup('radar') + + hooks = [ + user_id_hook.UserIdHook() + ] + + # Setup token storage + token_storage_type = CONF.token_storage_type + storage_cls = storage_impls.STORAGE_IMPLS[token_storage_type] + storage.set_storage(storage_cls()) + + # Setup search engine + search_engine_name = CONF.search_engine + search_engine_cls = search_engine_impls.ENGINE_IMPLS[search_engine_name] + search_engine.set_engine(search_engine_cls()) + + # Load user preference plugins + initialize_user_preferences() + + # Setup notifier + if CONF.enable_notifications: + hooks.append(NotificationHook()) + + app = pecan.make_app( + pecan_config.app.root, + debug=CONF.debug, + hooks=hooks, + force_canonical=getattr(pecan_config.app, 'force_canonical', True), + guess_content_type_from_ext=False + ) + + app = token_middleware.AuthTokenMiddleware(app) + + # Setup CORS + if CONF.cors.allowed_origins: + app = CORSMiddleware(app, + allowed_origins=CONF.cors.allowed_origins, + allowed_methods=['GET', 'POST', 'PUT', 'DELETE', + 'OPTIONS'], + allowed_headers=['origin', 'authorization', + 'accept', 'x-total', 'x-limit', + 'x-marker', 'x-client', + 'content-type'], + max_age=CONF.cors.max_age) + + return app + + +def start(): + CONF(project='radar') + + api_root = setup_app() + + # Create the WSGI server and start it + host = cfg.CONF.bind_host + port = cfg.CONF.bind_port + + srv = simple_server.make_server(host, port, api_root) + + LOG.info(_LI('Starting server in PID %s') % os.getpid()) + LOG.info(_LI("Configuration:")) + if host == '0.0.0.0': + LOG.info(_LI( + 'serving on 0.0.0.0:%(port)s, view at http://127.0.0.1:%(port)s') + % ({'port': port})) + else: + LOG.info(_LI("serving on http://%(host)s:%(port)s") % ( + {'host': host, 'port': port})) + + srv.serve_forever() + +if __name__ == '__main__': + start() \ No newline at end of file diff --git a/radar/api/auth/__init__.py b/radar/api/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/api/auth/authorization_checks.py b/radar/api/auth/authorization_checks.py new file mode 100644 index 0000000..f6d8ec1 --- /dev/null +++ b/radar/api/auth/authorization_checks.py @@ -0,0 +1,63 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pecan import abort +from pecan import request + +from radar.api.auth.token_storage import storage +from radar.db.api import users as user_api +from radar.openstack.common.gettextutils import _ # noqa + + +def _get_token(): + if request.authorization and len(request.authorization) == 2: + return request.authorization[1] + else: + return None + + +def guest(): + token_storage = storage.get_storage() + token = _get_token() + + # Public resources do not require a token. + if not token: + return True + + # But if there is a token, it should be valid. + return token_storage.check_access_token(token) + + +def authenticated(): + token_storage = storage.get_storage() + token = _get_token() + + return token and token_storage.check_access_token(token) + + +def superuser(): + token_storage = storage.get_storage() + token = _get_token() + + if not token: + return False + + token_info = token_storage.get_access_token_info(token) + user = user_api.user_get(token_info.user_id) + + if not user.is_superuser: + abort(403, _("This action is limited to superusers only.")) + + return user.is_superuser \ No newline at end of file diff --git a/radar/api/auth/oauth_validator.py b/radar/api/auth/oauth_validator.py new file mode 100644 index 0000000..1a78e41 --- /dev/null +++ b/radar/api/auth/oauth_validator.py @@ -0,0 +1,268 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime + +from oauthlib.oauth2 import RequestValidator +from oauthlib.oauth2 import WebApplicationServer +from oslo.config import cfg + +from radar.api.auth.token_storage import storage +from radar.db.api import users as user_api +from radar.openstack.common import log + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + +TOKEN_OPTS = [ + cfg.IntOpt("access_token_ttl", + default=60 * 60, # One hour + help="Time in seconds before an access_token expires"), + + cfg.IntOpt("refresh_token_ttl", + default=60 * 60 * 24 * 7, # One week + help="Time in seconds before an refresh_token expires") +] + +CONF.register_opts(TOKEN_OPTS) + + +class SkeletonValidator(RequestValidator): + """This is oauth skeleton for handling all kind of validations and storage + manipulations. + + As it is and OAuth2, not OpenId-connect, some methods are not required to + be implemented. + + Scope parameter validation is skipped as it is not a part of OpenId-connect + protocol. + + """ + + def __init__(self): + super(SkeletonValidator, self).__init__() + self.token_storage = storage.get_storage() + + def validate_client_id(self, client_id, request, *args, **kwargs): + """Check that a valid client is connecting + + """ + + # Let's think about valid clients later + return True + + def validate_redirect_uri(self, client_id, redirect_uri, request, *args, + **kwargs): + """Check that the client is allowed to redirect using the given + redirect_uri. + + """ + + #todo(nkonovalov): check an uri based on CONF.domain + return True + + def get_default_redirect_uri(self, client_id, request, *args, **kwargs): + return request.sb_redirect_uri + + def validate_scopes(self, client_id, scopes, client, request, *args, + **kwargs): + """Scopes are not supported in OpenId-connect + The "user" value is hardcoded here to fill the difference between + the protocols. + + """ + + # Verify that the claimed user is allowed to log in. + openid = request._params["openid.claimed_id"] + user = user_api.user_get_by_openid(openid) + + if user and not user.enable_login: + return False + + return scopes == "user" + + def get_default_scopes(self, client_id, request, *args, **kwargs): + """Scopes a client will authorize for if none are supplied in the + authorization request. + + """ + + return ["user"] + + def validate_response_type(self, client_id, response_type, client, request, + *args, **kwargs): + """Clients should only be allowed to use one type of response type, the + one associated with their one allowed grant type. + In this case it must be "code". + + """ + + return response_type == "code" + + # Post-authorization + + def save_authorization_code(self, client_id, code, request, *args, + **kwargs): + """Save the code to the storage and remove the state as it is persisted + in the "code" argument + """ + + openid = request._params["openid.claimed_id"] + email = request._params["openid.sreg.email"] + full_name = request._params["openid.sreg.fullname"] + username = request._params["openid.sreg.nickname"] + last_login = datetime.utcnow() + + user = user_api.user_get_by_openid(openid) + user_dict = {"full_name": full_name, + "username": username, + "email": email, + "last_login": last_login} + + if not user: + user_dict.update({"openid": openid}) + user = user_api.user_create(user_dict) + else: + user = user_api.user_update(user.id, user_dict) + + self.token_storage.save_authorization_code(code, user_id=user.id) + + # Token request + + def authenticate_client(self, request, *args, **kwargs): + """Skip the authentication here. It is handled through an OpenId client + The parameters are set to match th OAuth protocol. + + """ + + setattr(request, "client", type("Object", (object,), {})()) + setattr(request.client, "client_id", "1") + return True + + def validate_code(self, client_id, code, client, request, *args, **kwargs): + """Validate the code belongs to the client.""" + + return self.token_storage.check_authorization_code(code) + + def confirm_redirect_uri(self, client_id, code, redirect_uri, client, + *args, **kwargs): + """Check that the client is allowed to redirect using the given + redirect_uri. + + """ + + #todo(nkonovalov): check an uri based on CONF.domain + return True + + def validate_grant_type(self, client_id, grant_type, client, request, + *args, **kwargs): + """Clients should only be allowed to use one type of grant. + In this case, it must be "authorization_code" or "refresh_token" + + """ + + return (grant_type == "authorization_code" + or grant_type == "refresh_token") + + def _resolve_user_id(self, request): + + # Try authorization code + code = request._params.get("code") + if code: + code_info = self.token_storage.get_authorization_code_info(code) + return code_info.user_id + + # Try refresh token + refresh_token = request._params.get("refresh_token") + refresh_token_entry = self.token_storage.get_refresh_token_info( + refresh_token) + if refresh_token_entry: + return refresh_token_entry.user_id + + return None + + def save_bearer_token(self, token, request, *args, **kwargs): + """Save all token information to the storage.""" + + user_id = self._resolve_user_id(request) + + # If a refresh_token was used to obtain a new access_token, it should + # be removed. + self.invalidate_refresh_token(request) + + self.token_storage.save_token(access_token=token["access_token"], + expires_in=token["expires_in"], + refresh_token=token["refresh_token"], + user_id=user_id) + + def invalidate_authorization_code(self, client_id, code, request, *args, + **kwargs): + """Authorization codes are use once, invalidate it when a token has + been acquired. + + """ + + self.token_storage.invalidate_authorization_code(code) + + # Protected resource request + + def validate_bearer_token(self, token, scopes, request): + """The check will be performed in a separate middleware.""" + + pass + + # Token refresh request + + def get_original_scopes(self, refresh_token, request, *args, **kwargs): + """Scopes a client will authorize for if none are supplied in the + authorization request. + + """ + return ["user"] + + def rotate_refresh_token(self, request): + """The refresh token should be single use.""" + + return True + + def validate_refresh_token(self, refresh_token, client, request, *args, + **kwargs): + """Check that the refresh token exists in the db.""" + + return self.token_storage.check_refresh_token(refresh_token) + + def invalidate_refresh_token(self, request): + """Remove a used token from the storage.""" + + refresh_token = request._params.get("refresh_token") + + # The request may have no token in parameters which means that the + # authorization code was used. + if not refresh_token: + return + + self.token_storage.invalidate_refresh_token(refresh_token) + + +class OpenIdConnectServer(WebApplicationServer): + + def __init__(self, request_validator): + access_token_ttl = CONF.access_token_ttl + super(OpenIdConnectServer, self).__init__( + request_validator, + token_expires_in=access_token_ttl) + +validator = SkeletonValidator() +SERVER = OpenIdConnectServer(validator) \ No newline at end of file diff --git a/radar/api/auth/openid_client.py b/radar/api/auth/openid_client.py new file mode 100644 index 0000000..c391b22 --- /dev/null +++ b/radar/api/auth/openid_client.py @@ -0,0 +1,119 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.config import cfg +import requests + +from radar.api.auth import utils +from radar.openstack.common import log + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +OPENID_OPTS = [ + cfg.StrOpt('openid_url', + default='https://login.launchpad.net/+openid', + help='OpenId Authentication endpoint') +] + +CONF.register_opts(OPENID_OPTS) + + +class OpenIdClient(object): + + def send_openid_redirect(self, request, response): + redirect_location = CONF.openid_url + response.status_code = 303 + + return_params = { + "scope": str(request.params.get("scope")), + "state": str(request.params.get("state")), + "client_id": str(request.params.get("client_id")), + "response_type": str(request.params.get("response_type")), + "sb_redirect_uri": str(request.params.get("redirect_uri")) + } + + #TODO(krotscheck): URI base should be fully inferred from the request. + # assuming that the API is hosted at /api isn't good. + return_to_url = request.host_url + "/api/v1/openid/authorize_return?" \ + + utils.join_params(return_params, encode=True) + + response.status_code = 303 + + openid_params = { + "openid.ns": "http://specs.openid.net/auth/2.0", + "openid.mode": "checkid_setup", + + "openid.claimed_id": "http://specs.openid.net/auth/2.0/" + "identifier_select", + "openid.identity": "http://specs.openid.net/auth/2.0/" + "identifier_select", + + "openid.realm": request.host_url, + "openid.return_to": return_to_url, + + "openid.ns.sreg": "http://openid.net/sreg/1.0", + "openid.sreg.required": "fullname,email,nickname", + + "openid.ns.ext2": "http://openid.net/srv/ax/1.0", + "openid.ext2.mode": "fetch_request", + "openid.ext2.type.FirstName": "http://schema.openid.net/" + "namePerson/first", + "openid.ext2.type.LastName": "http://schema.openid.net/" + "namePerson/last", + "openid.ext2.type.Email": "http://schema.openid.net/contact/email", + "openid.ext2.required": "FirstName,LastName,Email" + } + joined_params = utils.join_params(openid_params) + + redirect_location = redirect_location + '?' + joined_params + response.headers["Location"] = redirect_location + + return response + + def verify_openid(self, request, response): + verify_params = dict(request.params.copy()) + verify_params["openid.mode"] = "check_authentication" + + verify_response = requests.post(CONF.openid_url, data=verify_params) + verify_data_tokens = verify_response.content.split() + verify_dict = dict((token.split(":")[0], token.split(":")[1]) + for token in verify_data_tokens) + + if (verify_response.status_code / 100 != 2 + or verify_dict['is_valid'] != 'true'): + response.status_code = 401 # Unauthorized + return False + + return True + + def create_association(self, op_location): + # Let's skip it for MVP at least + query_dict = { + "openid.ns": "http://specs.openid.net/auth/2.0", + "openid.mode": "associate", + "openid.assoc_type": "HMAC-SHA256", + "openid.session_type": "no-encryption" + } + assoc_data = requests.post(op_location, data=query_dict).content + + data_tokens = assoc_data.split() + data_dict = dict((token.split(":")[0], token.split(":")[1]) + for token in data_tokens) + + return data_dict["assoc_handle"] + +client = OpenIdClient() \ No newline at end of file diff --git a/radar/api/auth/token_storage/__init__.py b/radar/api/auth/token_storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/api/auth/token_storage/db_storage.py b/radar/api/auth/token_storage/db_storage.py new file mode 100644 index 0000000..726acb1 --- /dev/null +++ b/radar/api/auth/token_storage/db_storage.py @@ -0,0 +1,104 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +from oslo.config import cfg + +from radar.api.auth.token_storage import storage +from radar.db.api import auth as auth_api + + +CONF = cfg.CONF + + +class DBTokenStorage(storage.StorageBase): + def save_authorization_code(self, authorization_code, user_id): + values = { + "code": authorization_code["code"], + "state": authorization_code["state"], + "user_id": user_id + } + auth_api.authorization_code_save(values) + + def get_authorization_code_info(self, code): + return auth_api.authorization_code_get(code) + + def check_authorization_code(self, code): + db_code = auth_api.authorization_code_get(code) + return not db_code is None + + def invalidate_authorization_code(self, code): + auth_api.authorization_code_delete(code) + + def save_token(self, access_token, expires_in, refresh_token, user_id): + access_token_values = { + "access_token": access_token, + "expires_in": expires_in, + "expires_at": datetime.datetime.now() + datetime.timedelta( + seconds=expires_in), + "user_id": user_id + } + + # Oauthlib does not provide a separate expiration time for a + # refresh_token so taking it from config directly. + refresh_expires_in = CONF.refresh_token_ttl + + refresh_token_values = { + "refresh_token": refresh_token, + "user_id": user_id, + "expires_in": refresh_expires_in, + "expires_at": datetime.datetime.now() + datetime.timedelta( + seconds=refresh_expires_in), + } + + auth_api.access_token_save(access_token_values) + auth_api.refresh_token_save(refresh_token_values) + + def get_access_token_info(self, access_token): + return auth_api.access_token_get(access_token) + + def check_access_token(self, access_token): + token_info = auth_api.access_token_get(access_token) + + if not token_info: + return False + + if datetime.datetime.now() > token_info.expires_at: + auth_api.access_token_delete(access_token) + return False + + return True + + def remove_token(self, access_token): + auth_api.access_token_delete(access_token) + + def check_refresh_token(self, refresh_token): + refresh_token_entry = auth_api.refresh_token_get(refresh_token) + + if not refresh_token_entry: + return False + + if datetime.datetime.now() > refresh_token_entry.expires_at: + auth_api.refresh_token_delete(refresh_token) + return False + + return True + + def get_refresh_token_info(self, refresh_token): + return auth_api.refresh_token_get(refresh_token) + + def invalidate_refresh_token(self, refresh_token): + auth_api.refresh_token_delete(refresh_token) \ No newline at end of file diff --git a/radar/api/auth/token_storage/impls.py b/radar/api/auth/token_storage/impls.py new file mode 100644 index 0000000..552564c --- /dev/null +++ b/radar/api/auth/token_storage/impls.py @@ -0,0 +1,22 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from radar.api.auth.token_storage import db_storage +from radar.api.auth.token_storage import memory_storage + +STORAGE_IMPLS = { + "mem": memory_storage.MemoryTokenStorage, + "db": db_storage.DBTokenStorage +} diff --git a/radar/api/auth/token_storage/memory_storage.py b/radar/api/auth/token_storage/memory_storage.py new file mode 100644 index 0000000..0ff1834 --- /dev/null +++ b/radar/api/auth/token_storage/memory_storage.py @@ -0,0 +1,130 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +from radar.api.auth.token_storage import storage + + +class Token(object): + def __init__(self, access_token, refresh_token, expires_in, user_id, + is_valid=True): + self.access_token = access_token + self.refresh_token = refresh_token + self.expires_in = expires_in + self.expires_at = datetime.datetime.utcnow() + \ + datetime.timedelta(seconds=expires_in) + self.user_id = user_id + self.is_valid = is_valid + + +class AuthorizationCode(object): + def __init__(self, code, user_id): + self.code = code + self.user_id = user_id + + +class MemoryTokenStorage(storage.StorageBase): + + def __init__(self): + self.token_set = set([]) + self.auth_code_set = set([]) + + def save_token(self, access_token, expires_in, refresh_token, user_id): + token_info = Token(access_token=access_token, + expires_in=expires_in, + refresh_token=refresh_token, + user_id=user_id) + + self.token_set.add(token_info) + + def check_access_token(self, access_token): + token_entry = None + for token_info in self.token_set: + if token_info.access_token == access_token: + token_entry = token_info + + if not token_entry: + return False + + now = datetime.datetime.utcnow() + if now > token_entry.expires_at: + token_entry.is_valid = False + return False + + return True + + def get_access_token_info(self, access_token): + for token_info in self.token_set: + if token_info.access_token == access_token: + return token_info + return None + + def remove_token(self, token): + pass + + def check_refresh_token(self, refresh_token): + for token_info in self.token_set: + if token_info.refresh_token == refresh_token: + return True + + return False + + def get_refresh_token_info(self, refresh_token): + for token_info in self.token_set: + if token_info.refresh_token == refresh_token: + return token_info + + return None + + def invalidate_refresh_token(self, refresh_token): + token_entry = None + for entry in self.token_set: + if entry.refresh_token == refresh_token: + token_entry = entry + break + + self.token_set.remove(token_entry) + + def save_authorization_code(self, authorization_code, user_id): + self.auth_code_set.add(AuthorizationCode(authorization_code, user_id)) + + def check_authorization_code(self, code): + code_entry = None + for entry in self.auth_code_set: + if entry.code["code"] == code: + code_entry = entry + break + + if not code_entry: + return False + + return True + + def get_authorization_code_info(self, code): + for entry in self.auth_code_set: + if entry.code["code"] == code: + return entry + + return None + + def invalidate_authorization_code(self, code): + code_entry = None + for entry in self.auth_code_set: + if entry.code["code"] == code: + code_entry = entry + break + + self.auth_code_set.remove(code_entry) diff --git a/radar/api/auth/token_storage/storage.py b/radar/api/auth/token_storage/storage.py new file mode 100644 index 0000000..acc4bd0 --- /dev/null +++ b/radar/api/auth/token_storage/storage.py @@ -0,0 +1,153 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import abc + +from oslo.config import cfg + +CONF = cfg.CONF + +STORAGE_OPTS = [ + cfg.StrOpt('token_storage_type', + default='db', + help='Authorization token storage type.' + ' Supported types are "mem" and "db".' + ' Memory storage is not persistent between api launches') +] + +CONF.register_opts(STORAGE_OPTS) + + +class StorageBase(object): + + @abc.abstractmethod + def save_authorization_code(self, authorization_code, user_id): + """This method should save an Authorization Code to the storage and + associate it with a user_id. + + @param authorization_code: An object, containing state and a the code + itself. + @param user_id: The id of a User to associate the code with. + """ + pass + + @abc.abstractmethod + def check_authorization_code(self, code): + """Check that the given token exists in the storage. + + @param code: The code to be checked. + @return bool + """ + pass + + @abc.abstractmethod + def get_authorization_code_info(self, code): + """Get the code info from the storage. + + @param code: An authorization Code + + @return object: The returned object should contain the state and the + user_id, which the given code is associated with. + """ + pass + + @abc.abstractmethod + def invalidate_authorization_code(self, code): + """Remove a code from the storage. + + @param code: An authorization Code + """ + pass + + @abc.abstractmethod + def save_token(self, access_token, expires_in, refresh_token, user_id): + """Save a Bearer token to the storage with all associated fields + + @param access_token: A token that will be used in authorized requests. + @param expires_in: The time in seconds while the access_token is valid. + @param refresh_token: A token that will be used in a refresh request + after an access_token gets expired. + @param user_id: The id of a User which owns a token. + """ + pass + + @abc.abstractmethod + def check_access_token(self, access_token): + """This method should say if a given token exists in the storage and + that it has not expired yet. + + @param access_token: The token to be checked. + @return bool + """ + pass + + @abc.abstractmethod + def get_access_token_info(self, access_token): + """Get the Bearer token from the storage. + + @param access_token: The token to get the information about. + @return object: The object should contain all fields associated with + the token (refresh_token, expires_in, user_id). + """ + pass + + @abc.abstractmethod + def remove_token(self, token): + """Invalidate a given token and remove it from the storage. + + @param token: The token to be removed. + """ + pass + + @abc.abstractmethod + def check_refresh_token(self, refresh_token): + """This method should say if a given token exists in the storage and + that it has not expired yet. + + @param refresh_token: The token to be checked. + @return bool + """ + pass + + @abc.abstractmethod + def get_refresh_token_info(self, refresh_token): + """Get the Bearer token from the storage. + + @param refresh_token: The token to get the information about. + @return object: The object should contain all fields associated with + the token (refresh_token, expires_in, user_id). + """ + pass + + @abc.abstractmethod + def invalidate_refresh_token(self, refresh_token): + """Remove a token from the storage. + + @param refresh_token: A refresh token + """ + pass + + +STORAGE = None + + +def get_storage(): + global STORAGE + return STORAGE + + +def set_storage(impl): + global STORAGE + STORAGE = impl diff --git a/radar/api/auth/utils.py b/radar/api/auth/utils.py new file mode 100644 index 0000000..f36e369 --- /dev/null +++ b/radar/api/auth/utils.py @@ -0,0 +1,24 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import six +import urllib + + +def join_params(params, encode=True): + return '&'.join( + ["%s=%s" % (urllib.quote(key, safe='') if encode else key, + urllib.quote(val, safe='') if encode else val) + for key, val in six.iteritems(params)]) \ No newline at end of file diff --git a/radar/api/config.py b/radar/api/config.py new file mode 100644 index 0000000..62d90a7 --- /dev/null +++ b/radar/api/config.py @@ -0,0 +1,18 @@ +from oslo.config import cfg + +app = { + 'root': 'radar.api.root_controller.RootController', + 'modules': ['radar.api'], + 'debug': False +} + +cfg.CONF.register_opts([ + cfg.IntOpt('page_size_maximum', + default=500, + help='The maximum number of results to allow a user to request ' + 'from the API'), + cfg.IntOpt('page_size_default', + default=20, + help='The maximum number of results to allow a user to request ' + 'from the API') +]) \ No newline at end of file diff --git a/radar/api/middleware/__init__.py b/radar/api/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/api/middleware/cors_middleware.py b/radar/api/middleware/cors_middleware.py new file mode 100644 index 0000000..dae2639 --- /dev/null +++ b/radar/api/middleware/cors_middleware.py @@ -0,0 +1,113 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and +# limitations under the License. + +# Default allowed headers +ALLOWED_HEADERS = [ + 'origin', + 'authorization', + 'accept' +] +# Default allowed methods +ALLOWED_METHODS = [ + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'OPTIONS' +] + + +class CORSMiddleware(object): + """CORS Middleware. + + By providing a list of allowed origins, methods, headers, and a max-age, + this middleware will detect and apply the appropriate CORS headers so + that your web application may elegantly overcome the browser's + same-origin sandbox. + + For more information, see http://www.w3.org/TR/cors/ + """ + + def __init__(self, app, allowed_origins=None, allowed_methods=None, + allowed_headers=None, max_age=3600): + """Create a new instance of the CORS middleware. + + :param app: The application that is being wrapped. + :param allowed_origins: A list of allowed origins, as provided by the + 'Origin:' Http header. Must include protocol, host, and port. + :param allowed_methods: A list of allowed HTTP methods. + :param allowed_headers: A list of allowed HTTP headers. + :param max_age: A maximum CORS cache age in seconds. + :return: A new middleware instance. + """ + + # Wrapped app (or other middleware) + self.app = app + + # Allowed origins + self.allowed_origins = allowed_origins or [] + + # List of allowed headers. + self.allowed_headers = ','.join(allowed_headers or ALLOWED_HEADERS) + + # List of allowed methods. + self.allowed_methods = ','.join(allowed_methods or ALLOWED_METHODS) + + # Cache age. + self.max_age = str(max_age) + + def __call__(self, env, start_response): + """Serve an application request. + + :param env: Application environment parameters. + :param start_response: Wrapper method that starts the response. + :return: + """ + origin = env['HTTP_ORIGIN'] if 'HTTP_ORIGIN' in env else '' + method = env['REQUEST_METHOD'] if 'REQUEST_METHOD' in env else '' + + def replacement_start_response(status, headers, exc_info=None): + """Overrides the default response to attach CORS headers. + """ + + # Decorate the headers + headers.append(('Access-Control-Allow-Origin', + origin)) + headers.append(('Access-Control-Allow-Methods', + self.allowed_methods)) + headers.append(('Access-Control-Expose-Headers', + self.allowed_headers)) + headers.append(('Access-Control-Allow-Headers', + self.allowed_headers)) + headers.append(('Access-Control-Max-Age', + self.max_age)) + + return start_response(status, headers, exc_info) + + # Does this request match one of our origin domains? + if origin in self.allowed_origins: + + # Is this an OPTIONS request? + if method == 'OPTIONS': + options_headers = [('Content-Length', '0')] + replacement_start_response('204 No Content', options_headers) + return '' + else: + # Handle the request. + return self.app(env, replacement_start_response) + else: + # This is not a request for a permitted CORS domain. Return + # the response without the appropriate headers and let the browser + # figure out the details. + return self.app(env, start_response) diff --git a/radar/api/middleware/token_middleware.py b/radar/api/middleware/token_middleware.py new file mode 100644 index 0000000..d09c2d1 --- /dev/null +++ b/radar/api/middleware/token_middleware.py @@ -0,0 +1,51 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +AUTH_PREFIX = "/v1/openid" + + +class AuthTokenMiddleware(object): + + def __init__(self, app, allow_unauthorized=None): + self.app = app + self.allow_unauthorized = allow_unauthorized or [] + + def _header_to_env_var(self, key): + """Convert header to wsgi env variable. + + """ + return 'HTTP_%s' % key.replace('-', '_').upper() + + def _get_header(self, env, key, default=None): + """Get http header from environment.""" + env_key = self._header_to_env_var(key) + return env.get(env_key, default) + + def _get_url(self, env): + return env.get("PATH_INFO") + + def _get_method(self, env): + return env.get("REQUEST_METHOD") + + def _clear_params(self, url): + return url.split("?")[0] + + def __call__(self, env, start_response): + url = self._get_url(env) + + if url and url.startswith(AUTH_PREFIX): + return self.app(env, start_response) + + return self.app(env, start_response) diff --git a/radar/api/middleware/user_id_hook.py b/radar/api/middleware/user_id_hook.py new file mode 100644 index 0000000..0de6bb5 --- /dev/null +++ b/radar/api/middleware/user_id_hook.py @@ -0,0 +1,34 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pecan import hooks + +from radar.api.auth.token_storage import storage + + +class UserIdHook(hooks.PecanHook): + + def before(self, state): + request = state.request + + if request.authorization and len(request.authorization) == 2: + token = request.authorization[1] + token_info = storage.get_storage().get_access_token_info(token) + + if token_info: + request.current_user_id = token_info.user_id + return + + request.current_user_id = None diff --git a/radar/api/root_controller.py b/radar/api/root_controller.py new file mode 100644 index 0000000..892198c --- /dev/null +++ b/radar/api/root_controller.py @@ -0,0 +1,6 @@ +from radar.api.v1.v1_controller import V1Controller +from pecan import expose +from pecan.core import redirect +class RootController(object): + v1 = V1Controller() + diff --git a/radar/api/v1/__init__.py b/radar/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/api/v1/auth.py b/radar/api/v1/auth.py new file mode 100644 index 0000000..5650ce2 --- /dev/null +++ b/radar/api/v1/auth.py @@ -0,0 +1,132 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +import pecan +from pecan import request +from pecan import response +from pecan import rest + +from radar.api.auth.oauth_validator import SERVER +from radar.api.auth.openid_client import client as openid_client +from radar.api.auth.token_storage import storage +from radar.openstack.common import log + +LOG = log.getLogger(__name__) + + +class AuthController(rest.RestController): + _custom_actions = { + "authorize": ["GET"], + "authorize_return": ["GET"], + "token": ["POST"], + } + + @pecan.expose() + def authorize(self): + """Authorization code request.""" + + return openid_client.send_openid_redirect(request, response) + + @pecan.expose() + def authorize_return(self): + """Authorization code redirect endpoint. + At this point the server verifies an OpenId and retrieves user's + e-mail and full name from request + + The client may already use both the e-mail and the fullname in the + templates, even though there was no token request so far. + + """ + + if not openid_client.verify_openid(request, response): + # The verify call will set unauthorized code + return response + + headers, body, code = SERVER.create_authorization_response( + uri=request.url, + http_method=request.method, + body=request.body, + scopes=request.params.get("scope"), + headers=request.headers) + + response.headers = dict((str(k), str(v)) + for k, v in headers.iteritems()) + response.status_code = code + response.body = body or '' + + return response + + def _access_token_by_code(self): + auth_code = request.params.get("code") + code_info = storage.get_storage() \ + .get_authorization_code_info(auth_code) + headers, body, code = SERVER.create_token_response( + uri=request.url, + http_method=request.method, + body=request.body, + headers=request.headers) + response.headers = dict((str(k), str(v)) + for k, v in headers.iteritems()) + response.status_code = code + json_body = json.loads(body) + + # Update a body with user_id only if a response is 2xx + if code / 100 == 2: + json_body.update({ + 'id_token': code_info.user_id + }) + + response.body = json.dumps(json_body) + return response + + def _access_token_by_refresh_token(self): + refresh_token = request.params.get("refresh_token") + refresh_token_info = storage.get_storage().get_refresh_token_info( + refresh_token) + + headers, body, code = SERVER.create_token_response( + uri=request.url, + http_method=request.method, + body=request.body, + headers=request.headers) + response.headers = dict((str(k), str(v)) + for k, v in headers.iteritems()) + response.status_code = code + json_body = json.loads(body) + + # Update a body with user_id only if a response is 2xx + if code / 100 == 2: + json_body.update({ + 'id_token': refresh_token_info.user_id + }) + + response.body = json.dumps(json_body) + + return response + + @pecan.expose() + def token(self): + """Token endpoint.""" + + grant_type = request.params.get("grant_type") + if grant_type == "authorization_code": + # Serve an access token having an authorization code + return self._access_token_by_code() + + if grant_type == "refresh_token": + # Serve an access token having a refresh token + return self._access_token_by_refresh_token() \ No newline at end of file diff --git a/radar/api/v1/base.py b/radar/api/v1/base.py new file mode 100644 index 0000000..2329b2e --- /dev/null +++ b/radar/api/v1/base.py @@ -0,0 +1,47 @@ +from datetime import datetime +from wsme import types as wtypes + +class APIBase(wtypes.Base): + + id = int + """This is a unique identifier used as a primary key in all Database + models. + """ + + created_at = datetime + """The time when an object was added to the Database. This field is + managed by SqlAlchemy automatically. + """ + + updated_at = datetime + """The time when the object was updated to it's actual state. This + field is also managed by SqlAlchemy. + """ + + @classmethod + def from_db_model(cls, db_model, skip_fields=None): + """Returns the object from a given database representation.""" + skip_fields = skip_fields or [] + data = dict((k, v) for k, v in db_model.as_dict().items() + if k not in skip_fields) + return cls(**data) + + def as_dict(self, omit_unset=False): + """Converts this object into dictionary.""" + attribute_names = [a.name for a in self._wsme_attributes] + + if omit_unset: + attribute_names = [n for n in attribute_names + if getattr(self, n) != wtypes.Unset] + + values = dict((name, self._lookup(name)) for name in attribute_names) + return values + + def _lookup(self, key): + """Looks up a key, translating WSME's Unset into Python's None. + :return: value of the given attribute; None if it is not set + """ + value = getattr(self, key) + if value == wtypes.Unset: + value = None + return value diff --git a/radar/api/v1/operator.py b/radar/api/v1/operator.py new file mode 100644 index 0000000..3dd7dd3 --- /dev/null +++ b/radar/api/v1/operator.py @@ -0,0 +1,209 @@ +# Copyright (c) 2014 Triniplex. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.config import cfg +from pecan import expose +from pecan import request +from pecan import response +from pecan import rest +from pecan.secure import secure +from wsme.exc import ClientSideError +import wsmeext.pecan as wsme_pecan + +from radar.api.auth import authorization_checks as checks +from radar.api.v1.search import search_engine +from radar.api.v1 import wmodels +from radar.db.api import operators as operators_api +from radar.db.api import systems + + +CONF = cfg.CONF + +SEARCH_ENGINE = search_engine.get_engine() + + +class OperatorsController(rest.RestController): + """Manages operations on operators.""" + + _custom_actions = {"search": ["GET"]} + + @secure(checks.guest) + @wsme_pecan.wsexpose(wmodels.Operator, int) + def get_one_by_id(self, operator_id): + """Retrieve details about one operator. + + :param operator_id: An ID of the operator. + """ + operator = operators_api.operator_get(operator_id) + + if operator: + return wmodels.Operator.from_db_model(operator) + else: + raise ClientSideError("Operator %s not found" % operator_id, + status_code=404) + + @secure(checks.guest) + @wsme_pecan.wsexpose(wmodels.Operator, unicode) + def get_one_by_name(self, operator_name): + """Retrieve information about the given project. + + :param name: project name. + """ + + operator = operators_api.operator_get_by_name(operator_name) + + if operator: + return wmodels.Operator.from_db_model(operator) + else: + raise ClientSideError("Operator %s not found" % operator_name, + status_code=404) + + @secure(checks.guest) + @wsme_pecan.wsexpose([wmodels.Operator], int, int, unicode, unicode, + unicode) + def get(self, marker=None, limit=None, operator_name=None, sort_field='id', + sort_dir='asc'): + """Retrieve definitions of all of the operators. + + :param name: A string to filter the name by. + """ + + # Boundary check on limit. + if limit is None: + limit = CONF.page_size_default + limit = min(CONF.page_size_maximum, max(1, limit)) + + # Resolve the marker record. + marker_operator = operators_api.operator_get(marker) + + operators = operators_api \ + .operator_get_all(marker=marker_operator, + limit=limit, + name=operator_name, + sort_field=sort_field, + sort_dir=sort_dir) + operator_count = operators_api \ + .operator_get_count(name=operator_name) + + # Apply the query response headers. + response.headers['X-Limit'] = str(limit) + response.headers['X-Total'] = str(operator_count) + if marker_operator: + response.headers['X-Marker'] = str(marker_operator.id) + + if operators: + return [wmodels.Operator.from_db_model(o) for o in operators] + else: + raise ClientSideError("Could not retrieve operators list", + status_code=404) + + + @secure(checks.guest) + @wsme_pecan.wsexpose(wmodels.Operator, int, body=wmodels.Operator) + def post(self, system_id, operator): + """Create a new operator. + + :param operator: a operator within the request body. + """ + operator_dict = operator.as_dict() + + created_operator = operators_api.operator_create(operator_dict) + created_operator = operators_api.operator_add_system(created_operator.id, system_id) + + return wmodels.Operator.from_db_model(created_operator) + + @secure(checks.guest) + @wsme_pecan.wsexpose(wmodels.Operator, int, body=wmodels.Operator) + def put(self, operator_id, operator): + """Modify this operator. + + :param operator_id: An ID of the operator. + :param operator: a operator within the request body. + """ + updated_operator = operators_api.operator_update( + operator_id, + operator.as_dict(omit_unset=True)) + + if updated_operator: + return wmodels.Operator.from_db_model(updated_operator) + else: + raise ClientSideError("Operator %s not found" % operator_id, + status_code=404) + + @secure(checks.superuser) + @wsme_pecan.wsexpose(wmodels.Operator, int) + def delete(self, operator_id): + """Delete this operator. + + :param operator_id: An ID of the operator. + """ + operators_api.operator_delete(operator_id) + + response.status_code = 204 + + + def _is_int(self, s): + try: + int(s) + return True + except ValueError: + return False + + @secure(checks.guest) + @wsme_pecan.wsexpose([wmodels.Operator], unicode, unicode, int, int) + def search(self, q="", marker=None, limit=None): + """The search endpoint for operators. + + :param q: The query string. + :return: List of Operators matching the query. + """ + + operators = SEARCH_ENGINE.operators_query(q=q, + marker=marker, + limit=limit) + + return [wmodels.Operator.from_db_model(operator) for operator in operators] + + @wsme_pecan.wsexpose(long, unicode) + def count(self, args): + operators = operators_api.count() + + if operators: + return operators + else: + raise ClientSideError("Cannot return operator count for %s" + % kwargs, status_code=404) + + @expose() + def _route(self, args, request): + if request.method == 'GET' and len(args) > 0: + # It's a request by a name or id + something = args[0] + + if something == "search": + # Request to a search endpoint + return self.search, args + + if something == "count" and len(args) == 2: + return self.count, args + + if self._is_int(something): + # Get by id + return self.get_one_by_id, args + else: + # Get by name + return self.get_one_by_name, ["/".join(args)] + + return super(OperatorsController, self)._route(args, request) diff --git a/radar/api/v1/search/__init__.py b/radar/api/v1/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/api/v1/search/impls.py b/radar/api/v1/search/impls.py new file mode 100644 index 0000000..b62a60a --- /dev/null +++ b/radar/api/v1/search/impls.py @@ -0,0 +1,21 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from radar.api.v1.search.sqlalchemy_impl import SqlAlchemySearchImpl + + +ENGINE_IMPLS = { + "sqlalchemy": SqlAlchemySearchImpl +} diff --git a/radar/api/v1/search/search_engine.py b/radar/api/v1/search/search_engine.py new file mode 100644 index 0000000..2637a43 --- /dev/null +++ b/radar/api/v1/search/search_engine.py @@ -0,0 +1,60 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import abc + +from oslo.config import cfg + +from radar.db import models + +CONF = cfg.CONF + +SEARCH_OPTS = [ + cfg.StrOpt('search_engine', + default='sqlalchemy', + help='Search engine implementation.' + ' The only supported type is "sqlalchemy".') +] + +CONF.register_opts(SEARCH_OPTS) + + +class SearchEngine(object): + """This is an interface that should be implemented by search engines. + + """ + + searchable_fields = { + models.System: ["name"], + models.Operator: ["operator_name", "operator_email"], + models.SystemEvent: ["event_type", "event_info"], + } + + @abc.abstractmethod + def systems_query(self, q, name=None, + marker=None, limit=None, **kwargs): + pass + +ENGINE = None + + +def get_engine(): + global ENGINE + return ENGINE + + +def set_engine(impl): + global ENGINE + ENGINE = impl diff --git a/radar/api/v1/search/sqlalchemy_impl.py b/radar/api/v1/search/sqlalchemy_impl.py new file mode 100644 index 0000000..6133d66 --- /dev/null +++ b/radar/api/v1/search/sqlalchemy_impl.py @@ -0,0 +1,51 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.db.sqlalchemy import utils +from sqlalchemy_fulltext import FullTextSearch +import sqlalchemy_fulltext.modes as FullTextMode + +from radar.api.v1.search import search_engine +from radar.db.api import base as api_base +from radar.db import models + + +class SqlAlchemySearchImpl(search_engine.SearchEngine): + + def _build_fulltext_search(self, model_cls, query, q): + query = query.filter(FullTextSearch(q, model_cls, + mode=FullTextMode.NATURAL)) + + return query + + def _apply_pagination(self, model_cls, query, marker=None, limit=None): + + marker_entity = None + if marker: + marker_entity = api_base.entity_get(model_cls, marker, True) + + return utils.paginate_query(query=query, + model=model_cls, + limit=limit, + sort_keys=["id"], + marker=marker_entity) + + def systems_query(self, q, marker=None, limit=None, **kwargs): + session = api_base.get_session() + query = api_base.model_query(models.System, session) + query = self._build_fulltext_search(models.System, query, q) + query = self._apply_pagination(models.System, query, marker, limit) + + return query.all() diff --git a/radar/api/v1/subscription.py b/radar/api/v1/subscription.py new file mode 100644 index 0000000..e44a9f4 --- /dev/null +++ b/radar/api/v1/subscription.py @@ -0,0 +1,191 @@ +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.config import cfg +from pecan import abort +from pecan import request +from pecan import response +from pecan import rest +from pecan.secure import secure +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from radar.api.auth import authorization_checks as checks +from radar.api.v1 import base +from radar.db.api import subscriptions as subscription_api +from radar.db.api import users as user_api +from radar.openstack.common.gettextutils import _ # noqa + + +CONF = cfg.CONF + + +class Subscription(base.APIBase): + """A model that describes a resource subscription. + """ + + user_id = int + """The owner of this subscription. + """ + + target_type = wtypes.text + """The type of resource that the user is subscribed to. + """ + + target_id = int + """The database ID of the resource that the user is subscribed to. + """ + + @classmethod + def sample(cls): + return cls( + user_id=1, + target_type="subscription", + target_id=1) + + +class SubscriptionsController(rest.RestController): + """REST controller for Subscriptions. + + Provides Create, Delete, and search methods for resource subscriptions. + """ + + @secure(checks.authenticated) + @wsme_pecan.wsexpose(Subscription, int) + def get_one(self, subscription_id): + """Retrieve a specific subscription record. + + :param subscription_id: The unique id of this subscription. + """ + + subscription = subscription_api.subscription_get(subscription_id) + current_user = user_api.user_get(request.current_user_id) + + if subscription.user_id != request.current_user_id \ + and not current_user.is_superuser: + abort(403, _("You do not have access to this record.")) + + return Subscription.from_db_model(subscription) + + @secure(checks.authenticated) + @wsme_pecan.wsexpose([Subscription], int, int, [unicode], int, int, + unicode, unicode) + def get(self, marker=None, limit=None, target_type=None, target_id=None, + user_id=None, sort_field='id', sort_dir='asc'): + """Retrieve a list of subscriptions. + + :param marker: The resource id where the page should begin. + :param limit: The number of subscriptions to retrieve. + :param target_type: The type of resource to search by. + :param target_id: The unique ID of the resource to search by. + :param user_id: The unique ID of the user to search by. + :param sort_field: The name of the field to sort on. + :param sort_dir: sort direction for results (asc, desc). + """ + + # Boundary check on limit. + if limit is None: + limit = CONF.page_size_default + limit = min(CONF.page_size_maximum, max(1, limit)) + + # Sanity check on user_id + current_user = user_api.user_get(request.current_user_id) + if user_id != request.current_user_id \ + and not current_user.is_superuser: + user_id = request.current_user_id + + # Resolve the marker record. + marker_sub = subscription_api.subscription_get(marker) + + subscriptions = subscription_api.subscription_get_all( + marker=marker_sub, + limit=limit, + target_type=target_type, + target_id=target_id, + user_id=user_id, + sort_field=sort_field, + sort_dir=sort_dir) + subscription_count = subscription_api.subscription_get_count( + target_type=target_type, + target_id=target_id, + user_id=user_id) + + # Apply the query response headers. + response.headers['X-Limit'] = str(limit) + response.headers['X-Total'] = str(subscription_count) + if marker_sub: + response.headers['X-Marker'] = str(marker_sub.id) + + return [Subscription.from_db_model(s) for s in subscriptions] + + @secure(checks.authenticated) + @wsme_pecan.wsexpose(Subscription, body=Subscription) + def post(self, subscription): + """Create a new subscription. + + :param subscription: A subscription within the request body. + """ + + # Data sanity check - are all fields set? + if not subscription.target_type or not subscription.target_id: + abort(400, _('You are missing either the target_type or the' + ' target_id')) + + # Sanity check on user_id + current_user = user_api.user_get(request.current_user_id) + if not subscription.user_id: + subscription.user_id = request.current_user_id + elif subscription.user_id != request.current_user_id \ + and not current_user.is_superuser: + abort(403, _("You can only subscribe to resources on your own.")) + + # Data sanity check: The resource must exist. + resource = subscription_api.subscription_get_resource( + target_type=subscription.target_type, + target_id=subscription.target_id) + if not resource: + abort(400, _('You cannot subscribe to a nonexistent resource.')) + + # Data sanity check: The subscription cannot be duplicated for this + # user. + existing = subscription_api.subscription_get_all( + target_type=[subscription.target_type, ], + target_id=subscription.target_id, + user_id=subscription.user_id) + + if existing: + abort(409, _('You are already subscribed to this resource.')) + + result = subscription_api.subscription_create(subscription.as_dict()) + return Subscription.from_db_model(result) + + @secure(checks.authenticated) + @wsme_pecan.wsexpose(None, int) + def delete(self, subscription_id): + """Delete a specific subscription. + + :param subscription_id: The unique id of the subscription to delete. + """ + subscription = subscription_api.subscription_get(subscription_id) + + # Sanity check on user_id + current_user = user_api.user_get(request.current_user_id) + if subscription.user_id != request.current_user_id \ + and not current_user.is_superuser: + abort(403, _("You can only remove your own subscriptions.")) + + subscription_api.subscription_delete(subscription_id) + + response.status_code = 204 \ No newline at end of file diff --git a/radar/api/v1/system.py b/radar/api/v1/system.py new file mode 100644 index 0000000..93f6c0e --- /dev/null +++ b/radar/api/v1/system.py @@ -0,0 +1,210 @@ +# Copyright (c) 2014 Triniplex. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.config import cfg +from pecan import expose +from pecan import request +from pecan import response +from pecan import rest +from pecan.secure import secure +from wsme.exc import ClientSideError +import wsmeext.pecan as wsme_pecan + +from radar.api.auth import authorization_checks as checks +from radar.api.v1.search import search_engine +from radar.api.v1 import wmodels +from radar.db.api import systems as systems_api + + +CONF = cfg.CONF + +SEARCH_ENGINE = search_engine.get_engine() + + +class SystemsController(rest.RestController): + """Manages operations on systems.""" + + _custom_actions = {"search": ["GET"], + "count": ["GET"]} + + @secure(checks.guest) + @wsme_pecan.wsexpose(wmodels.System, int) + def get_one_by_id(self, system_id): + """Retrieve details about one system. + + :param system_id: An ID of the system. + """ + system = systems_api.system_get_by_id(system_id) + + if system: + return wmodels.System.from_db_model(system) + else: + raise ClientSideError("System %s not found" % system_id, + status_code=404) + + @secure(checks.guest) + @wsme_pecan.wsexpose(wmodels.System, unicode) + def get_one_by_name(self, system_name): + """Retrieve information about the given project. + + :param name: project name. + """ + + system = systems_api.system_get_by_name(system_name) + + if system: + return wmodels.System.from_db_model(system) + else: + raise ClientSideError("System %s not found" % system_name, + status_code=404) + + @secure(checks.guest) + @wsme_pecan.wsexpose([wmodels.System], int, int, unicode, unicode, + unicode) + def get(self, marker=None, limit=None, name=None, sort_field='id', + sort_dir='asc'): + """Retrieve definitions of all of the systems. + + :param name: A string to filter the name by. + """ + + # Boundary check on limit. + if limit is None: + limit = CONF.page_size_default + limit = min(CONF.page_size_maximum, max(1, limit)) + + # Resolve the marker record. + marker_system = systems_api.system_get_by_id(marker) + + systems = systems_api \ + .system_get_all(marker=marker_system, + limit=limit, + name=name, + sort_field=sort_field, + sort_dir=sort_dir) + system_count = systems_api \ + .system_get_count(name=name) + + # Apply the query response headers. + response.headers['X-Limit'] = str(limit) + response.headers['X-Total'] = str(system_count) + if marker_system: + response.headers['X-Marker'] = str(marker_system.id) + + if systems: + return [wmodels.System.from_db_model(s) for s in systems] + else: + raise ClientSideError("Could not retrieve system list", + status_code=404) + + @secure(checks.guest) + @wsme_pecan.wsexpose(wmodels.System, body=wmodels.System) + def post(self, system): + """Create a new system. + :param system: a system within the request body. + """ + system_dict = system.as_dict() + + created_system = systems_api.system_create(system_dict) + if created_system: + return wmodels.System.from_db_model(created_system) + else: + raise ClientSideError("Unable to create system %s" % system, + status_code=404) + + + @secure(checks.guest) + @wsme_pecan.wsexpose(wmodels.System, int, body=wmodels.System) + def put(self, system_id, system): + """Modify this system. + + :param system_id: An ID of the system. + :param system: a system within the request body. + """ + updated_system = systems_api.system_update( + system_id, + system.as_dict(omit_unset=True)) + + if updated_system: + return wmodels.System.from_db_model(updated_system) + else: + raise ClientSideError("System %s not found" % system_id, + status_code=404) + + @secure(checks.superuser) + @wsme_pecan.wsexpose(wmodels.System, int) + def delete(self, system_id): + """Delete this system. + + :param system_id: An ID of the system. + """ + systems_api.system_delete(system_id) + + response.status_code = 204 + + + def _is_int(self, s): + try: + int(s) + return True + except ValueError: + return False + + @secure(checks.guest) + @wsme_pecan.wsexpose([wmodels.System], unicode, int, int) + def search(self, q="", marker=None, limit=None): + """The search endpoint for systems. + + :param q: The query string. + :return: List of Systems matching the query. + """ + + systems = SEARCH_ENGINE.systems_query(q=q, + marker=marker, + limit=limit) + + return [wmodels.System.from_db_model(system) for system in systems] + + + @secure(checks.guest) + @wsme_pecan.wsexpose(long, unicode) + def count(self, args): + systems = systems_api.count() + + if systems: + return systems + else: + raise ClientSideError("Cannot return system count for %s" + % kwargs, status_code=404) + @expose() + def _route(self, args, request): + if request.method == 'GET' and len(args) > 0: + # It's a request by a name or id + something = args[0] + + if something == "search": + # Request to a search endpoint + return super(SystemsController, self)._route(args, request) + + if self._is_int(something): + # Get by id + return self.get_one_by_id, args + else: + # Get by name + if something == "count" and len(args) == 2: + return self.count, args + else: + return self.get_one_by_name, ["/".join(args)] + return super(SystemsController, self)._route(args, request) diff --git a/radar/api/v1/user.py b/radar/api/v1/user.py new file mode 100644 index 0000000..676493e --- /dev/null +++ b/radar/api/v1/user.py @@ -0,0 +1,179 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.config import cfg +from pecan import expose +from pecan import request +from pecan import response +from pecan import rest +from pecan.secure import secure +from wsme.exc import ClientSideError +import wsmeext.pecan as wsme_pecan + +from radar.api.auth import authorization_checks as checks +from radar.api.v1.search import search_engine +from radar.api.v1.user_preference import UserPreferencesController +from radar.api.v1.user_token import UserTokensController +from radar.api.v1 import wmodels +from radar.db.api import users as users_api +from radar.openstack.common.gettextutils import _ # noqa + + +CONF = cfg.CONF + +SEARCH_ENGINE = search_engine.get_engine() + + +class UsersController(rest.RestController): + """Manages users.""" + + # Import the user preferences. + preferences = UserPreferencesController() + + # Import user token management. + tokens = UserTokensController() + + _custom_actions = {"search": ["GET"]} + + @secure(checks.guest) + @wsme_pecan.wsexpose([wmodels.User], int, int, unicode, unicode, unicode, + unicode) + def get(self, marker=None, limit=None, username=None, full_name=None, + sort_field='id', sort_dir='asc'): + """Page and filter the users in radar. + + :param marker: The resource id where the page should begin. + :param limit The number of users to retrieve. + :param username A string of characters to filter the username with. + :param full_name A string of characters to filter the full_name with. + :param sort_field: The name of the field to sort on. + :param sort_dir: sort direction for results (asc, desc). + """ + + # Boundary check on limit. + if limit is None: + limit = CONF.page_size_default + limit = min(CONF.page_size_maximum, max(1, limit)) + + # Resolve the marker record. + marker_user = users_api.user_get(marker) + + users = users_api.user_get_all(marker=marker_user, limit=limit, + username=username, full_name=full_name, + filter_non_public=True, + sort_field=sort_field, + sort_dir=sort_dir) + user_count = users_api.user_get_count(username=username, + full_name=full_name) + + # Apply the query response headers. + response.headers['X-Limit'] = str(limit) + response.headers['X-Total'] = str(user_count) + if marker_user: + response.headers['X-Marker'] = str(marker_user.id) + + return [wmodels.User.from_db_model(u) for u in users] + + @secure(checks.guest) + @wsme_pecan.wsexpose(wmodels.User, int) + def get_one(self, user_id): + """Retrieve details about one user. + + :param user_id: The unique id of this user + """ + + filter_non_public = True + if user_id == request.current_user_id: + filter_non_public = False + + user = users_api.user_get(user_id, filter_non_public) + if not user: + raise ClientSideError(_("User %s not found") % user_id, + status_code=404) + return user + + @secure(checks.superuser) + @wsme_pecan.wsexpose(wmodels.User, body=wmodels.User) + def post(self, user): + """Create a new user. + + :param user: a user within the request body. + """ + + created_user = users_api.user_create(user.as_dict()) + return wmodels.User.from_db_model(created_user) + + @secure(checks.authenticated) + @wsme_pecan.wsexpose(wmodels.User, int, body=wmodels.User) + def put(self, user_id, user): + """Modify this user. + + :param user_id: unique id to identify the user. + :param user: a user within the request body. + """ + current_user = users_api.user_get(request.current_user_id) + + if not user or not user.id or not current_user: + response.status_code = 404 + response.body = _("Not found") + return response + + # Only owners and superadmins are allowed to modify users. + if request.current_user_id != user.id \ + and not current_user.is_superuser: + response.status_code = 403 + response.body = _("You are not allowed to update this user.") + return response + + # Strip out values that you're not allowed to change. + user_dict = user.as_dict() + + # You cannot modify the openid field. + del user_dict['openid'] + + if not current_user.is_superuser: + # Only superuser may create superusers or modify login permissions. + del user_dict['enable_login'] + del user_dict['is_superuser'] + + updated_user = users_api.user_update(user_id, user_dict) + return wmodels.User.from_db_model(updated_user) + + @secure(checks.guest) + @wsme_pecan.wsexpose([wmodels.User], unicode, int, int) + def search(self, q="", marker=None, limit=None): + """The search endpoint for users. + + :param q: The query string. + :return: List of Users matching the query. + """ + + users = SEARCH_ENGINE.users_query(q=q, marker=marker, limit=limit) + + return [wmodels.User.from_db_model(u) for u in users] + + @expose() + def _route(self, args, request): + if request.method == 'GET' and len(args) == 1: + # It's a request by a name or id + something = args[0] + + if something == "search": + # Request to a search endpoint + return self.search, args + else: + return self.get_one, args + + return super(UsersController, self)._route(args, request) \ No newline at end of file diff --git a/radar/api/v1/user_preference.py b/radar/api/v1/user_preference.py new file mode 100644 index 0000000..9bdc5cb --- /dev/null +++ b/radar/api/v1/user_preference.py @@ -0,0 +1,58 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.config import cfg +from pecan import abort +from pecan import request +from pecan import rest +from pecan.secure import secure +import wsme.types as types +import wsmeext.pecan as wsme_pecan + +from radar.api.auth import authorization_checks as checks +import radar.db.api.users as user_api +from radar.openstack.common import log + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +class UserPreferencesController(rest.RestController): + @secure(checks.authenticated) + @wsme_pecan.wsexpose(types.DictType(unicode, unicode), int) + def get_all(self, user_id): + """Return all preferences for the current user. + """ + if request.current_user_id != user_id: + abort(403) + return + + return user_api.user_get_preferences(user_id) + + @secure(checks.authenticated) + @wsme_pecan.wsexpose(types.DictType(unicode, unicode), int, + body=types.DictType(unicode, unicode)) + def post(self, user_id, body): + """Allow a user to update their preferences. Note that a user must + explicitly set a preference value to Null/None to have it deleted. + + :param user_id The ID of the user whose preferences we're updating. + :param body A dictionary of preference values. + """ + if request.current_user_id != user_id: + abort(403) + + return user_api.user_update_preferences(user_id, body) \ No newline at end of file diff --git a/radar/api/v1/user_token.py b/radar/api/v1/user_token.py new file mode 100644 index 0000000..9cbf762 --- /dev/null +++ b/radar/api/v1/user_token.py @@ -0,0 +1,184 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uuid + +from oslo.config import cfg +from pecan import abort +from pecan import request +from pecan import response +from pecan import rest +from pecan.secure import secure +import wsmeext.pecan as wsme_pecan + +from radar.api.auth import authorization_checks as checks +import radar.api.v1.wmodels as wmodels +import radar.db.api.access_tokens as token_api +import radar.db.api.users as user_api +from radar.openstack.common.gettextutils import _ # noqa +from radar.openstack.common import log + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +class UserTokensController(rest.RestController): + @secure(checks.authenticated) + @wsme_pecan.wsexpose([wmodels.AccessToken], int, int, int, unicode, + unicode) + def get_all(self, user_id, marker=None, limit=None, sort_field='id', + sort_dir='asc'): + """Returns all the access tokens for the provided user. + + :param user_id: The ID of the user. + :param marker: The marker record at which to start the page. + :param limit: The number of records to return. + :param sort_field: The field on which to sort. + :param sort_dir: The direction to sort. + :return: A list of access tokens for the given user. + """ + self._assert_can_access(user_id) + + # Boundary check on limit. + if limit is None: + limit = CONF.page_size_default + limit = min(CONF.page_size_maximum, max(1, limit)) + + # Resolve the marker record. + marker_token = token_api.access_token_get(marker) + + tokens = token_api.access_token_get_all(marker=marker_token, + limit=limit, + user_id=user_id, + filter_non_public=True, + sort_field=sort_field, + sort_dir=sort_dir) + token_count = token_api.access_token_get_count(user_id=user_id) + + # Apply the query response headers. + response.headers['X-Limit'] = str(limit) + response.headers['X-Total'] = str(token_count) + if marker_token: + response.headers['X-Marker'] = str(marker_token.id) + + return [wmodels.AccessToken.from_db_model(t) for t in tokens] + + @secure(checks.authenticated) + @wsme_pecan.wsexpose(wmodels.AccessToken, int, int) + def get(self, user_id, access_token_id): + """Returns a specific access token for the given user. + + :param user_id: The ID of the user. + :param access_token_id: The ID of the access token. + :return: The requested access token. + """ + access_token = token_api.access_token_get(access_token_id) + self._assert_can_access(user_id, access_token) + + if not access_token: + abort(404) + + return wmodels.AccessToken.from_db_model(access_token) + + @secure(checks.authenticated) + @wsme_pecan.wsexpose(wmodels.AccessToken, int, body=wmodels.AccessToken) + def post(self, user_id, body): + """Create a new access token for the given user. + + :param user_id: The user ID of the user. + :param body: The access token. + :return: The created access token. + """ + self._assert_can_access(user_id, body) + + # Generate a random token if one was not provided. + if not body.access_token: + body.access_token = str(uuid.uuid4()) + + # Token duplication check. + dupes = token_api.access_token_get_all(access_token=body.access_token) + if dupes: + abort(409, _('This token already exists.')) + + token = token_api.access_token_create(body.as_dict()) + + return wmodels.AccessToken.from_db_model(token) + + @secure(checks.authenticated) + @wsme_pecan.wsexpose(wmodels.AccessToken, int, int, + body=wmodels.AccessToken) + def put(self, user_id, access_token_id, body): + """Update an access token for the given user. + + :param user_id: The user ID of the user. + :param access_token_id: The ID of the access token. + :param body: The access token. + :return: The created access token. + """ + target_token = token_api.access_token_get(access_token_id) + + self._assert_can_access(user_id, body) + self._assert_can_access(user_id, target_token) + + if not target_token: + abort(404) + + # We only allow updating the expiration date. + target_token.expires_in = body.expires_in + + result_token = token_api.access_token_update(access_token_id, + target_token.as_dict()) + + return wmodels.AccessToken.from_db_model(result_token) + + @secure(checks.authenticated) + @wsme_pecan.wsexpose(wmodels.AccessToken, int, int) + def delete(self, user_id, access_token_id): + """Deletes an access token for the given user. + + :param user_id: The user ID of the user. + :param access_token_id: The ID of the access token. + :return: Empty body, or error response. + """ + access_token = token_api.access_token_get(access_token_id) + self._assert_can_access(user_id, access_token) + + if not access_token: + abort(404) + + token_api.access_token_delete(access_token_id) + + response.status_code = 204 + + def _assert_can_access(self, user_id, token_entity=None): + current_user = user_api.user_get(request.current_user_id) + + if not user_id: + abort(400) + + # The user must be logged in. + if not current_user: + abort(401) + + # If the impacted user is not the current user, the current user must + # be an admin. + if not current_user.is_superuser and current_user.id != user_id: + abort(403) + + # The path-based impacted user and the user found in the entity must + # be identical. No PUT /users/1/tokens { user_id: 2 } + if token_entity and token_entity.user_id != user_id: + abort(403) \ No newline at end of file diff --git a/radar/api/v1/v1_controller.py b/radar/api/v1/v1_controller.py new file mode 100644 index 0000000..834478e --- /dev/null +++ b/radar/api/v1/v1_controller.py @@ -0,0 +1,18 @@ +import pecan +from pecan import rest + +from radar.api.v1.auth import AuthController +from radar.api.v1.subscription import SubscriptionsController +from radar.api.v1.system import SystemsController +from radar.api.v1.operator import OperatorsController +from radar.api.v1.user import UsersController + +class V1Controller(rest.RestController): + + systems = SystemsController() + operators = OperatorsController() + users = UsersController() + subscriptions = SubscriptionsController() + + openid = AuthController() + diff --git a/radar/api/v1/wmodels.py b/radar/api/v1/wmodels.py new file mode 100644 index 0000000..642fb38 --- /dev/null +++ b/radar/api/v1/wmodels.py @@ -0,0 +1,75 @@ +from datetime import datetime +from wsme import types as wtypes +from api.v1 import base + +class System(base.APIBase): + """Represents the ci systems for the dashboard + """ + + name = wtypes.text + """The system name""" + +class SystemEvent(base.APIBase): + event_type = wtypes.text + event_info = wtypes.text + +class Operator(base.APIBase): + operator_name = wtypes.text + operator_email = wtypes.text + +class User(base.APIBase): + """Represents a user.""" + + username = wtypes.text + """A short unique name, beginning with a lower-case letter or number, and + containing only letters, numbers, dots, hyphens, or plus signs""" + + full_name = wtypes.text + """Full (Display) name.""" + + openid = wtypes.text + """The unique identifier, returned by an OpneId provider""" + + email = wtypes.text + """Email Address.""" + + # Todo(nkonovalov): use teams to define superusers + is_superuser = bool + + last_login = datetime + """Date of the last login.""" + + enable_login = bool + """Whether this user is permitted to log in.""" + + @classmethod + def sample(cls): + return cls( + username="elbarto", + full_name="Bart Simpson", + openid="https://login.launchpad.net/+id/Abacaba", + email="skinnerstinks@springfield.net", + is_staff=False, + is_active=True, + is_superuser=True, + last_login=datetime(2014, 1, 1, 16, 42)) + + +class AccessToken(base.APIBase): + """Represents a user access token.""" + + user_id = int + """The ID of User to whom this token belongs.""" + + access_token = wtypes.text + """The access token.""" + + expires_in = int + """The number of seconds after creation when this token expires.""" + + @classmethod + def sample(cls): + return cls( + user_id=1, + access_token="a_unique_access_token", + expires_in=3600) diff --git a/radar/common/__init__.py b/radar/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/common/custom_types.py b/radar/common/custom_types.py new file mode 100644 index 0000000..cb28419 --- /dev/null +++ b/radar/common/custom_types.py @@ -0,0 +1,31 @@ +# Copyright (c) 2014 Triniplex +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from wsme import types + + +class NameType(types.StringType): + """This type should be applied to the name fields. Currently this type + should be applied to Projects and Project Groups. + + This type allows alphanumeric characters with . - and / separators inside + the name. The name should be at least 3 symbols long. + + """ + + _name_regex = r'^[a-zA-Z0-9]+([\-\./]?[a-zA-Z0-9]+)*$' + + def __init__(self): + super(NameType, self).__init__(min_length=3, pattern=self._name_regex) diff --git a/radar/common/exception.py b/radar/common/exception.py new file mode 100644 index 0000000..db5b0a5 --- /dev/null +++ b/radar/common/exception.py @@ -0,0 +1,29 @@ +class DashboardException(Exception): + """Base Exception for the project + To correctly use this class, inherit from it and define + the 'message' property. + """ + + message = "An unknown exception occurred" + + def __str__(self): + return self.message + + def __init__(self): + super(DashboardException, self).__init__(self.message) + + +class NotFound(DashboardException): + message = "Object not found" + + def __init__(self, message=None): + if message: + self.message = message + + +class DuplicateEntry(DashboardException): + message = "Database object already exists" + + def __init__(self, message=None): + if message: + self.message = message diff --git a/radar/db/__init__.py b/radar/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/db/api/__init__.py b/radar/db/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/db/api/access_tokens.py b/radar/db/api/access_tokens.py new file mode 100644 index 0000000..881ab50 --- /dev/null +++ b/radar/db/api/access_tokens.py @@ -0,0 +1,114 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +from oslo.db.sqlalchemy.utils import InvalidSortKey +from wsme.exc import ClientSideError + +from radar.db.api import base as api_base +from radar.db import models +from radar.openstack.common.gettextutils import _ # noqa + + +def access_token_get(access_token_id): + return api_base.entity_get(models.AccessToken, access_token_id) + + +def access_token_get_by_token(access_token): + results = api_base.entity_get_all(models.AccessToken, + access_token=access_token) + + if not results: + return None + else: + return results[0] + + +def access_token_get_all(marker=None, limit=None, sort_field=None, + sort_dir=None, **kwargs): + # Sanity checks, in case someone accidentally explicitly passes in 'None' + if not sort_field: + sort_field = 'id' + if not sort_dir: + sort_dir = 'asc' + + # Construct the query + query = access_token_build_query(**kwargs) + + try: + query = api_base.paginate_query(query=query, + model=models.AccessToken, + limit=limit, + sort_keys=[sort_field], + marker=marker, + sort_dir=sort_dir) + except InvalidSortKey: + raise ClientSideError(_("Invalid sort_field [%s]") % (sort_field,), + status_code=400) + except ValueError as ve: + raise ClientSideError(_("%s") % (ve,), status_code=400) + + # Execute the query + return query.all() + + +def access_token_get_count(**kwargs): + # Construct the query + query = access_token_build_query(**kwargs) + + return query.count() + + +def access_token_create(values): + # Update the expires_at date. + values['created_at'] = datetime.datetime.utcnow() + values['expires_at'] = datetime.datetime.utcnow() + datetime.timedelta( + seconds=values['expires_in']) + + return api_base.entity_create(models.AccessToken, values) + + +def access_token_update(access_token_id, values): + values['expires_at'] = values['created_at'] + datetime.timedelta( + seconds=values['expires_in']) + + return api_base.entity_update(models.AccessToken, access_token_id, values) + + +def access_token_build_query(**kwargs): + # Construct the query + query = api_base.model_query(models.AccessToken) + + # Apply the filters + query = api_base.apply_query_filters(query=query, + model=models.AccessToken, + **kwargs) + + return query + + +def access_token_delete_by_token(access_token): + access_token = access_token_get_by_token(access_token) + + if access_token: + api_base.entity_hard_delete(models.AccessToken, access_token.id) + + +def access_token_delete(access_token_id): + access_token = access_token_get(access_token_id) + + if access_token: + api_base.entity_hard_delete(models.AccessToken, access_token_id) \ No newline at end of file diff --git a/radar/db/api/auth.py b/radar/db/api/auth.py new file mode 100644 index 0000000..e3e0af0 --- /dev/null +++ b/radar/db/api/auth.py @@ -0,0 +1,66 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from radar.db.api import base as api_base +from radar.db import models + + +def authorization_code_get(code): + query = api_base.model_query(models.AuthorizationCode, + api_base.get_session()) + return query.filter_by(code=code).first() + + +def authorization_code_save(values): + return api_base.entity_create(models.AuthorizationCode, values) + + +def authorization_code_delete(code): + del_code = authorization_code_get(code) + + if del_code: + api_base.entity_hard_delete(models.AuthorizationCode, del_code.id) + + +def access_token_get(access_token): + query = api_base.model_query(models.AccessToken, api_base.get_session()) + return query.filter_by(access_token=access_token).first() + + +def access_token_save(values): + return api_base.entity_create(models.AccessToken, values) + + +def access_token_delete(access_token): + del_token = access_token_get(access_token) + + if del_token: + api_base.entity_hard_delete(models.AccessToken, del_token.id) + + +def refresh_token_get(refresh_token): + query = api_base.model_query(models.RefreshToken, api_base.get_session()) + return query.filter_by(refresh_token=refresh_token).first() + + +def refresh_token_save(values): + return api_base.entity_create(models.RefreshToken, values) + + +def refresh_token_delete(refresh_token): + del_token = refresh_token_get(refresh_token) + + if del_token: + api_base.entity_hard_delete(models.RefreshToken, del_token.id) \ No newline at end of file diff --git a/radar/db/api/base.py b/radar/db/api/base.py new file mode 100644 index 0000000..f5ec4eb --- /dev/null +++ b/radar/db/api/base.py @@ -0,0 +1,214 @@ +import copy + +from oslo.config import cfg +from oslo.db import exception as db_exc +from oslo.db.sqlalchemy import session as db_session +from oslo.db.sqlalchemy.utils import InvalidSortKey +from oslo.db.sqlalchemy.utils import paginate_query +import six +import sqlalchemy.types as types +from wsme.exc import ClientSideError + +from radar.common import exception as exc +from radar.db import models +from radar.openstack.common import log + +CONF = cfg.CONF +LOG = log.getLogger(__name__) +_FACADE = None + +BASE = models.Base + + +def _get_facade_instance(): + """Generate an instance of the DB Facade. + """ + global _FACADE + if _FACADE is None: + _FACADE = db_session.EngineFacade.from_config(CONF) + return _FACADE + + +def _destroy_facade_instance(): + """Destroys the db facade instance currently in use. + """ + global _FACADE + _FACADE = None + + +def apply_query_filters(query, model, **kwargs): + """Parses through a list of kwargs to determine which exist on the model, + which should be filtered as ==, and which should be filtered as LIKE + """ + + for k, v in kwargs.iteritems(): + if v and hasattr(model, k): + column = getattr(model, k) + if column.is_attribute: + if isinstance(column.type, types.Enum): + query = query.filter(column.in_(v)) + elif isinstance(column.type, types.String): + # Filter strings with LIKE + query = query.filter(column.like("%" + v + "%")) + else: + # Everything else is a strict equal + query = query.filter(column == v) + + return query + + +def get_engine(): + """Returns the global instance of our database engine. + """ + facade = _get_facade_instance() + return facade.get_engine(use_slave=True) + + +def get_session(autocommit=True, expire_on_commit=False, **kwargs): + """Returns a database session from our facade. + """ + facade = _get_facade_instance() + return facade.get_session(autocommit=autocommit, + expire_on_commit=expire_on_commit, **kwargs) + + +def cleanup(): + """Manually clean up our database engine. + """ + _destroy_facade_instance() + + +def model_query(model, session=None): + """Query helper. + :param model: base model to query + """ + session = session or get_session() + query = session.query(model) + return query + + +def __entity_get(kls, entity_id, session): + query = model_query(kls, session) + return query.filter_by(id=entity_id).first() + + +def entity_get(kls, entity_id, filter_non_public=False, session=None): + if not session: + session = get_session() + + entity = __entity_get(kls, entity_id, session) + + if filter_non_public: + entity = _filter_non_public_fields(entity, entity._public_fields) + + return entity + + +def entity_get_all(kls, filter_non_public=False, marker=None, limit=None, + sort_field='id', sort_dir='asc', **kwargs): + + # Sanity checks, in case someone accidentally explicitly passes in 'None' + if not sort_field: + sort_field = 'id' + if not sort_dir: + sort_dir = 'asc' + + # Construct the query + query = model_query(kls) + + # Sanity check on input parameters + query = apply_query_filters(query=query, model=kls, **kwargs) + + # Construct the query + try: + query = paginate_query(query=query, + model=kls, + limit=limit, + sort_keys=[sort_field], + marker=marker, + sort_dir=sort_dir) + except InvalidSortKey: + raise ClientSideError("Invalid sort_field [%s]" % (sort_field,), + status_code=400) + except ValueError as ve: + raise ClientSideError("%s" % (ve,), status_code=400) + + # Execute the query + entities = query.all() + if len(entities) > 0 and filter_non_public: + sample_entity = entities[0] if len(entities) > 0 else None + public_fields = getattr(sample_entity, "_public_fields", []) + + entities = [_filter_non_public_fields(entity, public_fields) + for entity in entities] + + return entities + + +def entity_get_count(kls, **kwargs): + # Construct the query + query = model_query(kls) + + # Sanity check on input parameters + query = apply_query_filters(query=query, model=kls, **kwargs) + + count = query.count() + + return count + + +def _filter_non_public_fields(entity, public_list=list()): + ent_copy = copy.copy(entity) + for attr_name, val in six.iteritems(entity.__dict__): + if attr_name.startswith("_"): + continue + + if attr_name not in public_list: + delattr(ent_copy, attr_name) + + return ent_copy + + +def entity_create(kls, values): + entity = kls() + entity.update(values.copy()) + + session = get_session() + with session.begin(): + try: + session.add(entity) + except db_exc.DBDuplicateEntry: + raise exc.DuplicateEntry("Duplicate entry for : %s" + % kls.__name__) + + return entity + + +def entity_update(kls, entity_id, values): + session = get_session() + + with session.begin(): + entity = __entity_get(kls, entity_id, session) + if entity is None: + raise exc.NotFound("%s %s not found" % (kls.__name__, entity_id)) + + values_copy = values.copy() + values_copy["id"] = entity_id + entity.update(values_copy) + session.add(entity) + + session = get_session() + entity = __entity_get(kls, entity_id, session) + + return entity + + +def entity_hard_delete(kls, entity_id): + session = get_session() + with session.begin(): + query = model_query(kls, session) + entity = query.filter_by(id=entity_id).first() + if entity is None: + raise exc.NotFound("%s %s not found" % (kls.__name__, entity_id)) + + session.delete(entity) diff --git a/radar/db/api/operators.py b/radar/db/api/operators.py new file mode 100644 index 0000000..5d56733 --- /dev/null +++ b/radar/db/api/operators.py @@ -0,0 +1,118 @@ +# Copyright (c) 2014 Triniplex. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.db.sqlalchemy.utils import InvalidSortKey +from sqlalchemy.orm import subqueryload +from wsme.exc import ClientSideError + +from radar.common import exception as exc +from radar.db.api import base as api_base +from radar.db.api import systems +from radar.db import models + + +def operator_get(operator_id, session=None): + return api_base.model_query(models.Operator, session) \ + .filter_by(id=operator_id).first() + +def operator_get_by_name(name, session=None): + query = api_base.model_query(models.Operator, session) + return query.filter_by(operator_name=name).first() + +def count(session=None): + return api_base.model_query(models.Operator, session) \ + .count() + +def operator_get_all(marker=None, limit=None, name=None, sort_field=None, + sort_dir=None, **kwargs): + # Sanity checks, in case someone accidentally explicitly passes in 'None' + if not sort_field: + sort_field = 'id' + if not sort_dir: + sort_dir = 'asc' + + query = _operator_build_query(name=name, **kwargs) + + try: + query = api_base.paginate_query(query=query, + model=models.Operator, + limit=limit, + sort_keys=[sort_field], + marker=marker, + sort_dir=sort_dir) + except InvalidSortKey: + raise ClientSideError("Invalid sort_field [%s]" % (sort_field,), + status_code=400) + except ValueError as ve: + raise ClientSideError("%s" % (ve,), status_code=400) + + # Execute the query + return query.all() + +def operator_create(values): + return api_base.entity_create(models.Operator, values) + +def operator_add_system(operator_id, system_id): + session = api_base.get_session() + + with session.begin(): + operator = _entity_get(operator_id, session) + if operator is None: + raise exc.NotFound("%s %s not found" + % ("Operator", operator_id)) + + system = systems.system_get_by_id(system_id, session) + if system is None: + raise exc.NotFound("%s %s not found" + % ("System", system_id)) + + if system_id in [s.id for s in operator.systems]: + raise ClientSideError("The System %d is already associated with" + "Operator %d" % + (system_id, operator_id)) + + operator.systems.append(system) + session.add(operator) + return operator + +def operator_update(operator_id, values): + return api_base.entity_update(models.Operator, operator_id, values) + +def operator_delete(operator_id): + operator = operator_get(operator_id) + + if operator: + api_base.entity_hard_delete(models.Operator, operator_id) + +def _entity_get(id, session=None): + if not session: + session = api_base.get_session() + query = session.query(models.Operator)\ + .filter_by(id=id) + + return query.first() + +def operator_get_count(**kwargs): + query = _operator_build_query(**kwargs) + + return query.count() + +def _operator_build_query(**kwargs): + query = api_base.model_query(models.Operator) + query = api_base.apply_query_filters(query=query, + model=models.Operator, + **kwargs) + + return query diff --git a/radar/db/api/subscriptions.py b/radar/db/api/subscriptions.py new file mode 100644 index 0000000..e87cbc8 --- /dev/null +++ b/radar/db/api/subscriptions.py @@ -0,0 +1,59 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from radar.db.api import base as api_base +from radar.db import models + +SUPPORTED_TYPES = { + 'system': models.System, + 'operator': models.Operator +} + + +def subscription_get(subscription_id): + return api_base.entity_get(models.Subscription, subscription_id) + + +def subscription_get_all(**kwargs): + return api_base.entity_get_all(models.Subscription, + **kwargs) + + +def subscription_get_all_by_target(target_type, target_id): + return api_base.entity_get_all(models.Subscription, + target_type=target_type, + target_id=target_id) + + +def subscription_get_resource(target_type, target_id): + if target_type not in SUPPORTED_TYPES: + return None + + return api_base.entity_get(SUPPORTED_TYPES[target_type], target_id) + + +def subscription_get_count(**kwargs): + return api_base.entity_get_count(models.Subscription, **kwargs) + + +def subscription_create(values): + return api_base.entity_create(models.Subscription, values) + + +def subscription_delete(subscription_id): + subscription = subscription_get(subscription_id) + + if subscription: + api_base.entity_hard_delete(models.Subscription, subscription_id) \ No newline at end of file diff --git a/radar/db/api/systems.py b/radar/db/api/systems.py new file mode 100644 index 0000000..3a0052d --- /dev/null +++ b/radar/db/api/systems.py @@ -0,0 +1,85 @@ +# Copyright (c) 2014 Triniplex. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.db.sqlalchemy.utils import InvalidSortKey +from sqlalchemy.orm import subqueryload +from wsme.exc import ClientSideError + +from radar.db.api import base as api_base +from radar.db import models + + +def system_get_by_id(system_id, session=None): + return api_base.model_query(models.System, session) \ + .filter_by(id=system_id).first() + +def count(session=None): + return api_base.model_query(models.System, session) \ + .count() + +def system_get_by_name(name, session=None): + query = api_base.model_query(models.System, session) + return query.filter_by(name=name).first() + +def system_get_all(marker=None, limit=None, name=None, sort_field=None, + sort_dir=None, **kwargs): + # Sanity checks, in case someone accidentally explicitly passes in 'None' + if not sort_field: + sort_field = 'id' + if not sort_dir: + sort_dir = 'asc' + + query = _system_build_query(name=name, **kwargs) + + try: + query = api_base.paginate_query(query=query, + model=models.System, + limit=limit, + sort_keys=[sort_field], + marker=marker, + sort_dir=sort_dir) + except InvalidSortKey: + raise ClientSideError("Invalid sort_field [%s]" % (sort_field,), + status_code=400) + except ValueError as ve: + raise ClientSideError("%s" % (ve,), status_code=400) + + # Execute the query + return query.all() + +def system_create(values): + return api_base.entity_create(models.System, values) + +def system_update(system_id, values): + return api_base.entity_update(models.System, system_id, values) + +def system_delete(system_id): + system = system_get(system_id) + + if system: + api_base.entity_hard_delete(models.System, system_id) + +def system_get_count(**kwargs): + query = _system_build_query(**kwargs) + + return query.count() + +def _system_build_query(**kwargs): + query = api_base.model_query(models.System) + query = api_base.apply_query_filters(query=query, + model=models.System, + **kwargs) + + return query diff --git a/radar/db/api/users.py b/radar/db/api/users.py new file mode 100644 index 0000000..6dfac2b --- /dev/null +++ b/radar/db/api/users.py @@ -0,0 +1,118 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.db import exception as db_exc + +from radar.common import exception as exc +from radar.db.api import base as api_base +from radar.db import models +from radar.openstack.common.gettextutils import _ # noqa +from radar.plugin.user_preferences import PREFERENCE_DEFAULTS + + +def user_get(user_id, filter_non_public=False): + entity = api_base.entity_get(models.User, user_id, + filter_non_public=filter_non_public) + + return entity + + +def user_get_all(marker=None, limit=None, filter_non_public=False, + sort_field=None, sort_dir=None, **kwargs): + return api_base.entity_get_all(models.User, + marker=marker, + limit=limit, + filter_non_public=filter_non_public, + sort_field=sort_field, + sort_dir=sort_dir, + **kwargs) + + +def user_get_count(**kwargs): + return api_base.entity_get_count(models.User, **kwargs) + + +def user_get_by_openid(openid): + query = api_base.model_query(models.User, api_base.get_session()) + return query.filter_by(openid=openid).first() + + +def user_create(values): + user = models.User() + user.update(values.copy()) + + session = api_base.get_session() + with session.begin(): + try: + user.save(session=session) + except db_exc.DBDuplicateEntry as e: + raise exc.DuplicateEntry(_("Duplicate entry for User: %s") + % e.columns) + + return user + + +def user_update(user_id, values): + return api_base.entity_update(models.User, user_id, values) + + +def user_get_preferences(user_id): + preferences = api_base.entity_get_all(models.UserPreference, + user_id=user_id) + + pref_dict = dict() + for pref in preferences: + pref_dict[pref.key] = pref.cast_value + + # Decorate with plugin defaults. + for key in PREFERENCE_DEFAULTS: + if key not in pref_dict: + pref_dict[key] = PREFERENCE_DEFAULTS[key] + + return pref_dict + + +def user_update_preferences(user_id, preferences): + for key in preferences: + value = preferences[key] + prefs = api_base.entity_get_all(models.UserPreference, + user_id=user_id, + key=key) + + if prefs: + pref = prefs[0] + else: + pref = None + + # If the preference exists and it's null. + if pref and value is None: + api_base.entity_hard_delete(models.UserPreference, pref.id) + continue + + # If the preference exists and has a new value. + if pref and value and pref.cast_value != value: + pref.cast_value = value + api_base.entity_update(models.UserPreference, pref.id, dict(pref)) + continue + + # If the preference does not exist and a new value exists. + if not pref and value: + api_base.entity_create(models.UserPreference, { + 'user_id': user_id, + 'key': key, + 'cast_value': value + }) + + return user_get_preferences(user_id) \ No newline at end of file diff --git a/radar/db/migration/__init__.py b/radar/db/migration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/db/migration/alembic.ini b/radar/db/migration/alembic.ini new file mode 100644 index 0000000..3b7c3e6 --- /dev/null +++ b/radar/db/migration/alembic.ini @@ -0,0 +1,52 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = %(here)s/alembic_migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# default to an empty string because the radar migration cli will +# extract the correct value and set it programatically before alembic is fully +# invoked. +sqlalchemy.url = + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/radar/db/migration/alembic_migrations/README b/radar/db/migration/alembic_migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/radar/db/migration/alembic_migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/radar/db/migration/alembic_migrations/__init__.py b/radar/db/migration/alembic_migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/db/migration/alembic_migrations/env.py b/radar/db/migration/alembic_migrations/env.py new file mode 100644 index 0000000..0f23973 --- /dev/null +++ b/radar/db/migration/alembic_migrations/env.py @@ -0,0 +1,81 @@ +# Copyright 2012 New Dream Network, LLC (DreamHost) +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# from logging.config import fileConfig + +from alembic import context +from sqlalchemy import create_engine, pool + +from radar.db import models + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +radar_config = config.radar_config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +# TODO(mordred): enable this once we're doing something with logging +# fileConfig(config.config_file_name) + +# set the target for 'autogenerate' support +target_metadata = models.Base.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + context.configure(url=radar_config.database.connection) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + engine = create_engine( + radar_config.database.connection, + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure( + connection=connection, + target_metadata=target_metadata + ) + + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/radar/db/migration/alembic_migrations/script.py.mako b/radar/db/migration/alembic_migrations/script.py.mako new file mode 100644 index 0000000..43c0940 --- /dev/null +++ b/radar/db/migration/alembic_migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/radar/db/migration/alembic_migrations/versions/12f5a539f16f_initial_tables.py b/radar/db/migration/alembic_migrations/versions/12f5a539f16f_initial_tables.py new file mode 100644 index 0000000..463cdda --- /dev/null +++ b/radar/db/migration/alembic_migrations/versions/12f5a539f16f_initial_tables.py @@ -0,0 +1,75 @@ +"""initial_tables + +Revision ID: 12f5a539f16f +Revises: +Create Date: 2014-12-08 20:56:38.468330 + +""" + +# revision identifiers, used by Alembic. +revision = '12f5a539f16f' +down_revision = None +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +MYSQL_ENGINE = 'MyISAM' +MYSQL_CHARSET = 'utf8' + +def upgrade(): + + op.create_table( + 'systems_operators', + sa.Column('system_id', sa.Integer(), nullable=True), + sa.Column('operator_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['system_id'], ['systems.id'],), + sa.ForeignKeyConstraint(['operator_id'], ['operators.id'], ), + sa.PrimaryKeyConstraint(), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET + ) + op.create_table( + 'systems', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('name', sa.String(length=50), nullable=True), + sa.UniqueConstraint('name', name='uniq_systems_name'), + sa.PrimaryKeyConstraint('id'), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET + ) + + op.create_table( + 'system_events', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('event_type', sa.Unicode(length=100), nullable=False), + sa.Column('event_info', sa.UnicodeText(), nullable=True), + sa.Column('system_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['system_id'], ['systems.id'], ), + sa.PrimaryKeyConstraint('id'), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET) + + op.create_table( + 'operators', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('operator_name', sa.String(length=50), nullable=True), + sa.Column('operator_email', sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('operator_name', name='uniq_operator_name'), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET) + + +def downgrade(): + op.drop_table('systems_operators') + op.drop_table('systems') + op.drop_table('system_events') + op.drop_table('operators') diff --git a/radar/db/migration/alembic_migrations/versions/135e9f8aeb9c_add_users_permissions.py b/radar/db/migration/alembic_migrations/versions/135e9f8aeb9c_add_users_permissions.py new file mode 100644 index 0000000..6775edb --- /dev/null +++ b/radar/db/migration/alembic_migrations/versions/135e9f8aeb9c_add_users_permissions.py @@ -0,0 +1,68 @@ +"""add users permissions + +Revision ID: 135e9f8aeb9c +Revises: 4d5b6d924547 +Create Date: 2014-12-19 04:28:35.739935 + +""" + +# revision identifiers, used by Alembic. +revision = '135e9f8aeb9c' +down_revision = '4d5b6d924547' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +MYSQL_ENGINE = 'MyISAM' +MYSQL_CHARSET = 'utf8' + +def upgrade(): + op.create_table( + 'users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('username', sa.Unicode(length=30), nullable=True), + sa.Column('full_name', sa.Unicode(length=255), nullable=True), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('openid', sa.String(length=255), nullable=True), + sa.Column('is_staff', sa.Boolean(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_superuser', sa.Boolean(), nullable=True), + sa.Column('last_login', sa.DateTime(), nullable=True), + sa.Column('enable_login', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email', name='uniq_user_email'), + sa.UniqueConstraint('username', name='uniq_user_username'), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET + ) + op.create_table( + 'permissions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('name', sa.Unicode(length=50), nullable=True), + sa.Column('codename', sa.Unicode(length=255), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name', name='uniq_permission_name'), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET + ) + op.create_table( + 'user_permissions', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('permission_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint(), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET + ) + +def downgrade(): + op.drop_table('users') + op.drop_table('user_permissions') + op.drop_table('permissions') diff --git a/radar/db/migration/alembic_migrations/versions/1e10d235df14_add_authorization_models.py b/radar/db/migration/alembic_migrations/versions/1e10d235df14_add_authorization_models.py new file mode 100644 index 0000000..6bca7e7 --- /dev/null +++ b/radar/db/migration/alembic_migrations/versions/1e10d235df14_add_authorization_models.py @@ -0,0 +1,70 @@ +"""add authorization models + +Revision ID: 1e10d235df14 +Revises: 135e9f8aeb9c +Create Date: 2014-12-19 05:16:31.019506 + +""" + +# revision identifiers, used by Alembic. +revision = '1e10d235df14' +down_revision = '135e9f8aeb9c' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +MYSQL_ENGINE = 'MyISAM' +MYSQL_CHARSET = 'utf8' + +def upgrade(active_plugins=None, options=None): + + op.create_table( + 'authorization_codes', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.Unicode(100), nullable=False), + sa.Column('state', sa.Unicode(100), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET + ) + + op.create_table( + 'accesstokens', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('access_token', sa.Unicode(length=100), nullable=False), + sa.Column('expires_in', sa.Integer(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + mysql_default_charset=MYSQL_CHARSET, + mysql_engine=MYSQL_ENGINE) + + op.create_table( + 'refreshtokens', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('refresh_token', sa.Unicode(length=100), nullable=False), + sa.Column('expires_at', sa.DateTime(),nullable=False), + sa.Column('expires_in', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + mysql_default_charset=MYSQL_CHARSET, + mysql_engine=MYSQL_ENGINE) + + +def downgrade(active_plugins=None, options=None): + + op.drop_table('refreshtokens') + op.drop_table('accesstokens') + op.drop_table('authorization_codes') \ No newline at end of file diff --git a/radar/db/migration/alembic_migrations/versions/4d5b6d924547_add_fulltext_indexes.py b/radar/db/migration/alembic_migrations/versions/4d5b6d924547_add_fulltext_indexes.py new file mode 100644 index 0000000..d368101 --- /dev/null +++ b/radar/db/migration/alembic_migrations/versions/4d5b6d924547_add_fulltext_indexes.py @@ -0,0 +1,28 @@ +"""add fulltext indexes + +Revision ID: 4d5b6d924547 +Revises: 12f5a539f16f +Create Date: 2014-12-19 03:52:41.910419 + +""" + +# revision identifiers, used by Alembic. +revision = '4d5b6d924547' +down_revision = '12f5a539f16f' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.execute("ALTER TABLE systems " + "ADD FULLTEXT systems_name_fti (name)") + + op.execute("ALTER TABLE operators " + "ADD FULLTEXT operators_fti (operator_name, operator_email)") + +def downgrade(): + op.drop_index("systems_name_fti", table_name='systems') + op.drop_index("operators_fti", table_name='operators') diff --git a/radar/db/migration/alembic_migrations/versions/842a5f411f2_create_subscriptions_table.py b/radar/db/migration/alembic_migrations/versions/842a5f411f2_create_subscriptions_table.py new file mode 100644 index 0000000..f5550c7 --- /dev/null +++ b/radar/db/migration/alembic_migrations/versions/842a5f411f2_create_subscriptions_table.py @@ -0,0 +1,39 @@ +"""create subscriptions table + +Revision ID: 842a5f411f2 +Revises: 1e10d235df14 +Create Date: 2014-12-19 21:41:15.172502 + +""" + +# revision identifiers, used by Alembic. +revision = '842a5f411f2' +down_revision = '1e10d235df14' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +MYSQL_ENGINE = 'MyISAM' +MYSQL_CHARSET = 'utf8' + +target_type_enum = sa.Enum('system', 'operator') + +def upgrade(): + op.create_table( + 'subscriptions', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('target_type', target_type_enum, nullable=True), + sa.Column('target_id', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET + ) + + +def downgrade(): + op.drop_table('subscriptions') diff --git a/radar/db/migration/cli.py b/radar/db/migration/cli.py new file mode 100644 index 0000000..4a4d81f --- /dev/null +++ b/radar/db/migration/cli.py @@ -0,0 +1,121 @@ +# -*- encoding: utf-8 -*- +# +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# Copyright 2012 New Dream Network, LLC (DreamHost) +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import gettext +import os + +from alembic import command as alembic_command +from alembic import config as alembic_config +from alembic import util as alembic_util +from oslo.config import cfg +from oslo.db import options + +gettext.install('radar', unicode=1) + +CONF = cfg.CONF + + +def do_alembic_command(config, cmd, *args, **kwargs): + try: + getattr(alembic_command, cmd)(config, *args, **kwargs) + except alembic_util.CommandError as e: + alembic_util.err(str(e)) + + +def do_check_migration(config, cmd): + do_alembic_command(config, 'branches') + + +def do_upgrade_downgrade(config, cmd): + if not CONF.command.revision and not CONF.command.delta: + raise SystemExit(_('You must provide a revision or relative delta')) + + revision = CONF.command.revision + + if CONF.command.delta: + sign = '+' if CONF.command.name == 'upgrade' else '-' + revision = sign + str(CONF.command.delta) + else: + revision = CONF.command.revision + + do_alembic_command(config, cmd, revision, sql=CONF.command.sql) + + +def do_stamp(config, cmd): + do_alembic_command(config, cmd, + CONF.command.revision, + sql=CONF.command.sql) + + +def do_revision(config, cmd): + do_alembic_command(config, cmd, + message=CONF.command.message, + autogenerate=CONF.command.autogenerate, + sql=CONF.command.sql) + + +def add_command_parsers(subparsers): + for name in ['current', 'history', 'branches']: + parser = subparsers.add_parser(name) + parser.set_defaults(func=do_alembic_command) + + parser = subparsers.add_parser('check_migration') + parser.set_defaults(func=do_check_migration) + + for name in ['upgrade', 'downgrade']: + parser = subparsers.add_parser(name) + parser.add_argument('--delta', type=int) + parser.add_argument('--sql', action='store_true') + parser.add_argument('revision', nargs='?') + parser.set_defaults(func=do_upgrade_downgrade) + + parser = subparsers.add_parser('stamp') + parser.add_argument('--sql', action='store_true') + parser.add_argument('revision') + parser.set_defaults(func=do_stamp) + + parser = subparsers.add_parser('revision') + parser.add_argument('-m', '--message') + parser.add_argument('--autogenerate', action='store_true') + parser.add_argument('--sql', action='store_true') + parser.set_defaults(func=do_revision) + +command_opt = cfg.SubCommandOpt('command', + title='Command', + help=_('Available commands'), + handler=add_command_parsers) + +CONF.register_cli_opt(command_opt) +CONF.register_opts(options.database_opts, 'database') + + +def get_alembic_config(): + print os.path.join(os.path.dirname(__file__), 'alembic.ini') + config = alembic_config.Config( + os.path.join(os.path.dirname(__file__), 'alembic.ini')) + config.set_main_option('script_location', + 'radar.db.migration:alembic_migrations') + return config + + +def main(): + config = get_alembic_config() + # attach the radar conf to the Alembic conf + config.radar_config = CONF + + CONF(project='radar') + CONF.command.func(config, CONF.command.name) diff --git a/radar/db/models.py b/radar/db/models.py new file mode 100644 index 0000000..3a937fd --- /dev/null +++ b/radar/db/models.py @@ -0,0 +1,221 @@ +""" +SQLAlchemy Models +""" + +from oslo.config import cfg +from oslo.db.sqlalchemy import models +from sqlalchemy import Boolean +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy.dialects.mysql import MEDIUMTEXT +from sqlalchemy import Enum +from sqlalchemy.ext import declarative +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy.orm import relationship +from sqlalchemy import schema +from sqlalchemy import select +import sqlalchemy.sql.expression as expr +import sqlalchemy.sql.functions as func +from sqlalchemy import String +from sqlalchemy import Table +from sqlalchemy import Unicode +from sqlalchemy import UnicodeText +from sqlalchemy_fulltext import FullText + +import six.moves.urllib.parse as urlparse + + +CONF = cfg.CONF + +def table_args(): + engine_name = urlparse.urlparse(cfg.CONF.database_connection).scheme + if engine_name == 'mysql': + return {'mysql_engine': cfg.CONF.mysql_engine, + 'mysql_charset': "utf8"} + return None + +## CUSTOM TYPES + +# A mysql medium text type. +MYSQL_MEDIUM_TEXT = UnicodeText().with_variant(MEDIUMTEXT(), 'mysql') + + +class IdMixin(object): + id = Column(Integer, primary_key=True) + + +class RadarBase(models.TimestampMixin, + IdMixin, + models.ModelBase): + metadata = None + + @declarative.declared_attr + def __tablename__(cls): + return cls.__name__.lower() + 's' + + def as_dict(self): + d = {} + for c in self.__table__.columns: + d[c.name] = self[c.name] + return d + +Base = declarative.declarative_base(cls=RadarBase) + +class ModelBuilder(object): + def __init__(self, **kwargs): + super(ModelBuilder, self).__init__() + + if kwargs: + for key in kwargs: + if key in self: + self[key] = kwargs[key] + +class AuthorizationCode(ModelBuilder, Base): + __tablename__ = "authorization_codes" + code = Column(Unicode(100), nullable=False) + state = Column(Unicode(100), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + + +class AccessToken(ModelBuilder, Base): + __tablename__ = "accesstokens" + + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + access_token = Column(Unicode(100), nullable=False) + expires_in = Column(Integer, nullable=False) + expires_at = Column(DateTime, nullable=False) + + +class RefreshToken(ModelBuilder, Base): + __tablename__ = "refreshtokens" + + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + refresh_token = Column(Unicode(100), nullable=False) + expires_in = Column(Integer, nullable=False) + expires_at = Column(DateTime, nullable=False) + +user_permissions = Table( + 'user_permissions', Base.metadata, + Column('user_id', Integer, ForeignKey('users.id')), + Column('permission_id', Integer, ForeignKey('permissions.id')), +) + +systems_operators = Table( + 'systems_operators', Base.metadata, + Column('system_id', Integer, ForeignKey('systems.id')), + Column('operator_id', Integer, ForeignKey('operators.id')), +) +class System(FullText, ModelBuilder, Base): + __tablename__ = "systems" + + __fulltext_columns__ = ['name'] + + name = Column(Unicode(50)) + events = relationship('SystemEvent', backref='system') + operators = relationship("Operator", secondary="systems_operators") + + _public_fields = ["id", "name", "events", "operators"] + + +class SystemEvent(ModelBuilder, Base): + __tablename__ = 'system_events' + + __fulltext_columns__ = ['event_type', 'event_info'] + + system_id = Column(Integer, ForeignKey('systems.id')) + event_type = Column(Unicode(100), nullable=False) + event_info = Column(UnicodeText(), nullable=True) + + _public_fields = ["id", "system_id", "event_type", "event_info"] + +class Operator(ModelBuilder, Base): + __tablename__ = "operators" + + __fulltext_columns__ = ['operator_name', 'operator_email'] + + operator_name = Column(Unicode(50)) + operator_email = Column(Unicode(50)) + systems = relationship('System', secondary="systems_operators") + + _public_fields = ["id", "operator_name", "operator_email", "systems"] + +class User(FullText, ModelBuilder, Base): + __table_args__ = ( + schema.UniqueConstraint('email', name='uniq_user_email'), + ) + + __fulltext_columns__ = ['username', 'full_name', 'email'] + + username = Column(Unicode(30)) + full_name = Column(Unicode(255), nullable=True) + email = Column(String(255)) + openid = Column(String(255)) + is_staff = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + last_login = Column(DateTime) + permissions = relationship("Permission", secondary="user_permissions") + enable_login = Column(Boolean, default=True) + + preferences = relationship("UserPreference") + + _public_fields = ["id", "openid", "full_name", "username", "last_login", + "enable_login"] + +class Permission(ModelBuilder, Base): + __table_args__ = ( + schema.UniqueConstraint('name', name='uniq_permission_name'), + ) + name = Column(Unicode(50)) + codename = Column(Unicode(255)) + +class UserPreference(ModelBuilder, Base): + __tablename__ = 'user_preferences' + + _TASK_TYPES = ('string', 'int', 'bool', 'float') + + user_id = Column(Integer, ForeignKey('users.id')) + key = Column(Unicode(100)) + value = Column(Unicode(255)) + type = Column(Enum(*_TASK_TYPES), default='string') + + @property + def cast_value(self): + try: + cast_func = { + 'float': lambda x: float(x), + 'int': lambda x: int(x), + 'bool': lambda x: bool(x), + 'string': lambda x: str(x) + }[self.type] + + return cast_func(self.value) + except ValueError: + return self.value + + @cast_value.setter + def cast_value(self, value): + if isinstance(value, bool): + self.type = 'bool' + elif isinstance(value, int): + self.type = 'int' + elif isinstance(value, float): + self.type = 'float' + else: + self.type = 'string' + + self.value = str(value) + + _public_fields = ["id", "key", "value", "type"] + +class Subscription(ModelBuilder, Base): + _SUBSCRIPTION_TARGETS = ('system') + + user_id = Column(Integer, ForeignKey('users.id')) + target_type = Column(Enum(*_SUBSCRIPTION_TARGETS)) + + # Cant use foreign key here as it depends on the type + target_id = Column(Integer) + + _public_fields = ["id", "target_type", "target_id", "user_id"] diff --git a/radar/notifications/__init__.py b/radar/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/notifications/conf.py b/radar/notifications/conf.py new file mode 100644 index 0000000..7b83be1 --- /dev/null +++ b/radar/notifications/conf.py @@ -0,0 +1,43 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo.config import cfg + +CONF = cfg.CONF + +NOTIFICATION_OPTS = [ + cfg.StrOpt("rabbit_exchange_name", default="radar", + help="The name of the topic exchange which radar will " + "use to broadcast its events."), + cfg.StrOpt("rabbit_event_queue_name", default="radar_events", + help="The name of the queue that will be created for " + "API events."), + cfg.StrOpt("rabbit_application_name", default="radar", + help="The rabbit application identifier for radar's " + "connection."), + cfg.StrOpt("rabbit_host", default="localhost", + help="Host of the rabbitmq server."), + cfg.StrOpt("rabbit_login_method", default="AMQPLAIN", + help="The RabbitMQ login method."), + cfg.StrOpt("rabbit_userid", default="radar", + help="The RabbitMQ userid."), + cfg.StrOpt("rabbit_password", default="radar", + help="The RabbitMQ password."), + cfg.IntOpt("rabbit_port", default=5672, + help="The RabbitMQ broker port where a single node is used."), + cfg.StrOpt("rabbit_virtual_host", default="/", + help="The virtual host within which our queues and exchanges " + "live."), +] \ No newline at end of file diff --git a/radar/notifications/connection_service.py b/radar/notifications/connection_service.py new file mode 100644 index 0000000..c1b1a43 --- /dev/null +++ b/radar/notifications/connection_service.py @@ -0,0 +1,153 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from threading import Timer + +import pika + +from oslo.config import cfg + +from radar.openstack.common import log +from radar.openstack.common.gettextutils import _, _LI # noqa + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +class ConnectionService(object): + """A generic amqp connection agent that handles unexpected + interactions with RabbitMQ such as channel and connection closures, + by reconnecting on failure. + """ + + def __init__(self, conf): + """Setup the connection instance based on our configuration. + + :param conf A configuration object. + """ + self._connection = None + self._channel = None + self._open = False + self.started = False + self._timer = None + self._closing = False + self._open_hooks = set() + self._exchange_name = conf.rabbit_exchange_name + self._application_id = conf.rabbit_application_name + self._properties = pika.BasicProperties( + app_id='radar', content_type='application/json') + self._connection_credentials = pika.PlainCredentials( + conf.rabbit_userid, + conf.rabbit_password) + self._connection_parameters = pika.ConnectionParameters( + conf.rabbit_host, + conf.rabbit_port, + conf.rabbit_virtual_host, + self._connection_credentials) + + def _connect(self): + """This method connects to RabbitMQ, establishes a channel, declares + the radar exchange if it doesn't yet exist, and executes any + post-connection hooks that an extending class may have registered. + """ + + # If the closing flag is set, just exit. + if self._closing: + return + + # If a timer is set, kill it. + if self._timer: + LOG.debug(_('Clearing timer...')) + self._timer.cancel() + self._timer = None + + # Create the connection + LOG.info(_LI('Connecting to %s'), self._connection_parameters.host) + self._connection = pika.BlockingConnection(self._connection_parameters) + + # Create a channel + LOG.debug(_('Creating a new channel')) + self._channel = self._connection.channel() + self._channel.confirm_delivery() + + # Declare the exchange + LOG.debug(_('Declaring exchange %s'), self._exchange_name) + self._channel.exchange_declare(exchange=self._exchange_name, + exchange_type='topic', + durable=True, + auto_delete=False) + + # Set the open flag and execute any connection hooks. + self._open = True + self._execute_open_hooks() + + def _reconnect(self): + """Reconnect to rabbit. + """ + + # Sanity check - if we're closing, do nothing. + if self._closing: + return + + # If a timer is already there, assume it's doing its thing... + if self._timer: + return + LOG.debug(_('Scheduling reconnect in 5 seconds...')) + self._timer = Timer(5, self._connect) + self._timer.start() + + def _close(self): + """This method closes the connection to RabbitMQ.""" + LOG.info(_LI('Closing connection')) + self._open = False + if self._channel: + self._channel.close() + self._channel = None + if self._connection: + self._connection.close() + self._connection = None + self._closing = False + LOG.debug(_('Connection Closed')) + + def _execute_open_hooks(self): + """Executes all hooks that have been registered to run on open. + """ + for hook in self._open_hooks: + hook() + + def start(self): + """Start the publisher, opening a connection to RabbitMQ. This method + must be explicitly invoked, otherwise any messages will simply be + cached for later broadcast. + """ + + # Create the connection. + self.started = True + self._closing = False + self._connect() + + def stop(self): + """Stop the publisher by closing the channel and the connection. + """ + self.started = False + self._closing = True + self._close() + + def add_open_hook(self, hook): + """Add a method that will be executed whenever a connection is + established. + """ + self._open_hooks.add(hook) \ No newline at end of file diff --git a/radar/notifications/notification_hook.py b/radar/notifications/notification_hook.py new file mode 100644 index 0000000..1a321c7 --- /dev/null +++ b/radar/notifications/notification_hook.py @@ -0,0 +1,82 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import re + +from pecan import hooks + +from radar.notifications.publisher import publish + + +class NotificationHook(hooks.PecanHook): + def __init__(self): + super(NotificationHook, self).__init__() + + def after(self, state): + # Ignore get methods, we only care about changes. + if state.request.method not in ['POST', 'PUT', 'DELETE']: + return + + request = state.request + req_method = request.method + req_author_id = request.current_user_id + req_path = request.path + req_resource_grp = self._parse(req_path) + + if not req_resource_grp: + return + + resource = req_resource_grp[0] + + if req_resource_grp[1]: + resource_id = req_resource_grp[1] + else: + # When a resource is created.. + response_str = state.response.body + response = json.loads(response_str) + if response: + resource_id = response.get('id') + else: + resource_id = None + + # when adding/removing projects to project_groups.. + if req_resource_grp[3]: + sub_resource_id = req_resource_grp[3] + payload = { + "author_id": req_author_id, + "method": req_method, + "resource": resource, + "resource_id": resource_id, + "sub_resource_id": sub_resource_id + } + + else: + payload = { + "author_id": req_author_id, + "method": req_method, + "resource": resource, + "resource_id": resource_id + } + + publish(resource, payload) + + def _parse(self, s): + url_pattern = re.match("^\/v1\/([a-z_]+)\/?([0-9]+)?" + "\/?([a-z]+)?\/?([0-9]+)?$", s) + if url_pattern and url_pattern.groups()[0] != "openid": + return url_pattern.groups() + else: + return \ No newline at end of file diff --git a/radar/notifications/publisher.py b/radar/notifications/publisher.py new file mode 100644 index 0000000..fbfe989 --- /dev/null +++ b/radar/notifications/publisher.py @@ -0,0 +1,149 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +from oslo.config import cfg +from pika.exceptions import ConnectionClosed + +from radar.notifications.conf import NOTIFICATION_OPTS +from radar.notifications.connection_service import ConnectionService +from radar.openstack.common import log +from radar.openstack.common.gettextutils import _, _LW, _LE # noqa + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) +PUBLISHER = None + + +class Publisher(ConnectionService): + """A generic message publisher that uses delivery confirmation to ensure + that messages are delivered, and will keep a running cache of unsent + messages while the publisher is attempting to reconnect. + """ + + def __init__(self, conf): + """Setup the publisher instance based on our configuration. + + :param conf A configuration object. + """ + super(Publisher, self).__init__(conf) + + self._pending = list() + + self.add_open_hook(self._publish_pending) + + def _publish_pending(self): + """Publishes any pending messages that were broadcast while the + publisher was connecting. + """ + + # Shallow copy, so we can iterate over it without having it be modified + # out of band. + pending = list(self._pending) + + for payload in pending: + self._publish(payload) + + def _publish(self, payload): + """Publishes a payload to the passed exchange. If it encounters a + failure, will store the payload for later. + + :param Payload payload: The payload to send. + """ + LOG.debug(_("Sending message to %(name)s [%(topic)s]") % + {'name': self._exchange_name, 'topic': payload.topic}) + + # First check, are we closing? + if self._closing: + LOG.warning(_LW("Cannot send message, publisher is closing.")) + if payload not in self._pending: + self._pending.append(payload) + return + + # Second check, are we open? + if not self._open: + LOG.debug(_("Cannot send message, publisher is connecting.")) + if payload not in self._pending: + self._pending.append(payload) + self._reconnect() + return + + # Third check, are we in a sane state? This should never happen, + # but just in case... + if not self._connection or not self._channel: + LOG.error(_LE("Cannot send message, publisher is " + "an unexpected state.")) + if payload not in self._pending: + self._pending.append(payload) + self._reconnect() + return + + # Try to send a message. If we fail, schedule a reconnect and store + # the message. + try: + self._channel.basic_publish(self._exchange_name, + payload.topic, + json.dumps(payload.payload, + ensure_ascii=False), + self._properties) + if payload in self._pending: + self._pending.remove(payload) + return True + except ConnectionClosed as cc: + LOG.warning(_LW("Attempted to send message on closed connection.")) + LOG.debug(cc) + self._open = False + if payload not in self._pending: + self._pending.append(payload) + self._reconnect() + return False + + def publish_message(self, topic, payload): + """Publishes a message to RabbitMQ. + """ + self._publish(Payload(topic, payload)) + + +class Payload(object): + def __init__(self, topic, payload): + """Setup the example publisher object, passing in the URL we will use + to connect to RabbitMQ. + + :param topic string The exchange topic to broadcast on. + :param payload string The message payload to send. + """ + + self.topic = topic + self.payload = payload + + +def publish(topic, payload): + """Send a message with a given topic and payload to the radar + exchange. The message will be automatically JSON encoded. + + :param topic: The RabbitMQ topic. + :param payload: The JSON-serializable payload. + :return: + """ + global PUBLISHER + + if not PUBLISHER: + CONF.register_opts(NOTIFICATION_OPTS, "notifications") + PUBLISHER = Publisher(CONF.notifications) + PUBLISHER.start() + + PUBLISHER.publish_message(topic, payload) \ No newline at end of file diff --git a/radar/notifications/subscriber.py b/radar/notifications/subscriber.py new file mode 100644 index 0000000..68375a0 --- /dev/null +++ b/radar/notifications/subscriber.py @@ -0,0 +1,135 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time + +from oslo.config import cfg +from pika.exceptions import ConnectionClosed +from stevedore import enabled + +from radar.notifications.conf import NOTIFICATION_OPTS +from radar.notifications.connection_service import ConnectionService +from radar.openstack.common import log +from radar.openstack.common.gettextutils import _, _LW # noqa + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +def subscribe(): + log.setup('radar') + CONF(project='radar') + CONF.register_opts(NOTIFICATION_OPTS, "notifications") + + subscriber = Subscriber(CONF.notifications) + subscriber.start() + + manager = enabled.EnabledExtensionManager( + namespace='radar.worker.task', + check_func=check_enabled, + invoke_on_load=True, + invoke_args=(CONF,) + ) + + while subscriber.started: + (method, properties, body) = subscriber.get() + + if not method or not properties: + LOG.debug(_("No messages available, sleeping for 5 seconds.")) + time.sleep(5) + continue + + manager.map(handle_event, body) + + # Ack the message + subscriber.ack(method.delivery_tag) + + +def handle_event(ext, body): + """Handle an event from the queue. + + :param ext: The extension that's handling this event. + :param body: The body of the event. + :return: The result of the handler. + """ + return ext.obj.handle(body) + + +def check_enabled(ext): + """Check to see whether an extension should be enabled. + + :param ext: The extension instance to check. + :return: True if it should be enabled. Otherwise false. + """ + return ext.obj.enabled() + + +class Subscriber(ConnectionService): + def __init__(self, conf): + """Setup the subscriber instance based on our configuration. + + :param conf A configuration object. + """ + super(Subscriber, self).__init__(conf) + + self._queue_name = conf.rabbit_event_queue_name + self._binding_keys = ['systems'] + self.add_open_hook(self._declare_queue) + + def _declare_queue(self): + """Declare the subscription queue against our exchange. + """ + self._channel.queue_declare(queue=self._queue_name, + durable=True) + + # Set up the queue bindings. + for binding_key in self._binding_keys: + self._channel.queue_bind(exchange=self._exchange_name, + queue=self._queue_name, + routing_key=binding_key) + + def ack(self, delivery_tag): + """Acknowledge receipt and processing of the message. + """ + self._channel.basic_ack(delivery_tag) + + def get(self): + """Get a single message from the queue. If the subscriber is currently + waiting to reconnect, it will return None. Note that you must + manually ack the message after it has been successfully processed. + + :rtype: (None, None, None)|(spec.Basic.Get, + spec.Basic.Properties, + str or unicode) + """ + + # Sanity check one, are we closing? + if self._closing: + return None, None, None + + # Sanity check two, are we open, or reconnecting? + if not self._open: + return None, None, None + + try: + return self._channel.basic_get(queue=self._queue_name, + no_ack=False) + except ConnectionClosed as cc: + LOG.warning(_LW("Attempted to get message on closed connection.")) + LOG.debug(cc) + self._open = False + self._reconnect() + return None, None, None \ No newline at end of file diff --git a/radar/openstack/__init__.py b/radar/openstack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/openstack/common/__init__.py b/radar/openstack/common/__init__.py new file mode 100644 index 0000000..2a00f3b --- /dev/null +++ b/radar/openstack/common/__init__.py @@ -0,0 +1,2 @@ +import six +six.add_move(six.MovedModule('mox', 'mox', 'mox3.mox')) diff --git a/radar/openstack/common/gettextutils.py b/radar/openstack/common/gettextutils.py new file mode 100644 index 0000000..fa46023 --- /dev/null +++ b/radar/openstack/common/gettextutils.py @@ -0,0 +1,474 @@ +# Copyright 2012 Red Hat, Inc. +# Copyright 2013 IBM Corp. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +gettext for openstack-common modules. + +Usual usage in an openstack.common module: + + from radar.openstack.common.gettextutils import _ +""" + +import copy +import functools +import gettext +import locale +from logging import handlers +import os +import re + +from babel import localedata +import six + +_localedir = os.environ.get('radar'.upper() + '_LOCALEDIR') +_t = gettext.translation('radar', localedir=_localedir, fallback=True) + +# We use separate translation catalogs for each log level, so set up a +# mapping between the log level name and the translator. The domain +# for the log level is project_name + "-log-" + log_level so messages +# for each level end up in their own catalog. +_t_log_levels = dict( + (level, gettext.translation('radar' + '-log-' + level, + localedir=_localedir, + fallback=True)) + for level in ['info', 'warning', 'error', 'critical'] +) + +_AVAILABLE_LANGUAGES = {} +USE_LAZY = False + + +def enable_lazy(): + """Convenience function for configuring _() to use lazy gettext + + Call this at the start of execution to enable the gettextutils._ + function to use lazy gettext functionality. This is useful if + your project is importing _ directly instead of using the + gettextutils.install() way of importing the _ function. + """ + global USE_LAZY + USE_LAZY = True + + +def _(msg): + if USE_LAZY: + return Message(msg, domain='radar') + else: + if six.PY3: + return _t.gettext(msg) + return _t.ugettext(msg) + + +def _log_translation(msg, level): + """Build a single translation of a log message + """ + if USE_LAZY: + return Message(msg, domain='radar' + '-log-' + level) + else: + translator = _t_log_levels[level] + if six.PY3: + return translator.gettext(msg) + return translator.ugettext(msg) + +# Translators for log levels. +# +# The abbreviated names are meant to reflect the usual use of a short +# name like '_'. The "L" is for "log" and the other letter comes from +# the level. +_LI = functools.partial(_log_translation, level='info') +_LW = functools.partial(_log_translation, level='warning') +_LE = functools.partial(_log_translation, level='error') +_LC = functools.partial(_log_translation, level='critical') + + +def install(domain, lazy=False): + """Install a _() function using the given translation domain. + + Given a translation domain, install a _() function using gettext's + install() function. + + The main difference from gettext.install() is that we allow + overriding the default localedir (e.g. /usr/share/locale) using + a translation-domain-specific environment variable (e.g. + NOVA_LOCALEDIR). + + :param domain: the translation domain + :param lazy: indicates whether or not to install the lazy _() function. + The lazy _() introduces a way to do deferred translation + of messages by installing a _ that builds Message objects, + instead of strings, which can then be lazily translated into + any available locale. + """ + if lazy: + # NOTE(mrodden): Lazy gettext functionality. + # + # The following introduces a deferred way to do translations on + # messages in OpenStack. We override the standard _() function + # and % (format string) operation to build Message objects that can + # later be translated when we have more information. + def _lazy_gettext(msg): + """Create and return a Message object. + + Lazy gettext function for a given domain, it is a factory method + for a project/module to get a lazy gettext function for its own + translation domain (i.e. nova, glance, cinder, etc.) + + Message encapsulates a string so that we can translate + it later when needed. + """ + return Message(msg, domain=domain) + + from six import moves + moves.builtins.__dict__['_'] = _lazy_gettext + else: + localedir = '%s_LOCALEDIR' % domain.upper() + if six.PY3: + gettext.install(domain, + localedir=os.environ.get(localedir)) + else: + gettext.install(domain, + localedir=os.environ.get(localedir), + unicode=True) + + +class Message(six.text_type): + """A Message object is a unicode object that can be translated. + + Translation of Message is done explicitly using the translate() method. + For all non-translation intents and purposes, a Message is simply unicode, + and can be treated as such. + """ + + def __new__(cls, msgid, msgtext=None, params=None, + domain='radar', *args): + """Create a new Message object. + + In order for translation to work gettext requires a message ID, this + msgid will be used as the base unicode text. It is also possible + for the msgid and the base unicode text to be different by passing + the msgtext parameter. + """ + # If the base msgtext is not given, we use the default translation + # of the msgid (which is in English) just in case the system locale is + # not English, so that the base text will be in that locale by default. + if not msgtext: + msgtext = Message._translate_msgid(msgid, domain) + # We want to initialize the parent unicode with the actual object that + # would have been plain unicode if 'Message' was not enabled. + msg = super(Message, cls).__new__(cls, msgtext) + msg.msgid = msgid + msg.domain = domain + msg.params = params + return msg + + def translate(self, desired_locale=None): + """Translate this message to the desired locale. + + :param desired_locale: The desired locale to translate the message to, + if no locale is provided the message will be + translated to the system's default locale. + + :returns: the translated message in unicode + """ + + translated_message = Message._translate_msgid(self.msgid, + self.domain, + desired_locale) + if self.params is None: + # No need for more translation + return translated_message + + # This Message object may have been formatted with one or more + # Message objects as substitution arguments, given either as a single + # argument, part of a tuple, or as one or more values in a dictionary. + # When translating this Message we need to translate those Messages too + translated_params = _translate_args(self.params, desired_locale) + + translated_message = translated_message % translated_params + + return translated_message + + @staticmethod + def _translate_msgid(msgid, domain, desired_locale=None): + if not desired_locale: + system_locale = locale.getdefaultlocale() + # If the system locale is not available to the runtime use English + if not system_locale[0]: + desired_locale = 'en_US' + else: + desired_locale = system_locale[0] + + locale_dir = os.environ.get(domain.upper() + '_LOCALEDIR') + lang = gettext.translation(domain, + localedir=locale_dir, + languages=[desired_locale], + fallback=True) + if six.PY3: + translator = lang.gettext + else: + translator = lang.ugettext + + translated_message = translator(msgid) + return translated_message + + def __mod__(self, other): + # When we mod a Message we want the actual operation to be performed + # by the parent class (i.e. unicode()), the only thing we do here is + # save the original msgid and the parameters in case of a translation + params = self._sanitize_mod_params(other) + unicode_mod = super(Message, self).__mod__(params) + modded = Message(self.msgid, + msgtext=unicode_mod, + params=params, + domain=self.domain) + return modded + + def _sanitize_mod_params(self, other): + """Sanitize the object being modded with this Message. + + - Add support for modding 'None' so translation supports it + - Trim the modded object, which can be a large dictionary, to only + those keys that would actually be used in a translation + - Snapshot the object being modded, in case the message is + translated, it will be used as it was when the Message was created + """ + if other is None: + params = (other,) + elif isinstance(other, dict): + params = self._trim_dictionary_parameters(other) + else: + params = self._copy_param(other) + return params + + def _trim_dictionary_parameters(self, dict_param): + """Return a dict that only has matching entries in the msgid.""" + # NOTE(luisg): Here we trim down the dictionary passed as parameters + # to avoid carrying a lot of unnecessary weight around in the message + # object, for example if someone passes in Message() % locals() but + # only some params are used, and additionally we prevent errors for + # non-deepcopyable objects by unicoding() them. + + # Look for %(param) keys in msgid; + # Skip %% and deal with the case where % is first character on the line + keys = re.findall('(?:[^%]|^)?%\((\w*)\)[a-z]', self.msgid) + + # If we don't find any %(param) keys but have a %s + if not keys and re.findall('(?:[^%]|^)%[a-z]', self.msgid): + # Apparently the full dictionary is the parameter + params = self._copy_param(dict_param) + else: + params = {} + # Save our existing parameters as defaults to protect + # ourselves from losing values if we are called through an + # (erroneous) chain that builds a valid Message with + # arguments, and then does something like "msg % kwds" + # where kwds is an empty dictionary. + src = {} + if isinstance(self.params, dict): + src.update(self.params) + src.update(dict_param) + for key in keys: + params[key] = self._copy_param(src[key]) + + return params + + def _copy_param(self, param): + try: + return copy.deepcopy(param) + except TypeError: + # Fallback to casting to unicode this will handle the + # python code-like objects that can't be deep-copied + return six.text_type(param) + + def __add__(self, other): + msg = _('Message objects do not support addition.') + raise TypeError(msg) + + def __radd__(self, other): + return self.__add__(other) + + def __str__(self): + # NOTE(luisg): Logging in python 2.6 tries to str() log records, + # and it expects specifically a UnicodeError in order to proceed. + msg = _('Message objects do not support str() because they may ' + 'contain non-ascii characters. ' + 'Please use unicode() or translate() instead.') + raise UnicodeError(msg) + + +def get_available_languages(domain): + """Lists the available languages for the given translation domain. + + :param domain: the domain to get languages for + """ + if domain in _AVAILABLE_LANGUAGES: + return copy.copy(_AVAILABLE_LANGUAGES[domain]) + + localedir = '%s_LOCALEDIR' % domain.upper() + find = lambda x: gettext.find(domain, + localedir=os.environ.get(localedir), + languages=[x]) + + # NOTE(mrodden): en_US should always be available (and first in case + # order matters) since our in-line message strings are en_US + language_list = ['en_US'] + # NOTE(luisg): Babel <1.0 used a function called list(), which was + # renamed to locale_identifiers() in >=1.0, the requirements master list + # requires >=0.9.6, uncapped, so defensively work with both. We can remove + # this check when the master list updates to >=1.0, and update all projects + list_identifiers = (getattr(localedata, 'list', None) or + getattr(localedata, 'locale_identifiers')) + locale_identifiers = list_identifiers() + + for i in locale_identifiers: + if find(i) is not None: + language_list.append(i) + + # NOTE(luisg): Babel>=1.0,<1.3 has a bug where some OpenStack supported + # locales (e.g. 'zh_CN', and 'zh_TW') aren't supported even though they + # are perfectly legitimate locales: + # https://github.com/mitsuhiko/babel/issues/37 + # In Babel 1.3 they fixed the bug and they support these locales, but + # they are still not explicitly "listed" by locale_identifiers(). + # That is why we add the locales here explicitly if necessary so that + # they are listed as supported. + aliases = {'zh': 'zh_CN', + 'zh_Hant_HK': 'zh_HK', + 'zh_Hant': 'zh_TW', + 'fil': 'tl_PH'} + for (locale, alias) in six.iteritems(aliases): + if locale in language_list and alias not in language_list: + language_list.append(alias) + + _AVAILABLE_LANGUAGES[domain] = language_list + return copy.copy(language_list) + + +def translate(obj, desired_locale=None): + """Gets the translated unicode representation of the given object. + + If the object is not translatable it is returned as-is. + If the locale is None the object is translated to the system locale. + + :param obj: the object to translate + :param desired_locale: the locale to translate the message to, if None the + default system locale will be used + :returns: the translated object in unicode, or the original object if + it could not be translated + """ + message = obj + if not isinstance(message, Message): + # If the object to translate is not already translatable, + # let's first get its unicode representation + message = six.text_type(obj) + if isinstance(message, Message): + # Even after unicoding() we still need to check if we are + # running with translatable unicode before translating + return message.translate(desired_locale) + return obj + + +def _translate_args(args, desired_locale=None): + """Translates all the translatable elements of the given arguments object. + + This method is used for translating the translatable values in method + arguments which include values of tuples or dictionaries. + If the object is not a tuple or a dictionary the object itself is + translated if it is translatable. + + If the locale is None the object is translated to the system locale. + + :param args: the args to translate + :param desired_locale: the locale to translate the args to, if None the + default system locale will be used + :returns: a new args object with the translated contents of the original + """ + if isinstance(args, tuple): + return tuple(translate(v, desired_locale) for v in args) + if isinstance(args, dict): + translated_dict = {} + for (k, v) in six.iteritems(args): + translated_v = translate(v, desired_locale) + translated_dict[k] = translated_v + return translated_dict + return translate(args, desired_locale) + + +class TranslationHandler(handlers.MemoryHandler): + """Handler that translates records before logging them. + + The TranslationHandler takes a locale and a target logging.Handler object + to forward LogRecord objects to after translating them. This handler + depends on Message objects being logged, instead of regular strings. + + The handler can be configured declaratively in the logging.conf as follows: + + [handlers] + keys = translatedlog, translator + + [handler_translatedlog] + class = handlers.WatchedFileHandler + args = ('/var/log/api-localized.log',) + formatter = context + + [handler_translator] + class = openstack.common.log.TranslationHandler + target = translatedlog + args = ('zh_CN',) + + If the specified locale is not available in the system, the handler will + log in the default locale. + """ + + def __init__(self, locale=None, target=None): + """Initialize a TranslationHandler + + :param locale: locale to use for translating messages + :param target: logging.Handler object to forward + LogRecord objects to after translation + """ + # NOTE(luisg): In order to allow this handler to be a wrapper for + # other handlers, such as a FileHandler, and still be able to + # configure it using logging.conf, this handler has to extend + # MemoryHandler because only the MemoryHandlers' logging.conf + # parsing is implemented such that it accepts a target handler. + handlers.MemoryHandler.__init__(self, capacity=0, target=target) + self.locale = locale + + def setFormatter(self, fmt): + self.target.setFormatter(fmt) + + def emit(self, record): + # We save the message from the original record to restore it + # after translation, so other handlers are not affected by this + original_msg = record.msg + original_args = record.args + + try: + self._translate_and_log_record(record) + finally: + record.msg = original_msg + record.args = original_args + + def _translate_and_log_record(self, record): + record.msg = translate(record.msg, self.locale) + + # In addition to translating the message, we also need to translate + # arguments that were passed to the log method that were not part + # of the main message e.g., log.info(_('Some message %s'), this_one)) + record.args = _translate_args(record.args, self.locale) + + self.target.emit(record) diff --git a/radar/openstack/common/importutils.py b/radar/openstack/common/importutils.py new file mode 100644 index 0000000..66cf2a4 --- /dev/null +++ b/radar/openstack/common/importutils.py @@ -0,0 +1,73 @@ +# Copyright 2011 OpenStack Foundation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Import related utilities and helper functions. +""" + +import sys +import traceback + + +def import_class(import_str): + """Returns a class from a string including module and class.""" + mod_str, _sep, class_str = import_str.rpartition('.') + try: + __import__(mod_str) + return getattr(sys.modules[mod_str], class_str) + except (ValueError, AttributeError): + raise ImportError('Class %s cannot be found (%s)' % + (class_str, + traceback.format_exception(*sys.exc_info()))) + + +def import_object(import_str, *args, **kwargs): + """Import a class and return an instance of it.""" + return import_class(import_str)(*args, **kwargs) + + +def import_object_ns(name_space, import_str, *args, **kwargs): + """Tries to import object from default namespace. + + Imports a class and return an instance of it, first by trying + to find the class in a default namespace, then failing back to + a full path if not found in the default namespace. + """ + import_value = "%s.%s" % (name_space, import_str) + try: + return import_class(import_value)(*args, **kwargs) + except ImportError: + return import_class(import_str)(*args, **kwargs) + + +def import_module(import_str): + """Import a module.""" + __import__(import_str) + return sys.modules[import_str] + + +def import_versioned_module(version, submodule=None): + module = 'radar.v%s' % version + if submodule: + module = '.'.join((module, submodule)) + return import_module(module) + + +def try_import(import_str, default=None): + """Try to import a module and if it fails return default.""" + try: + return import_module(import_str) + except ImportError: + return default diff --git a/radar/openstack/common/jsonutils.py b/radar/openstack/common/jsonutils.py new file mode 100644 index 0000000..d9b856f --- /dev/null +++ b/radar/openstack/common/jsonutils.py @@ -0,0 +1,174 @@ +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# Copyright 2011 Justin Santa Barbara +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +''' +JSON related utilities. + +This module provides a few things: + + 1) A handy function for getting an object down to something that can be + JSON serialized. See to_primitive(). + + 2) Wrappers around loads() and dumps(). The dumps() wrapper will + automatically use to_primitive() for you if needed. + + 3) This sets up anyjson to use the loads() and dumps() wrappers if anyjson + is available. +''' + + +import datetime +import functools +import inspect +import itertools +import json + +import six +import six.moves.xmlrpc_client as xmlrpclib + +from radar.openstack.common import gettextutils +from radar.openstack.common import importutils +from radar.openstack.common import timeutils + +netaddr = importutils.try_import("netaddr") + +_nasty_type_tests = [inspect.ismodule, inspect.isclass, inspect.ismethod, + inspect.isfunction, inspect.isgeneratorfunction, + inspect.isgenerator, inspect.istraceback, inspect.isframe, + inspect.iscode, inspect.isbuiltin, inspect.isroutine, + inspect.isabstract] + +_simple_types = (six.string_types + six.integer_types + + (type(None), bool, float)) + + +def to_primitive(value, convert_instances=False, convert_datetime=True, + level=0, max_depth=3): + """Convert a complex object into primitives. + + Handy for JSON serialization. We can optionally handle instances, + but since this is a recursive function, we could have cyclical + data structures. + + To handle cyclical data structures we could track the actual objects + visited in a set, but not all objects are hashable. Instead we just + track the depth of the object inspections and don't go too deep. + + Therefore, convert_instances=True is lossy ... be aware. + + """ + # handle obvious types first - order of basic types determined by running + # full tests on nova project, resulting in the following counts: + # 572754 + # 460353 + # 379632 + # 274610 + # 199918 + # 114200 + # 51817 + # 26164 + # 6491 + # 283 + # 19 + if isinstance(value, _simple_types): + return value + + if isinstance(value, datetime.datetime): + if convert_datetime: + return timeutils.strtime(value) + else: + return value + + # value of itertools.count doesn't get caught by nasty_type_tests + # and results in infinite loop when list(value) is called. + if type(value) == itertools.count: + return six.text_type(value) + + # FIXME(vish): Workaround for LP bug 852095. Without this workaround, + # tests that raise an exception in a mocked method that + # has a @wrap_exception with a notifier will fail. If + # we up the dependency to 0.5.4 (when it is released) we + # can remove this workaround. + if getattr(value, '__module__', None) == 'mox': + return 'mock' + + if level > max_depth: + return '?' + + # The try block may not be necessary after the class check above, + # but just in case ... + try: + recursive = functools.partial(to_primitive, + convert_instances=convert_instances, + convert_datetime=convert_datetime, + level=level, + max_depth=max_depth) + if isinstance(value, dict): + return dict((k, recursive(v)) for k, v in six.iteritems(value)) + elif isinstance(value, (list, tuple)): + return [recursive(lv) for lv in value] + + # It's not clear why xmlrpclib created their own DateTime type, but + # for our purposes, make it a datetime type which is explicitly + # handled + if isinstance(value, xmlrpclib.DateTime): + value = datetime.datetime(*tuple(value.timetuple())[:6]) + + if convert_datetime and isinstance(value, datetime.datetime): + return timeutils.strtime(value) + elif isinstance(value, gettextutils.Message): + return value.data + elif hasattr(value, 'iteritems'): + return recursive(dict(value.iteritems()), level=level + 1) + elif hasattr(value, '__iter__'): + return recursive(list(value)) + elif convert_instances and hasattr(value, '__dict__'): + # Likely an instance of something. Watch for cycles. + # Ignore class member vars. + return recursive(value.__dict__, level=level + 1) + elif netaddr and isinstance(value, netaddr.IPAddress): + return six.text_type(value) + else: + if any(test(value) for test in _nasty_type_tests): + return six.text_type(value) + return value + except TypeError: + # Class objects are tricky since they may define something like + # __iter__ defined but it isn't callable as list(). + return six.text_type(value) + + +def dumps(value, default=to_primitive, **kwargs): + return json.dumps(value, default=default, **kwargs) + + +def loads(s): + return json.loads(s) + + +def load(s): + return json.load(s) + + +try: + import anyjson +except ImportError: + pass +else: + anyjson._modules.append((__name__, 'dumps', TypeError, + 'loads', ValueError, 'load')) + anyjson.force_implementation(__name__) diff --git a/radar/openstack/common/local.py b/radar/openstack/common/local.py new file mode 100644 index 0000000..0819d5b --- /dev/null +++ b/radar/openstack/common/local.py @@ -0,0 +1,45 @@ +# Copyright 2011 OpenStack Foundation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Local storage of variables using weak references""" + +import threading +import weakref + + +class WeakLocal(threading.local): + def __getattribute__(self, attr): + rval = super(WeakLocal, self).__getattribute__(attr) + if rval: + # NOTE(mikal): this bit is confusing. What is stored is a weak + # reference, not the value itself. We therefore need to lookup + # the weak reference and return the inner value here. + rval = rval() + return rval + + def __setattr__(self, attr, value): + value = weakref.ref(value) + return super(WeakLocal, self).__setattr__(attr, value) + + +# NOTE(mikal): the name "store" should be deprecated in the future +store = WeakLocal() + +# A "weak" store uses weak references and allows an object to fall out of scope +# when it falls out of scope in the code that uses the thread local storage. A +# "strong" store will hold a reference to the object so that it never falls out +# of scope. +weak_store = WeakLocal() +strong_store = threading.local() diff --git a/radar/openstack/common/log.py b/radar/openstack/common/log.py new file mode 100644 index 0000000..1d900a3 --- /dev/null +++ b/radar/openstack/common/log.py @@ -0,0 +1,712 @@ +# Copyright 2011 OpenStack Foundation. +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""OpenStack logging handler. + +This module adds to logging functionality by adding the option to specify +a context object when calling the various log methods. If the context object +is not specified, default formatting is used. Additionally, an instance uuid +may be passed as part of the log message, which is intended to make it easier +for admins to find messages related to a specific instance. + +It also allows setting of formatting information through conf. + +""" + +import inspect +import itertools +import logging +import logging.config +import logging.handlers +import os +import re +import sys +import traceback + +from oslo.config import cfg +import six +from six import moves + +from radar.openstack.common.gettextutils import _ +from radar.openstack.common import importutils +from radar.openstack.common import jsonutils +from radar.openstack.common import local + + +_DEFAULT_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +_SANITIZE_KEYS = ['adminPass', 'admin_pass', 'password', 'admin_password'] + +# NOTE(ldbragst): Let's build a list of regex objects using the list of +# _SANITIZE_KEYS we already have. This way, we only have to add the new key +# to the list of _SANITIZE_KEYS and we can generate regular expressions +# for XML and JSON automatically. +_SANITIZE_PATTERNS = [] +_FORMAT_PATTERNS = [r'(%(key)s\s*[=]\s*[\"\']).*?([\"\'])', + r'(<%(key)s>).*?()', + r'([\"\']%(key)s[\"\']\s*:\s*[\"\']).*?([\"\'])', + r'([\'"].*?%(key)s[\'"]\s*:\s*u?[\'"]).*?([\'"])'] + +for key in _SANITIZE_KEYS: + for pattern in _FORMAT_PATTERNS: + reg_ex = re.compile(pattern % {'key': key}, re.DOTALL) + _SANITIZE_PATTERNS.append(reg_ex) + + +common_cli_opts = [ + cfg.BoolOpt('debug', + short='d', + default=False, + help='Print debugging output (set logging level to ' + 'DEBUG instead of default WARNING level).'), + cfg.BoolOpt('verbose', + short='v', + default=False, + help='Print more verbose output (set logging level to ' + 'INFO instead of default WARNING level).'), +] + +logging_cli_opts = [ + cfg.StrOpt('log-config-append', + metavar='PATH', + deprecated_name='log-config', + help='The name of logging configuration file. It does not ' + 'disable existing loggers, but just appends specified ' + 'logging configuration to any other existing logging ' + 'options. Please see the Python logging module ' + 'documentation for details on logging configuration ' + 'files.'), + cfg.StrOpt('log-format', + default=None, + metavar='FORMAT', + help='DEPRECATED. ' + 'A logging.Formatter log message format string which may ' + 'use any of the available logging.LogRecord attributes. ' + 'This option is deprecated. Please use ' + 'logging_context_format_string and ' + 'logging_default_format_string instead.'), + cfg.StrOpt('log-date-format', + default=_DEFAULT_LOG_DATE_FORMAT, + metavar='DATE_FORMAT', + help='Format string for %%(asctime)s in log records. ' + 'Default: %(default)s'), + cfg.StrOpt('log-file', + metavar='PATH', + deprecated_name='logfile', + help='(Optional) Name of log file to output to. ' + 'If no default is set, logging will go to stdout.'), + cfg.StrOpt('log-dir', + deprecated_name='logdir', + help='(Optional) The base directory used for relative ' + '--log-file paths'), + cfg.BoolOpt('use-syslog', + default=False, + help='Use syslog for logging. ' + 'Existing syslog format is DEPRECATED during I, ' + 'and then will be changed in J to honor RFC5424'), + cfg.BoolOpt('use-syslog-rfc-format', + # TODO(bogdando) remove or use True after existing + # syslog format deprecation in J + default=False, + help='(Optional) Use syslog rfc5424 format for logging. ' + 'If enabled, will add APP-NAME (RFC5424) before the ' + 'MSG part of the syslog message. The old format ' + 'without APP-NAME is deprecated in I, ' + 'and will be removed in J.'), + cfg.StrOpt('syslog-log-facility', + default='LOG_USER', + help='Syslog facility to receive log lines') +] + +generic_log_opts = [ + cfg.BoolOpt('use_stderr', + default=True, + help='Log output to standard error') +] + +log_opts = [ + cfg.StrOpt('logging_context_format_string', + default='%(asctime)s.%(msecs)03d %(process)d %(levelname)s ' + '%(name)s [%(request_id)s %(user_identity)s] ' + '%(instance)s%(message)s', + help='Format string to use for log messages with context'), + cfg.StrOpt('logging_default_format_string', + default='%(asctime)s.%(msecs)03d %(process)d %(levelname)s ' + '%(name)s [-] %(instance)s%(message)s', + help='Format string to use for log messages without context'), + cfg.StrOpt('logging_debug_format_suffix', + default='%(funcName)s %(pathname)s:%(lineno)d', + help='Data to append to log format when level is DEBUG'), + cfg.StrOpt('logging_exception_prefix', + default='%(asctime)s.%(msecs)03d %(process)d TRACE %(name)s ' + '%(instance)s', + help='Prefix each line of exception output with this format'), + cfg.ListOpt('default_log_levels', + default=[ + 'amqp=WARN', + 'amqplib=WARN', + 'boto=WARN', + 'qpid=WARN', + 'sqlalchemy=WARN', + 'suds=INFO', + 'iso8601=WARN', + 'requests.packages.urllib3.connectionpool=WARN' + ], + help='List of logger=LEVEL pairs'), + cfg.BoolOpt('publish_errors', + default=False, + help='Publish error events'), + cfg.BoolOpt('fatal_deprecations', + default=False, + help='Make deprecations fatal'), + + # NOTE(mikal): there are two options here because sometimes we are handed + # a full instance (and could include more information), and other times we + # are just handed a UUID for the instance. + cfg.StrOpt('instance_format', + default='[instance: %(uuid)s] ', + help='If an instance is passed with the log message, format ' + 'it like this'), + cfg.StrOpt('instance_uuid_format', + default='[instance: %(uuid)s] ', + help='If an instance UUID is passed with the log message, ' + 'format it like this'), +] + +CONF = cfg.CONF +CONF.register_cli_opts(common_cli_opts) +CONF.register_cli_opts(logging_cli_opts) +CONF.register_opts(generic_log_opts) +CONF.register_opts(log_opts) + +# our new audit level +# NOTE(jkoelker) Since we synthesized an audit level, make the logging +# module aware of it so it acts like other levels. +logging.AUDIT = logging.INFO + 1 +logging.addLevelName(logging.AUDIT, 'AUDIT') + + +try: + NullHandler = logging.NullHandler +except AttributeError: # NOTE(jkoelker) NullHandler added in Python 2.7 + class NullHandler(logging.Handler): + def handle(self, record): + pass + + def emit(self, record): + pass + + def createLock(self): + self.lock = None + + +def _dictify_context(context): + if context is None: + return None + if not isinstance(context, dict) and getattr(context, 'to_dict', None): + context = context.to_dict() + return context + + +def _get_binary_name(): + return os.path.basename(inspect.stack()[-1][1]) + + +def _get_log_file_path(binary=None): + logfile = CONF.log_file + logdir = CONF.log_dir + + if logfile and not logdir: + return logfile + + if logfile and logdir: + return os.path.join(logdir, logfile) + + if logdir: + binary = binary or _get_binary_name() + return '%s.log' % (os.path.join(logdir, binary),) + + return None + + +def mask_password(message, secret="***"): + """Replace password with 'secret' in message. + + :param message: The string which includes security information. + :param secret: value with which to replace passwords. + :returns: The unicode value of message with the password fields masked. + + For example: + + >>> mask_password("'adminPass' : 'aaaaa'") + "'adminPass' : '***'" + >>> mask_password("'admin_pass' : 'aaaaa'") + "'admin_pass' : '***'" + >>> mask_password('"password" : "aaaaa"') + '"password" : "***"' + >>> mask_password("'original_password' : 'aaaaa'") + "'original_password' : '***'" + >>> mask_password("u'original_password' : u'aaaaa'") + "u'original_password' : u'***'" + """ + message = six.text_type(message) + + # NOTE(ldbragst): Check to see if anything in message contains any key + # specified in _SANITIZE_KEYS, if not then just return the message since + # we don't have to mask any passwords. + if not any(key in message for key in _SANITIZE_KEYS): + return message + + secret = r'\g<1>' + secret + r'\g<2>' + for pattern in _SANITIZE_PATTERNS: + message = re.sub(pattern, secret, message) + return message + + +class BaseLoggerAdapter(logging.LoggerAdapter): + + def audit(self, msg, *args, **kwargs): + self.log(logging.AUDIT, msg, *args, **kwargs) + + +class LazyAdapter(BaseLoggerAdapter): + def __init__(self, name='unknown', version='unknown'): + self._logger = None + self.extra = {} + self.name = name + self.version = version + + @property + def logger(self): + if not self._logger: + self._logger = getLogger(self.name, self.version) + return self._logger + + +class ContextAdapter(BaseLoggerAdapter): + warn = logging.LoggerAdapter.warning + + def __init__(self, logger, project_name, version_string): + self.logger = logger + self.project = project_name + self.version = version_string + self._deprecated_messages_sent = dict() + + @property + def handlers(self): + return self.logger.handlers + + def deprecated(self, msg, *args, **kwargs): + """Call this method when a deprecated feature is used. + + If the system is configured for fatal deprecations then the message + is logged at the 'critical' level and :class:`DeprecatedConfig` will + be raised. + + Otherwise, the message will be logged (once) at the 'warn' level. + + :raises: :class:`DeprecatedConfig` if the system is configured for + fatal deprecations. + + """ + stdmsg = _("Deprecated: %s") % msg + if CONF.fatal_deprecations: + self.critical(stdmsg, *args, **kwargs) + raise DeprecatedConfig(msg=stdmsg) + + # Using a list because a tuple with dict can't be stored in a set. + sent_args = self._deprecated_messages_sent.setdefault(msg, list()) + + if args in sent_args: + # Already logged this message, so don't log it again. + return + + sent_args.append(args) + self.warn(stdmsg, *args, **kwargs) + + def process(self, msg, kwargs): + # NOTE(mrodden): catch any Message/other object and + # coerce to unicode before they can get + # to the python logging and possibly + # cause string encoding trouble + if not isinstance(msg, six.string_types): + msg = six.text_type(msg) + + if 'extra' not in kwargs: + kwargs['extra'] = {} + extra = kwargs['extra'] + + context = kwargs.pop('context', None) + if not context: + context = getattr(local.store, 'context', None) + if context: + extra.update(_dictify_context(context)) + + instance = kwargs.pop('instance', None) + instance_uuid = (extra.get('instance_uuid') or + kwargs.pop('instance_uuid', None)) + instance_extra = '' + if instance: + instance_extra = CONF.instance_format % instance + elif instance_uuid: + instance_extra = (CONF.instance_uuid_format + % {'uuid': instance_uuid}) + extra['instance'] = instance_extra + + extra.setdefault('user_identity', kwargs.pop('user_identity', None)) + + extra['project'] = self.project + extra['version'] = self.version + extra['extra'] = extra.copy() + return msg, kwargs + + +class JSONFormatter(logging.Formatter): + def __init__(self, fmt=None, datefmt=None): + # NOTE(jkoelker) we ignore the fmt argument, but its still there + # since logging.config.fileConfig passes it. + self.datefmt = datefmt + + def formatException(self, ei, strip_newlines=True): + lines = traceback.format_exception(*ei) + if strip_newlines: + lines = [moves.filter( + lambda x: x, + line.rstrip().splitlines()) for line in lines] + lines = list(itertools.chain(*lines)) + return lines + + def format(self, record): + message = {'message': record.getMessage(), + 'asctime': self.formatTime(record, self.datefmt), + 'name': record.name, + 'msg': record.msg, + 'args': record.args, + 'levelname': record.levelname, + 'levelno': record.levelno, + 'pathname': record.pathname, + 'filename': record.filename, + 'module': record.module, + 'lineno': record.lineno, + 'funcname': record.funcName, + 'created': record.created, + 'msecs': record.msecs, + 'relative_created': record.relativeCreated, + 'thread': record.thread, + 'thread_name': record.threadName, + 'process_name': record.processName, + 'process': record.process, + 'traceback': None} + + if hasattr(record, 'extra'): + message['extra'] = record.extra + + if record.exc_info: + message['traceback'] = self.formatException(record.exc_info) + + return jsonutils.dumps(message) + + +def _create_logging_excepthook(product_name): + def logging_excepthook(exc_type, value, tb): + extra = {} + if CONF.verbose or CONF.debug: + extra['exc_info'] = (exc_type, value, tb) + getLogger(product_name).critical( + "".join(traceback.format_exception_only(exc_type, value)), + **extra) + return logging_excepthook + + +class LogConfigError(Exception): + + message = _('Error loading logging config %(log_config)s: %(err_msg)s') + + def __init__(self, log_config, err_msg): + self.log_config = log_config + self.err_msg = err_msg + + def __str__(self): + return self.message % dict(log_config=self.log_config, + err_msg=self.err_msg) + + +def _load_log_config(log_config_append): + try: + logging.config.fileConfig(log_config_append, + disable_existing_loggers=False) + except moves.configparser.Error as exc: + raise LogConfigError(log_config_append, str(exc)) + + +def setup(product_name, version='unknown'): + """Setup logging.""" + if CONF.log_config_append: + _load_log_config(CONF.log_config_append) + else: + _setup_logging_from_conf(product_name, version) + sys.excepthook = _create_logging_excepthook(product_name) + + +def set_defaults(logging_context_format_string): + cfg.set_defaults(log_opts, + logging_context_format_string= + logging_context_format_string) + + +def _find_facility_from_conf(): + facility_names = logging.handlers.SysLogHandler.facility_names + facility = getattr(logging.handlers.SysLogHandler, + CONF.syslog_log_facility, + None) + + if facility is None and CONF.syslog_log_facility in facility_names: + facility = facility_names.get(CONF.syslog_log_facility) + + if facility is None: + valid_facilities = facility_names.keys() + consts = ['LOG_AUTH', 'LOG_AUTHPRIV', 'LOG_CRON', 'LOG_DAEMON', + 'LOG_FTP', 'LOG_KERN', 'LOG_LPR', 'LOG_MAIL', 'LOG_NEWS', + 'LOG_AUTH', 'LOG_SYSLOG', 'LOG_USER', 'LOG_UUCP', + 'LOG_LOCAL0', 'LOG_LOCAL1', 'LOG_LOCAL2', 'LOG_LOCAL3', + 'LOG_LOCAL4', 'LOG_LOCAL5', 'LOG_LOCAL6', 'LOG_LOCAL7'] + valid_facilities.extend(consts) + raise TypeError(_('syslog facility must be one of: %s') % + ', '.join("'%s'" % fac + for fac in valid_facilities)) + + return facility + + +class RFCSysLogHandler(logging.handlers.SysLogHandler): + def __init__(self, *args, **kwargs): + self.binary_name = _get_binary_name() + super(RFCSysLogHandler, self).__init__(*args, **kwargs) + + def format(self, record): + msg = super(RFCSysLogHandler, self).format(record) + msg = self.binary_name + ' ' + msg + return msg + + +def _setup_logging_from_conf(project, version): + log_root = getLogger(None).logger + for handler in log_root.handlers: + log_root.removeHandler(handler) + + if CONF.use_syslog: + facility = _find_facility_from_conf() + # TODO(bogdando) use the format provided by RFCSysLogHandler + # after existing syslog format deprecation in J + if CONF.use_syslog_rfc_format: + syslog = RFCSysLogHandler(address='/dev/log', + facility=facility) + else: + syslog = logging.handlers.SysLogHandler(address='/dev/log', + facility=facility) + log_root.addHandler(syslog) + + logpath = _get_log_file_path() + if logpath: + filelog = logging.handlers.WatchedFileHandler(logpath) + log_root.addHandler(filelog) + + if CONF.use_stderr: + streamlog = ColorHandler() + log_root.addHandler(streamlog) + + elif not logpath: + # pass sys.stdout as a positional argument + # python2.6 calls the argument strm, in 2.7 it's stream + streamlog = logging.StreamHandler(sys.stdout) + log_root.addHandler(streamlog) + + if CONF.publish_errors: + handler = importutils.import_object( + "radar.openstack.common.log_handler.PublishErrorsHandler", + logging.ERROR) + log_root.addHandler(handler) + + datefmt = CONF.log_date_format + for handler in log_root.handlers: + # NOTE(alaski): CONF.log_format overrides everything currently. This + # should be deprecated in favor of context aware formatting. + if CONF.log_format: + handler.setFormatter(logging.Formatter(fmt=CONF.log_format, + datefmt=datefmt)) + log_root.info('Deprecated: log_format is now deprecated and will ' + 'be removed in the next release') + else: + handler.setFormatter(ContextFormatter(project=project, + version=version, + datefmt=datefmt)) + + if CONF.debug: + log_root.setLevel(logging.DEBUG) + elif CONF.verbose: + log_root.setLevel(logging.INFO) + else: + log_root.setLevel(logging.WARNING) + + for pair in CONF.default_log_levels: + mod, _sep, level_name = pair.partition('=') + level = logging.getLevelName(level_name) + logger = logging.getLogger(mod) + logger.setLevel(level) + +_loggers = {} + + +def getLogger(name='unknown', version='unknown'): + if name not in _loggers: + _loggers[name] = ContextAdapter(logging.getLogger(name), + name, + version) + return _loggers[name] + + +def getLazyLogger(name='unknown', version='unknown'): + """Returns lazy logger. + + Creates a pass-through logger that does not create the real logger + until it is really needed and delegates all calls to the real logger + once it is created. + """ + return LazyAdapter(name, version) + + +class WritableLogger(object): + """A thin wrapper that responds to `write` and logs.""" + + def __init__(self, logger, level=logging.INFO): + self.logger = logger + self.level = level + + def write(self, msg): + self.logger.log(self.level, msg.rstrip()) + + +class ContextFormatter(logging.Formatter): + """A context.RequestContext aware formatter configured through flags. + + The flags used to set format strings are: logging_context_format_string + and logging_default_format_string. You can also specify + logging_debug_format_suffix to append extra formatting if the log level is + debug. + + For information about what variables are available for the formatter see: + http://docs.python.org/library/logging.html#formatter + + If available, uses the context value stored in TLS - local.store.context + + """ + + def __init__(self, *args, **kwargs): + """Initialize ContextFormatter instance + + Takes additional keyword arguments which can be used in the message + format string. + + :keyword project: project name + :type project: string + :keyword version: project version + :type version: string + + """ + + self.project = kwargs.pop('project', 'unknown') + self.version = kwargs.pop('version', 'unknown') + + logging.Formatter.__init__(self, *args, **kwargs) + + def format(self, record): + """Uses contextstring if request_id is set, otherwise default.""" + + # store project info + record.project = self.project + record.version = self.version + + # store request info + context = getattr(local.store, 'context', None) + if context: + d = _dictify_context(context) + for k, v in d.items(): + setattr(record, k, v) + + # NOTE(sdague): default the fancier formatting params + # to an empty string so we don't throw an exception if + # they get used + for key in ('instance', 'color'): + if key not in record.__dict__: + record.__dict__[key] = '' + + if record.__dict__.get('request_id'): + self._fmt = CONF.logging_context_format_string + else: + self._fmt = CONF.logging_default_format_string + + if (record.levelno == logging.DEBUG and + CONF.logging_debug_format_suffix): + self._fmt += " " + CONF.logging_debug_format_suffix + + # Cache this on the record, Logger will respect our formatted copy + if record.exc_info: + record.exc_text = self.formatException(record.exc_info, record) + return logging.Formatter.format(self, record) + + def formatException(self, exc_info, record=None): + """Format exception output with CONF.logging_exception_prefix.""" + if not record: + return logging.Formatter.formatException(self, exc_info) + + stringbuffer = moves.StringIO() + traceback.print_exception(exc_info[0], exc_info[1], exc_info[2], + None, stringbuffer) + lines = stringbuffer.getvalue().split('\n') + stringbuffer.close() + + if CONF.logging_exception_prefix.find('%(asctime)') != -1: + record.asctime = self.formatTime(record, self.datefmt) + + formatted_lines = [] + for line in lines: + pl = CONF.logging_exception_prefix % record.__dict__ + fl = '%s%s' % (pl, line) + formatted_lines.append(fl) + return '\n'.join(formatted_lines) + + +class ColorHandler(logging.StreamHandler): + LEVEL_COLORS = { + logging.DEBUG: '\033[00;32m', # GREEN + logging.INFO: '\033[00;36m', # CYAN + logging.AUDIT: '\033[01;36m', # BOLD CYAN + logging.WARN: '\033[01;33m', # BOLD YELLOW + logging.ERROR: '\033[01;31m', # BOLD RED + logging.CRITICAL: '\033[01;31m', # BOLD RED + } + + def format(self, record): + record.color = self.LEVEL_COLORS[record.levelno] + return logging.StreamHandler.format(self, record) + + +class DeprecatedConfig(Exception): + message = _("Fatal call to deprecated config: %(msg)s") + + def __init__(self, msg): + super(Exception, self).__init__(self.message % dict(msg=msg)) diff --git a/radar/openstack/common/timeutils.py b/radar/openstack/common/timeutils.py new file mode 100644 index 0000000..52688a0 --- /dev/null +++ b/radar/openstack/common/timeutils.py @@ -0,0 +1,210 @@ +# Copyright 2011 OpenStack Foundation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Time related utilities and helper functions. +""" + +import calendar +import datetime +import time + +import iso8601 +import six + + +# ISO 8601 extended time format with microseconds +_ISO8601_TIME_FORMAT_SUBSECOND = '%Y-%m-%dT%H:%M:%S.%f' +_ISO8601_TIME_FORMAT = '%Y-%m-%dT%H:%M:%S' +PERFECT_TIME_FORMAT = _ISO8601_TIME_FORMAT_SUBSECOND + + +def isotime(at=None, subsecond=False): + """Stringify time in ISO 8601 format.""" + if not at: + at = utcnow() + st = at.strftime(_ISO8601_TIME_FORMAT + if not subsecond + else _ISO8601_TIME_FORMAT_SUBSECOND) + tz = at.tzinfo.tzname(None) if at.tzinfo else 'UTC' + st += ('Z' if tz == 'UTC' else tz) + return st + + +def parse_isotime(timestr): + """Parse time from ISO 8601 format.""" + try: + return iso8601.parse_date(timestr) + except iso8601.ParseError as e: + raise ValueError(six.text_type(e)) + except TypeError as e: + raise ValueError(six.text_type(e)) + + +def strtime(at=None, fmt=PERFECT_TIME_FORMAT): + """Returns formatted utcnow.""" + if not at: + at = utcnow() + return at.strftime(fmt) + + +def parse_strtime(timestr, fmt=PERFECT_TIME_FORMAT): + """Turn a formatted time back into a datetime.""" + return datetime.datetime.strptime(timestr, fmt) + + +def normalize_time(timestamp): + """Normalize time in arbitrary timezone to UTC naive object.""" + offset = timestamp.utcoffset() + if offset is None: + return timestamp + return timestamp.replace(tzinfo=None) - offset + + +def is_older_than(before, seconds): + """Return True if before is older than seconds.""" + if isinstance(before, six.string_types): + before = parse_strtime(before).replace(tzinfo=None) + else: + before = before.replace(tzinfo=None) + + return utcnow() - before > datetime.timedelta(seconds=seconds) + + +def is_newer_than(after, seconds): + """Return True if after is newer than seconds.""" + if isinstance(after, six.string_types): + after = parse_strtime(after).replace(tzinfo=None) + else: + after = after.replace(tzinfo=None) + + return after - utcnow() > datetime.timedelta(seconds=seconds) + + +def utcnow_ts(): + """Timestamp version of our utcnow function.""" + if utcnow.override_time is None: + # NOTE(kgriffs): This is several times faster + # than going through calendar.timegm(...) + return int(time.time()) + + return calendar.timegm(utcnow().timetuple()) + + +def utcnow(): + """Overridable version of utils.utcnow.""" + if utcnow.override_time: + try: + return utcnow.override_time.pop(0) + except AttributeError: + return utcnow.override_time + return datetime.datetime.utcnow() + + +def iso8601_from_timestamp(timestamp): + """Returns a iso8601 formatted date from timestamp.""" + return isotime(datetime.datetime.utcfromtimestamp(timestamp)) + + +utcnow.override_time = None + + +def set_time_override(override_time=None): + """Overrides utils.utcnow. + + Make it return a constant time or a list thereof, one at a time. + + :param override_time: datetime instance or list thereof. If not + given, defaults to the current UTC time. + """ + utcnow.override_time = override_time or datetime.datetime.utcnow() + + +def advance_time_delta(timedelta): + """Advance overridden time using a datetime.timedelta.""" + assert(not utcnow.override_time is None) + try: + for dt in utcnow.override_time: + dt += timedelta + except TypeError: + utcnow.override_time += timedelta + + +def advance_time_seconds(seconds): + """Advance overridden time by seconds.""" + advance_time_delta(datetime.timedelta(0, seconds)) + + +def clear_time_override(): + """Remove the overridden time.""" + utcnow.override_time = None + + +def marshall_now(now=None): + """Make an rpc-safe datetime with microseconds. + + Note: tzinfo is stripped, but not required for relative times. + """ + if not now: + now = utcnow() + return dict(day=now.day, month=now.month, year=now.year, hour=now.hour, + minute=now.minute, second=now.second, + microsecond=now.microsecond) + + +def unmarshall_time(tyme): + """Unmarshall a datetime dict.""" + return datetime.datetime(day=tyme['day'], + month=tyme['month'], + year=tyme['year'], + hour=tyme['hour'], + minute=tyme['minute'], + second=tyme['second'], + microsecond=tyme['microsecond']) + + +def delta_seconds(before, after): + """Return the difference between two timing objects. + + Compute the difference in seconds between two date, time, or + datetime objects (as a float, to microsecond resolution). + """ + delta = after - before + return total_seconds(delta) + + +def total_seconds(delta): + """Return the total seconds of datetime.timedelta object. + + Compute total seconds of datetime.timedelta, datetime.timedelta + doesn't have method total_seconds in Python2.6, calculate it manually. + """ + try: + return delta.total_seconds() + except AttributeError: + return ((delta.days * 24 * 3600) + delta.seconds + + float(delta.microseconds) / (10 ** 6)) + + +def is_soon(dt, window): + """Determines if time is going to happen in the next window seconds. + + :param dt: the time + :param window: minimum seconds to remain to consider the time not soon + + :return: True if expiration is within the given duration + """ + soon = (utcnow() + datetime.timedelta(seconds=window)) + return normalize_time(dt) <= soon diff --git a/radar/plugin/__init__.py b/radar/plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/plugin/base.py b/radar/plugin/base.py new file mode 100644 index 0000000..738dc28 --- /dev/null +++ b/radar/plugin/base.py @@ -0,0 +1,67 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and +# limitations under the License. + +import abc +import six + +from oslo.config import cfg +from stevedore.enabled import EnabledExtensionManager + + +CONF = cfg.CONF + + +def is_enabled(ext): + """Check to see whether a plugin should be enabled. Assumes that the + plugin extends PluginBase. + + :param ext: The extension instance to check. + :return: True if it should be enabled. Otherwise false. + """ + return ext.obj.enabled() + + +@six.add_metaclass(abc.ABCMeta) +class PluginBase(object): + """Base class for all radar plugins. + + Every radar plugin will be provided an instance of the application + configuration, and will then be asked whether it should be enabled. Each + plugin should decide, given the configuration and the environment, + whether it has the necessary resources to operate properly. + """ + + def __init__(self, config): + self.config = config + + @abc.abstractmethod + def enabled(self): + """A method which indicates whether this plugin is properly + configured and should be enabled. If it's ready to go, return True. + Otherwise, return False. + """ + + +class RadarPluginLoader(EnabledExtensionManager): + """The radar plugin loader, a stevedore abstraction that formalizes + our plugin contract. + """ + + def __init__(self, namespace, on_load_failure_callback=None): + super(RadarPluginLoader, self) \ + .__init__(namespace=namespace, + check_func=is_enabled, + invoke_on_load=True, + invoke_args=(CONF,), + on_load_failure_callback=on_load_failure_callback) diff --git a/radar/plugin/user_preferences.py b/radar/plugin/user_preferences.py new file mode 100644 index 0000000..a94be67 --- /dev/null +++ b/radar/plugin/user_preferences.py @@ -0,0 +1,68 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and +# limitations under the License. + +import abc + +import six + +from radar.openstack.common import log +from radar.plugin.base import PluginBase +from radar.plugin.base import RadarPluginLoader + + +LOG = log.getLogger(__name__) +PREFERENCE_DEFAULTS = dict() + + +def initialize_user_preferences(): + """Initialize any plugins that were installed via pip. This will parse + out all the default preference values into one dictionary for later + use in the API. + """ + manager = RadarPluginLoader( + namespace='radar.plugin.user_preferences') + + if manager.extensions: + manager.map(load_preferences, PREFERENCE_DEFAULTS) + + +def load_preferences(ext, defaults): + """Load all plugin default preferences into our cache. + + :param ext: The extension that's handling this event. + :param defaults: The current dict of default preferences. + """ + + plugin_defaults = ext.obj.get_default_preferences() + + for key in plugin_defaults: + if key in defaults: + # Let's not error out here. + LOG.error("Duplicate preference key %s found." % (key,)) + else: + defaults[key] = plugin_defaults[key] + + +@six.add_metaclass(abc.ABCMeta) +class UserPreferencesPluginBase(PluginBase): + """Base class for a plugin that provides a set of expected user + preferences and their default values. By extending this plugin, you can + add preferences for your own radar plugins and workers, and have + them be manageable via your web client (Your client may need to be + customized). + """ + + @abc.abstractmethod + def get_default_preferences(self): + """Return a dictionary of preferences and their default values.""" diff --git a/radar/tasks/__init__.py b/radar/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/tasks/update_systems.py b/radar/tasks/update_systems.py new file mode 100644 index 0000000..d69b157 --- /dev/null +++ b/radar/tasks/update_systems.py @@ -0,0 +1,54 @@ +# Copyright (c) 2014 Triniplex. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time + +from oslo.config import cfg +from pika.exceptions import ConnectionClosed +from stevedore import enabled + +from radar.openstack.common import log +from radar.worker.task.process_update import ProcessCISystems + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +def update(): + log.setup('radar') + CONF(project='radar') + + updater = Updater() + updater.start() + + while updater.started: + LOG.info("processing systems") + updater.systems.do_update() + LOG.info("done processing systems. Sleeping for 5 minutes.") + + time.sleep(300) + continue + +class Updater(): + def __init__(self): + self.started = False + self.systems = ProcessCISystems() + + def start(self): + self.started = True + + def stop(self): + self.started = False diff --git a/radar/worker/__init__.py b/radar/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/worker/daemon.py b/radar/worker/daemon.py new file mode 100644 index 0000000..f74ad95 --- /dev/null +++ b/radar/worker/daemon.py @@ -0,0 +1,148 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and +# limitations under the License. + +import signal + +from multiprocessing import Process +from threading import Timer + +from oslo.config import cfg +from radar.tasks.update_systems import update +from radar.openstack.common import log + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) +MANAGER = None + +IMPORT_OPTS = [ + cfg.IntOpt("worker-count", + default="1", + help="The number of workers to spawn and manage.") +] + + +def run(): + """Start the daemon manager. + """ + global MANAGER + + log.setup('radar') + CONF.register_cli_opts(IMPORT_OPTS) + CONF(project='radar') + + signal.signal(signal.SIGTERM, terminate) + signal.signal(signal.SIGINT, terminate) + + MANAGER = DaemonManager(daemon_method=update, + child_process_count=CONF.worker_count) + MANAGER.start() + + +def terminate(signal, frame): + # This assumes that all the child processes will terminate gracefully + # on a SIGINT + global MANAGER + MANAGER.stop() + + # Raise SIGINT to all child processes. + signal.default_int_handler() + + +class DaemonManager(): + """A Daemon manager to handle multiple subprocesses. + """ + def __init__(self, child_process_count, daemon_method): + """Create a new daemon manager with N processes running the passed + method. Once start() is called, The daemon method will be spawned N + times and continually checked/restarted until the process is + interrupted either by a system exit or keyboard interrupt. + + :param child_process_count: The number of child processes to spawn. + :param daemon_method: The method to run in the child process. + """ + + # Number of child procs. + self._child_process_count = child_process_count + + # Process management threads. + self._procs = list() + + # Save the daemon method + self._daemon_method = daemon_method + + # Health check timer + self._timer = PerpetualTimer(1, self._health_check) + + def _health_check(self): + processes = list(self._procs) + dead_processes = 0 + + for process in processes: + if not process.is_alive(): + LOG.warning("Dead Process found [exit code:%d]" % + (process.exitcode,)) + dead_processes += 1 + self._procs.remove(process) + + for i in range(dead_processes): + self._add_process() + + def start(self): + """Start the daemon manager and spawn child processes. + """ + LOG.info("Spawning %s child processes" % (self._child_process_count,)) + self._timer.start() + for i in range(self._child_process_count): + self._add_process() + + def stop(self): + self._timer.cancel() + + processes = list(self._procs) + for process in processes: + if process.is_alive(): + process.terminate() + process.join() + self._procs.remove(process) + + def _add_process(self): + process = Process(target=self._daemon_method) + process.start() + self._procs.append(process) + + +class PerpetualTimer(): + """A timer wrapper class that repeats itself. + """ + + def __init__(self, t, handler): + self.t = t + self.handler = handler + self.thread = Timer(self.t, self.handle_function) + + def handle_function(self): + self.handler() + self.thread = Timer(self.t, self.handle_function) + self.thread.setDaemon(True) + self.thread.start() + + def start(self): + self.thread.start() + + def cancel(self): + self.thread.cancel() + +if __name__ == "__main__": + run() diff --git a/radar/worker/task/__init__.py b/radar/worker/task/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radar/worker/task/base.py b/radar/worker/task/base.py new file mode 100644 index 0000000..a5cd816 --- /dev/null +++ b/radar/worker/task/base.py @@ -0,0 +1,37 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and +# limitations under the License. + +import abc + + +class WorkerTaskBase(object): + """Base class for a worker that listens to events that occur within the + API. + """ + + __metaclass__ = abc.ABCMeta + + def __init__(self, config): + self.config = config + + @abc.abstractmethod + def enabled(self): + """A method which indicates whether this worker task is properly + configured and should be enabled. If it's ready to go, return True. + Otherwise, return False. + """ + + @abc.abstractmethod + def handle(self, body): + """Handle an event.""" diff --git a/radar/worker/task/process_update.py b/radar/worker/task/process_update.py new file mode 100644 index 0000000..e51988a --- /dev/null +++ b/radar/worker/task/process_update.py @@ -0,0 +1,198 @@ +import sys +import os +import urllib2 +import httplib +import json +import pprint +import re +import requests +from urllib import urlencode +from datetime import datetime + +from ConfigParser import ConfigParser + + +class ProcessCISystems(): + + def __init__(self): + self._requests = list() + self._responses = list() + self._uri = "https://review.openstack.org" + self._pp = pprint.PrettyPrinter(indent=4) + self._systems_resource = '/systems' + self._operators_resource = '/operators' + self.default_headers = {} + + def get_credentials(self): + dashboardconfig = ConfigParser() + dashboardconfig.readfp(open("/opt/.dashboardconfig")) + username = dashboardconfig.get("review", "user") + password = dashboardconfig.get("review", "password") + + passwordmgr = urllib2.HTTPPasswordMgrWithDefaultRealm() + passwordmgr.add_password(None, self._uri, username, password) + handler = urllib2.HTTPDigestAuthHandler(passwordmgr) + opener = urllib2.build_opener(handler) + urllib2.install_opener(opener) + + # Make the request + self._requests.append(urllib2.Request("%s/a/groups/95d633d37a5d6b06df758e57b1370705ec071a57/members/" % (self._uri))) + self._responses.append(urllib2.urlopen(self._requests[0])) + + def process_systems(self): + processed=0 + for index, line in enumerate(self._responses[0]): + if index > 0: + if line.find("account_id") > -1: + cis_account_id = line[line.rfind(":")+2:line.rfind(",")] + if line.find("\"name\"") > -1: + cis_system_name = line[line.rfind(":")+3:line.rfind(",")-1] + if line.find("\"email\"") > -1: + cis_operator_email = line[line.rfind(":")+3:line.rfind(",")-1] + if line.find("\"username\"") > -1: + cis_operator_username = line[line.rfind(":")+3:line.rfind(",")-1] + print "username: %s" % cis_operator_username + system = {"name": cis_system_name} + print "Attempting to import %s" % cis_system_name + if not self.system_exists(self._systems_resource, system): + self._responses.append(self.post_json(self._systems_resource, system)) + thesystem = json.loads(self._responses[1].text) + success=True + system_id='' + try: + system_id = thesystem['id'] + url = "%s/%d" % (self._systems_resource, system_id) + thesystem = self.get_json(url) + except KeyError as ke: + print "System %s has already been imported" % cis_system_name + success=False + finally: + self._responses.remove(self._responses[1]) + if success: + operator = {"system_id": system_id, + "operator_name": cis_operator_username, + "operator_email": cis_operator_email} + print 'updating operator: %s' % operator + self._responses.append(self.post_json(self._operators_resource, operator)) + theoperator = json.loads(self._responses[1].text) + try: + operator_id = theoperator['id'] + url = "%s/%d" % (self._operators_resource, operator_id) + theoperator = self.get_json(url) + except KeyError as ke: + pass + finally: + self._responses.remove(self._responses[1]) + else: + system_id='' + success=True + try: + response = self.get_json(self._systems_resource + "/" + cis_system_name) + thesystem = json.loads(response.text) + system_id = thesystem['id'] + except KeyError as ke: + print "Unable to retrieve system %s" % cis_system_name + success=False + if success: + print "put request for system_id: %s and system %s" % (system_id, cis_system_name) + system = { "system_id": system_id, + "name": cis_system_name, + "updated_at": datetime.utcnow().isoformat()} + self._responses.append(self.put_json(self._systems_resource, system)) + thesystem = json.loads(self._responses[1].text) + success=True + system_id='' + try: + system_id = thesystem['id'] + url = "%s/%d" % (self._systems_resource, system_id) + thesystem = self.get_json(url) + except KeyError as ke: + print "System %s update failed" % cis_system_name + success=False + finally: + self._responses.remove(self._responses[1]) + if success: + print "system %s updated successfully" % system_id + success=True + operator_id='' + try: + response = self.get_json(self._operators_resource + "/" + cis_operator_username) + theoperator = json.loads(response.text) + operator_id = theoperator['id'] + except KeyError as ke: + print "Unable to retrieve operator %s" % cis_operator_username + success=False + if success: + success=True + operator = {"operator_id": operator_id, + "operator_name": cis_operator_username, + "operator_email": cis_operator_email, + "updated_at": datetime.utcnow().isoformat()} + self._responses.append(self.put_json(self._operators_resource, operator)) + theoperator = json.loads(self._responses[1].text) + try: + operator_id = theoperator['id'] + operator_name = theoperator['operator_name'] + url = "%s/%d" % (self._operators_resource, operator_id) + response = self.get_json(url) + except KeyError as ke: + success=False + finally: + self._responses.remove(self._responses[1]) + + if success: + theoperator = json.loads(response.text) + print "operator %s was updated successfully" % theoperator['operator_name'] + + def _request_json(self, path, params, headers=None, method="post", + status=None, path_prefix="http://10.211.55.29:8080/v1"): + + merged_headers = self.default_headers.copy() + if headers: + merged_headers.update(headers) + + full_path = path_prefix + path + if not headers: + headers = {'content-type': 'application/json'} + if method is "post": + response = requests.post(str(full_path), data=json.dumps(params), headers=headers) + elif method is "put": + response = requests.put(str(full_path), data=json.dumps(params), headers=headers) + else: + response = requests.get(str(full_path)) + + return response + + + def put_json(self, path, params, headers=None, status=None): + return self._request_json(path=path, params=params, headers=headers, + status=status, method="put") + + def post_json(self, path, params, headers=None, status=None): + return self._request_json(path=path, params=params, + headers=headers, + status=status, method="post") + + def get_json(self, path, headers=None, status=None): + return self._request_json(path=path, params=None, + headers=headers, + status=status, method="get") + def system_exists(self, path, system, headers=None, status=None): + try: + response = self.get_json(path + "/" + system['name']) + if response.status_code == requests.codes.ok: + print "%s has already been imported" % system['name'] + return True + else: + return False + except KeyError as ke: + return False + + def do_update(self): + self.__init__() + self.get_credentials() + self.process_systems() + +if __name__ == "__main__": + process = ProcessCISystems() + process.do_update() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5a219e0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +pbr>=0.6,!=0.7,<1.0 +argparse +alembic>=0.4.1 +Babel>=1.3 +iso8601>=0.1.9 +oauthlib>=0.6 +oslo.config>=1.2.1 +pecan>=0.4.5 +oslo.db>=0.2.0 +pika>=0.9.14 +python-openid +PyYAML>=3.1.0 +requests>=1.1 +six>=1.7.0 +SQLAlchemy>=0.8,<=0.8.99 +WSME>=0.6 +sqlalchemy-migrate>=0.8.2,!=0.8.4 +SQLAlchemy-FullText-Search +eventlet>=0.13.0 +stevedore>=1.0.0 +python-crontab>=1.8.1 +tzlocal>=1.1.2 \ No newline at end of file diff --git a/analyse.py b/scripts/analyse.py similarity index 100% rename from analyse.py rename to scripts/analyse.py diff --git a/conf.py b/scripts/conf.py similarity index 100% rename from conf.py rename to scripts/conf.py diff --git a/feedutils.py b/scripts/feedutils.py similarity index 100% rename from feedutils.py rename to scripts/feedutils.py diff --git a/mirror_fetchers.py b/scripts/mirror_fetchers.py similarity index 100% rename from mirror_fetchers.py rename to scripts/mirror_fetchers.py diff --git a/report.py b/scripts/report.py similarity index 100% rename from report.py rename to scripts/report.py diff --git a/reports/reviewsummary.html b/scripts/reports/reviewsummary.html similarity index 100% rename from reports/reviewsummary.html rename to scripts/reports/reviewsummary.html diff --git a/reports/reviewsummaryfeed.py b/scripts/reports/reviewsummaryfeed.py similarity index 100% rename from reports/reviewsummaryfeed.py rename to scripts/reports/reviewsummaryfeed.py diff --git a/scrapereviews.py b/scripts/scrapereviews.py similarity index 100% rename from scrapereviews.py rename to scripts/scrapereviews.py diff --git a/sql.py b/scripts/sql.py similarity index 100% rename from sql.py rename to scripts/sql.py diff --git a/templates/report.pt b/scripts/templates/report.pt similarity index 100% rename from templates/report.pt rename to scripts/templates/report.pt diff --git a/utility.py b/scripts/utility.py similarity index 100% rename from utility.py rename to scripts/utility.py diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e965408 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,37 @@ +[metadata] +name = radar +summary = OpenStack Third Party Dashboard +description-file = + README.rst +author = OpenStack +author-email = openstack-dev@lists.openstack.org +home-page = http://www.openstack.org/ +classifier = + Environment :: OpenStack + Framework :: Pecan/WSME + Intended Audience :: Developers + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: Apache Software License + Operating System :: OS Independent + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.3 + Topic :: Internet :: WWW/HTTP + +[files] +packages = + radar +data_files = + etc/radar = + etc/radar.conf.sample + +[entry_points] +console_scripts = + radar-api = radar.api.app:start + radar-db-manage = radar.db.migration.cli:main + radar-update-daemon = radar.worker.daemon:run + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..70c2b3f --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT +import setuptools + +setuptools.setup( + setup_requires=['pbr'], + pbr=True) diff --git a/webclient/Gruntfile.js b/webclient/Gruntfile.js new file mode 100644 index 0000000..f170820 --- /dev/null +++ b/webclient/Gruntfile.js @@ -0,0 +1,204 @@ +var proxySnippet = require('grunt-connect-proxy/lib/utils').proxyRequest; +var config = { + livereload: { + port: 35729 + } + }; +var lrSnippet = require('connect-livereload')(config.livereload); +module.exports = function(grunt) { + var mountFolder = function (connect, dir) { + 'use strict'; + return connect.static(require('path').resolve(dir)); + }; + var proxySnippet = require('grunt-connect-proxy/lib/utils').proxyRequest; + var dir = { + source: './src', + theme: './src/theme', + test: './test', + build: './build', + report: './reports', + bower: './bower_components' + }; + + var proxies = { + localhost: { + context: '/api/v1', + host: '0.0.0.0', + port: 8080, + https: false, + rewrite: { + '^/api/v1': '/v1' + } + } + }; + + grunt.initConfig({ + pkg: grunt.file.readJSON('package.json'), + + concat: { + dist: { + src: [ + dir.source + '/app/**/module.js', + dir.source + '/app/**/*.js' + ], + dest: dir.build + '/js/dashboard.js' + } + }, + copy: { + build: { + files: [ + { + expand: true, + dot: true, + cwd: dir.source, + dest: dir.build, + src: [ + '*.html', + 'robots.txt', + 'config.json' + ] + }, + { + expand: true, + dot: true, + cwd: dir.source + '/theme', + dest: dir.build, + src: [ + '**/*.{txt,eot,ttf,woff}' + ] + }, + { + expand: true, + dot: true, + cwd: dir.source + '/theme/js', + dest: dir.build + '/js', + src: [ + 'jquery.js' + ] + } + ] + }, + publish: { + files: [ + { + expand: true, + dot: true, + cwd: dir.build, + dest: dir.publish, + src: [ + '**/*.*' + ] + } + ] + } + }, + html2js: { + options: { + module: 'db.templates', + base: dir.source + }, + main: { + src: [dir.source + '/app/*/template/**/**.html'], + dest: dir.build + '/js/templates.js' + } + }, + + cssmin: { + build: { + options: { + report: "min" + }, + src: [dir.source + '/theme/css/**/*.css'], + dest: dir.build + '/styles/main.css', + } + }, + + useminPrepare: { + html: [dir.source + '/index.html'], + options: { + dest: dir.build, + flow: { + steps: { + 'js': ['concat'], + }, + post: [] + } + } + + }, + usemin: { + html: [ + dir.build + '/index.html' + ], + options: { + dirs: [dir.output] + } + }, + connect: { + options: { + hostname: '0.0.0.0' + }, + livereload: { + options: { + port: 9000, + middleware: function (connect) { + return [ + lrSnippet, + mountFolder(connect, dir.build), + proxySnippet + ]; + } + }, + proxies: [proxies.localhost] + }, + dist: { + options: { + port: 9000, + keepalive: true, + middleware: function (connect) { + return [ + mountFolder(connect, dir.build), + proxySnippet + ]; + } + }, + proxies: [proxies.localhost] + } + }, + open: { + server: { + url: 'http://0.0.0.0:9000' + } + }, + watch: { + livereload: { + options: { + livereload: config.livereload.port + }, + files: [ + dir.source + '/**/*.*' + ] + } + } + + }); + grunt.loadNpmTasks('grunt-contrib-uglify'); + grunt.loadNpmTasks('grunt-angular-templates'); + grunt.loadNpmTasks('grunt-html2js'); + grunt.loadNpmTasks('grunt-contrib-cssmin'); + grunt.loadNpmTasks('grunt-usemin'); + grunt.loadNpmTasks('grunt-contrib-concat'); + grunt.loadNpmTasks('grunt-contrib-copy'); + grunt.loadNpmTasks('grunt-contrib-connect'); + grunt.loadNpmTasks('grunt-connect-proxy'); + grunt.loadNpmTasks('grunt-open'); + grunt.loadNpmTasks('grunt-contrib-watch'); + grunt.registerTask('default', ['html2js','cssmin','useminPrepare','concat','copy','usemin','copy:publish','serve']); + grunt.registerTask('publish', ['copy']); + grunt.registerTask('serve', function (target) { + grunt.task.run([ +'configureProxies:livereload', +'connect:livereload', +'open', 'watch']); + }); +}; diff --git a/webclient/bower.json b/webclient/bower.json new file mode 100644 index 0000000..ca8e371 --- /dev/null +++ b/webclient/bower.json @@ -0,0 +1,40 @@ +{ + "name": "dashboard", + "version": "0.0.0", + "homepage": "http://dashboard.triniplex.com", + "authors": [ + "Steven Weston " + ], + "description": "Third Party Dashboard Web Client", + "moduleType": [ + "node" + ], + "license": "MIT", + "private": true, + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ], + "devDependencies": { + "angular-resource": "~1.3.8", + "angular": "~1.3.7", + "angular-bootstrap": "~0.12.0", + "angular-ui-router": "~0.2.13", + "angular-local-storage": "~0.1.5", + "angular-cache": "~3.2.4", + "moment": "~2.8.4", + "angular-moment": "~0.8.3", + "angular-datatables": "~0.3.0", + "at-table": "~1.0.4", + "grunt-contrib-connect": "~0.9.0", + "ng-table": "~0.3.3", + "angular-x2js": "~0.1.1" + }, + "dependencies": { + "angular-bootstrap": "~0.12.0", + "angular-data": "~1.5.3" + } +} diff --git a/webclient/index.html b/webclient/index.html new file mode 100644 index 0000000..6e28fb6 --- /dev/null +++ b/webclient/index.html @@ -0,0 +1,31 @@ + + + + + + + + + Radar Third Party Dashboard + + + + + + + + + + + + + + + + +
+
+ + + + diff --git a/webclient/package.json b/webclient/package.json new file mode 100644 index 0000000..2b0b7d8 --- /dev/null +++ b/webclient/package.json @@ -0,0 +1,66 @@ +{ + "name": "radar-webclient", + "version": "1.0.0", + "description": "An all Javascript Web Client for the Radar API", + "main": "Gruntfile.js", + "dependencies": { + "bower": "^1.3.12", + "connect-livereload": "^0.4.0", + "gifsicle": "^1.0.0", + "grunt": "^0.4.5", + "grunt-cli": "^0.1.13", + "grunt-connect-proxy": "^0.1.11", + "grunt-contrib-concat": "^0.5.0", + "grunt-contrib-connect": "^0.8.0", + "grunt-contrib-clean": "^0.6.0", + "grunt-contrib-copy": "^0.6.0", + "grunt-contrib-cssmin": "^0.10.0", + "grunt-contrib-htmlmin": "^0.3.0", + "grunt-contrib-imagemin": "^0.8.1", + "grunt-contrib-jshint": "^0.10.0", + "grunt-contrib-less": "^0.11.4", + "grunt-contrib-uglify": "^0.6.0", + "grunt-contrib-watch": "^0.6.1", + "grunt-env": "^0.4.1", + "grunt-html2js": "^0.2.9", + "grunt-karma": "^0.9.0", + "grunt-open": "^0.2.3", + "grunt-protractor-runner": "^1.1.4", + "grunt-usemin": "^2.4.0", + "grunt-shell": "^1.1.1", + "grunt-webfont": "^0.4.8", + "karma": "^0.12.23", + "karma-coffee-preprocessor": "^0.2.1", + "karma-coverage": "^0.2.6", + "karma-chrome-launcher": "^0.1.5", + "karma-firefox-launcher": "^0.1.3", + "karma-html-reporter": "^0.2.4", + "karma-html2js-preprocessor": "^0.1.0", + "karma-jasmine": "^0.2.2", + "karma-phantomjs-launcher": "^0.1.4", + "karma-requirejs": "^0.2.2", + "karma-script-launcher": "^0.1.0", + "matchdep": "^0.3.0", + "protractor": "^1.3.1", + "requirejs": "^2.1.15", + "selenium-standalone": "^2.43.1-2.9.0-1" + }, + "devDependencies": { + "grunt": "^0.4.5", + "grunt-angular-templates": "^0.5.7", + "grunt-connect-proxy": "^0.1.11", + "grunt-contrib-concat": "^0.5.0", + "grunt-contrib-copy": "^0.6.0", + "grunt-contrib-cssmin": "^0.10.0", + "grunt-contrib-uglify": "^0.6.0", + "grunt-contrib-watch": "^0.6.1", + "grunt-filerev": "^2.1.2", + "grunt-html2js": "^0.2.9", + "grunt-open": "^0.2.3" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Steve Weston", + "license": "Apache2" +} diff --git a/webclient/src/app/auth/controller/auth_authorize_controller.js b/webclient/src/app/auth/controller/auth_authorize_controller.js new file mode 100644 index 0000000..2095106 --- /dev/null +++ b/webclient/src/app/auth/controller/auth_authorize_controller.js @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/* + * This controller is responsible for getting an authorization code + * having a state and an openid. + * + * @author Nikita Konovalov + */ + +angular.module('db.auth').controller('AuthAuthorizeController', + function ($stateParams, $state, $log, OpenId, $window, LastLocation, + localStorageService) { + 'use strict'; + + // First, check for the edge case where the API returns an error code + // back to us. This should only happen when it fails to properly parse + // our redirect_uri and thus just sends the error back to referrer, but + // we should still catch it. + if (!!$stateParams.error) { + $log.debug('Error received, redirecting to auth.error.'); + $state.go('auth.error', $stateParams); + return; + } + + // Store the last path... + localStorageService.set('lastPath', LastLocation.get()); + + // We're not an error, let's fire the authorization. + OpenId.authorize(); + }); \ No newline at end of file diff --git a/webclient/src/app/auth/controller/auth_deauthorize_controller.js b/webclient/src/app/auth/controller/auth_deauthorize_controller.js new file mode 100644 index 0000000..402d550 --- /dev/null +++ b/webclient/src/app/auth/controller/auth_deauthorize_controller.js @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * This controller deauthorizes the session and destroys all tokens. + */ + +angular.module('db.auth').controller('AuthDeauthorizeController', + function (Session, $state, $log) { + 'use strict'; + + $log.debug('Logging out'); + Session.destroySession(); + $state.go('index'); + }); \ No newline at end of file diff --git a/webclient/src/app/auth/controller/auth_error_controller.js b/webclient/src/app/auth/controller/auth_error_controller.js new file mode 100644 index 0000000..ce06edc --- /dev/null +++ b/webclient/src/app/auth/controller/auth_error_controller.js @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * View controller for authorization error conditions. + */ +angular.module('db.auth').controller('AuthErrorController', + function ($scope, $stateParams) { + 'use strict'; + + $scope.error = $stateParams.error || 'Unknown'; + $scope.errorDescription = $stateParams.error_description || + 'No description received from server.'; + }); \ No newline at end of file diff --git a/webclient/src/app/auth/controller/auth_token_controller.js b/webclient/src/app/auth/controller/auth_token_controller.js new file mode 100644 index 0000000..8cc0808 --- /dev/null +++ b/webclient/src/app/auth/controller/auth_token_controller.js @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2014 Mirantis Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/* + * This controller is responsible for getting an access_token and + * a refresh token having an authorization_code. + * + * @author Nikita Konovalov + */ + +angular.module('db.auth').controller('AuthTokenController', + function ($state, $log, OpenId, Session, $searchParams, $window, UrlUtil, + LastLocation) { + 'use strict'; + + // First, check for the edge case where the API returns an error code + // back to us. This should only happen when it fails to properly parse + // our redirect_uri and thus just sends the error back to referrer, but + // we should still catch it. + if (!!$searchParams.error) { + $log.debug('Error received, redirecting to auth.error.'); + $state.go('auth.error', $searchParams); + return; + } + + // Validate any previously stored redirect path + function buildNextPath() { + + // First, do we have a stored last location? + var location = LastLocation.get(); + + // Sanity check on the location, we don't want to bounce straight + // back into auth. + if (location.indexOf('/auth') > -1) { + location = '/'; + } + + return location; + } + + // Looks like there's no error, so let's see if we can resolve a token. + // TODO: Finish implementing. + OpenId.token($searchParams) + .then( + function (token) { + Session.updateSession(token) + .then(function () { + var nextPath = buildNextPath(); + $window.location.href = + UrlUtil.buildApplicationUrl(nextPath); + }); + }, + function (error) { + Session.destroySession(); + $state.go('auth.error', error); + } + ); + }); \ No newline at end of file diff --git a/webclient/src/app/auth/controller/login_required_modal_controller.js b/webclient/src/app/auth/controller/login_required_modal_controller.js new file mode 100644 index 0000000..c208f85 --- /dev/null +++ b/webclient/src/app/auth/controller/login_required_modal_controller.js @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * A simple modal controller for the login require modal. + */ + +angular.module('db.auth').controller('LoginRequiredModalController', + function ($state, $scope, $modalInstance) { + 'use strict'; + + $scope.login = function () { + $state.go('auth.authorize'); + $modalInstance.dismiss('success'); + }; + + $scope.close = function () { + $modalInstance.dismiss('cancel'); + }; + }); \ No newline at end of file diff --git a/webclient/src/app/auth/directive/permission.js b/webclient/src/app/auth/directive/permission.js new file mode 100644 index 0000000..35293e7 --- /dev/null +++ b/webclient/src/app/auth/directive/permission.js @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Permission provider, which hides particular controls based on whether the + * passed permission flag has been set. + */ +angular.module('db.util').directive('permission', + function ($log, PermissionManager) { + 'use strict'; + + return { + restrict: 'A', + link: function ($scope, element, attrs) { + // Start by hiding it. + element.hide(); + + var permName = attrs.permission; + var permValue = attrs.permissionValue || true; + + PermissionManager.listen($scope, permName, + function (actualValue) { + + if (!!actualValue && + actualValue.toString() === permValue.toString()) { + element.show(); + } else { + element.hide(); + } + } + ); + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/auth/http/http_authorization_header.js b/webclient/src/app/auth/http/http_authorization_header.js new file mode 100644 index 0000000..1658f4e --- /dev/null +++ b/webclient/src/app/auth/http/http_authorization_header.js @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * An HTTP request interceptor that attaches an authorization to every HTTP + * request, assuming it exists and isn't expired. + */ +angular.module('db.auth').factory('httpAuthorizationHeader', + function (AccessToken) { + 'use strict'; + + return { + request: function (request) { + + var token = AccessToken.getAccessToken(); + var type = AccessToken.getTokenType(); + if (!!token) { + request.headers.Authorization = type + ' ' + token; + } + return request; + } + }; + }) + // Attach the HTTP interceptor. + .config(function ($httpProvider) { + 'use strict'; + + $httpProvider.interceptors.push('httpAuthorizationHeader'); + }); diff --git a/webclient/src/app/auth/module.js b/webclient/src/app/auth/module.js new file mode 100644 index 0000000..f144719 --- /dev/null +++ b/webclient/src/app/auth/module.js @@ -0,0 +1,65 @@ +angular.module('db.auth', [ 'db.services', 'db.templates', 'ui.router', + 'db.util', 'LocalStorageModule']) + .config(function ($stateProvider, SessionResolver) { + 'use strict'; + + // Declare the states for this module. + $stateProvider + .state('auth', { + abstract: true, + template: '
', + url: '/auth' + }) + .state('auth.authorize', { + url: '/authorize?error&error_description', + templateUrl: 'app/auth/template/busy.html', + controller: 'AuthAuthorizeController', + resolve: { + isLoggedOut: SessionResolver.requireLoggedOut + } + }) + .state('auth.deauthorize', { + url: '/deauthorize', + templateUrl: 'app/auth/template/busy.html', + controller: 'AuthDeauthorizeController', + resolve: { + isLoggedIn: SessionResolver.requireLoggedIn + } + }) + .state('auth.token', { + url: '/token?code&state&error&error_description', + templateUrl: 'app/auth/template/busy.html', + controller: 'AuthTokenController', + resolve: { + isLoggedOut: SessionResolver.requireLoggedOut + } + }) + .state('auth.error', { + url: '/error?error&error_description', + templateUrl: 'app/auth/template/error.html', + controller: 'AuthErrorController' + }); + }) + .run(function ($rootScope, SessionState, Session, PermissionManager, + RefreshManager, Notification, Priority) { + 'use strict'; + + // Initialize our permission manager. + PermissionManager.initialize(); + + // Always record the logged in state on the root scope. + Notification.intercept(function (message) { + switch (message.type) { + case SessionState.LOGGED_IN: + $rootScope.isLoggedIn = true; + break; + case SessionState.LOGGED_OUT: + $rootScope.isLoggedIn = false; + break; + default: + break; + } + }, Priority.LAST); + + RefreshManager.scheduleRefresh(); + }); \ No newline at end of file diff --git a/webclient/src/app/auth/notification/auth_error_handling.js b/webclient/src/app/auth/notification/auth_error_handling.js new file mode 100644 index 0000000..4bb7978 --- /dev/null +++ b/webclient/src/app/auth/notification/auth_error_handling.js @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2014 Mirantis Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +angular.module('db.auth').run( + function($log, $modal, Notification, RefreshManager, Session, Priority) { + 'use strict'; + + function handle_401() { + RefreshManager.tryRefresh().then(function () { + $log.info('Token refreshed on 401'); + }, function () { + $log.info('Could not refresh token. Destroying session'); + Session.destroySession(); + }); + } + + function handle_403() { + var modalInstance = $modal.open({ + templateUrl: 'app/templates/auth/modal/superuser_required.html', + controller: function($modalInstance, $scope) { + $scope.close = function () { + $modalInstance.dismiss('cancel'); + }; + } + }); + return modalInstance.result; + } + + + // We're using -1 as the priority, to ensure that this is + // intercepted before anything else happens. + Notification.intercept(function (message) { + if (message.type === 'http') { + if (message.message === 401) { + // An unauthorized error. Refreshing the access token + // might help. + handle_401(); + } + + if (message.message === 403) { + // Forbidden error. A user should be warned tha he is + // doing something wrong. + handle_403(); + } + + return true; // Stop processing this notifications. + } + }, Priority.BEFORE); + + } +); diff --git a/webclient/src/app/auth/notification/session_state_handling.js b/webclient/src/app/auth/notification/session_state_handling.js new file mode 100644 index 0000000..251b14b --- /dev/null +++ b/webclient/src/app/auth/notification/session_state_handling.js @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +angular.module('db.auth').run( + function ($log, $modal, Notification, SessionState, Priority) { + 'use strict'; + + + // We're using -1 as the priority, to ensure that this is + // intercepted before anything else happens. + Notification.intercept(function (message) { + + switch (message.type) { + case SessionState.LOGGED_IN: + // Logged in messages get filtered out. + return true; + case SessionState.LOGGED_OUT: + message.message = 'You have been logged out.'; + break; + default: + break; + } + }, Priority.AFTER); + + } +); diff --git a/webclient/src/app/auth/provider/session_state.js b/webclient/src/app/auth/provider/session_state.js new file mode 100644 index 0000000..f653c4b --- /dev/null +++ b/webclient/src/app/auth/provider/session_state.js @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * A list of constants used by the session service to maintain the user's + * current authentication state. + */ +angular.module('db.auth').value('SessionState', { + + /** + * Session state constant, used to indicate that the user is logged in. + */ + LOGGED_IN: 'logged_in', + + /** + * Session state constant, used to indicate that the user is logged out. + */ + LOGGED_OUT: 'logged_out', + + /** + * Session state constant, used during initialization when we're not quite + * certain yet whether we're logged in or logged out. + */ + PENDING: 'pending' + +}); \ No newline at end of file diff --git a/webclient/src/app/auth/resolver/permission_resolver.js b/webclient/src/app/auth/resolver/permission_resolver.js new file mode 100644 index 0000000..e732bf3 --- /dev/null +++ b/webclient/src/app/auth/resolver/permission_resolver.js @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * The permission resolver allows us to require certain permissions for specific + * UI routes. + */ +angular.module('db.auth').constant('PermissionResolver', + { + /** + * Rejects the route if the current user does not have the required + * permission. + */ + requirePermission: function (permName, requiredValue) { + 'use strict'; + + return function ($q, $log, PermissionManager) { + var deferred = $q.defer(); + + PermissionManager.resolve(permName).then( + function (value) { + $log.debug('permission:', permName, requiredValue, + value); + if (value === requiredValue) { + deferred.resolve(value); + } else { + deferred.reject(value); + } + }, + function (error) { + $log.debug('permission:', error); + deferred.reject(error); + } + ); + + return deferred.promise; + }; + + }, + + /** + * Resolves the value of the provided permission. + */ + resolvePermission: function (permName) { + 'use strict'; + + return function ($q, $log, PermissionManager) { + var deferred = $q.defer(); + + PermissionManager.resolve(permName).then( + function (value) { + deferred.resolve(value); + }, + function () { + deferred.resolve(false); + } + ); + + return deferred.promise; + }; + + } + }); diff --git a/webclient/src/app/auth/resolver/session_resolver.js b/webclient/src/app/auth/resolver/session_resolver.js new file mode 100644 index 0000000..97860f1 --- /dev/null +++ b/webclient/src/app/auth/resolver/session_resolver.js @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * A set of utility methods that may be used during state declaration to enforce + * session state. They return asynchronous promises which will either resolve + * or reject the state change, depending on what you're asking for. + */ +angular.module('db.auth').constant('SessionResolver', + (function () { + 'use strict'; + + /** + * Resolve the promise based on the current session state. We can't + * inject here, since the injector's not ready yet. + */ + function resolveSessionState(deferred, desiredSessionState, Session) { + return function () { + var sessionState = Session.getSessionState(); + if (sessionState === desiredSessionState) { + deferred.resolve(sessionState); + } else { + deferred.reject(sessionState); + } + }; + } + + return { + /** + * This resolver simply checks to see whether a user is logged + * in or not, and returns the session state. + */ + resolveSessionState: function ($q, $log, Session, SessionState) { + var deferred = $q.defer(); + + $log.debug('Resolving session state...'); + Session.resolveSessionState().then( + function (sessionState) { + deferred.resolve(sessionState); + }, + function (error) { + $log.error(error); + deferred.resolve(SessionState.LOGGED_OUT); + } + ); + + return deferred.promise; + }, + + /** + * This resolver asserts that the user is logged + * out before allowing a route. Otherwise it fails. + */ + requireLoggedOut: function ($q, $log, Session, SessionState) { + + $log.debug('Resolving logged-out-only route...'); + var deferred = $q.defer(); + var resolveLoggedOut = resolveSessionState(deferred, + SessionState.LOGGED_OUT, Session); + + // Do we have to wait for state resolution? + if (Session.getSessionState() === SessionState.PENDING) { + Session.resolveSessionState().then(resolveLoggedOut); + } else { + resolveLoggedOut(); + } + + return deferred.promise; + }, + + /** + * This resolver asserts that the user is logged + * in before allowing a route. Otherwise it fails. + */ + requireLoggedIn: function ($q, $log, Session, $rootScope, + SessionState) { + + $log.debug('Resolving logged-in-only route...'); + var deferred = $q.defer(); + var resolveLoggedIn = resolveSessionState(deferred, + SessionState.LOGGED_IN, Session); + + // Do we have to wait for state resolution? + if (Session.getSessionState() === SessionState.PENDING) { + Session.resolveSessionState().then(resolveLoggedIn); + } else { + resolveLoggedIn(); + } + + return deferred.promise; + }, + + /** + * This resolver ensures that the currentUser has been resolved + * before the route resolves. + */ + requireCurrentUser: function ($q, $log, CurrentUser) { + $log.debug('Resolving current user...'); + return CurrentUser.resolve(); + } + }; + })()); diff --git a/webclient/src/app/auth/service/access_token.js b/webclient/src/app/auth/service/access_token.js new file mode 100644 index 0000000..31b2702 --- /dev/null +++ b/webclient/src/app/auth/service/access_token.js @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * AccessToken storage service, an abstraction layer between our token storage + * and the rest of the system. This feature uses localStorage, which means that + * our application will NOT support IE7. Once that becomes a requirement, we'll + * have to use this abstraction layer to store data in a cookie instead. + */ +angular.module('db.auth').factory('AccessToken', + function (localStorageService, preExpireDelta) { + 'use strict'; + + /** + * Our local storage key name constants + */ + var TOKEN_TYPE = 'token_type'; + var ACCESS_TOKEN = 'access_token'; + var REFRESH_TOKEN = 'refresh_token'; + var ID_TOKEN = 'id_token'; + var EXPIRES_IN = 'expires_in'; + var ISSUE_DATE = 'issue_date'; + + return { + + /** + * Clears the token + */ + clear: function () { + localStorageService.remove(TOKEN_TYPE); + localStorageService.remove(ACCESS_TOKEN); + localStorageService.remove(REFRESH_TOKEN); + localStorageService.remove(ID_TOKEN); + localStorageService.remove(EXPIRES_IN); + localStorageService.remove(ISSUE_DATE); + }, + + /** + * Sets all token properties at once. + */ + setToken: function (jsonToken) { + this.setTokenType(jsonToken.token_type); + this.setAccessToken(jsonToken.access_token); + this.setRefreshToken(jsonToken.refresh_token); + this.setIdToken(jsonToken.id_token); + this.setIssueDate(jsonToken.issue_date); + this.setExpiresIn(jsonToken.expires_in); + }, + + /** + * Is the current access token expired? + */ + isExpired: function () { + var expiresIn = this.getExpiresIn() || 0; + var issueDate = this.getIssueDate() || 0; + var now = Math.round((new Date()).getTime() / 1000); + + return issueDate + expiresIn < now; + }, + + /** + * Will this token expire in an hour + */ + expiresSoon: function () { + var expiresIn = this.getExpiresIn() || 0; + var issueDate = this.getIssueDate() || 0; + var now = Math.round((new Date()).getTime() / 1000); + + return issueDate + expiresIn - preExpireDelta < now; + }, + + /** + * Get the token type. Bearer, etc. + */ + getTokenType: function () { + return localStorageService.get(TOKEN_TYPE); + }, + + /** + * Set the token type. + */ + setTokenType: function (value) { + return localStorageService.set(TOKEN_TYPE, value); + }, + + /** + * Retrieve the date this token was issued. + */ + getIssueDate: function () { + return parseInt(localStorageService.get(ISSUE_DATE)) || null; + }, + + /** + * Set the issue date for the current access token. + */ + setIssueDate: function (value) { + return localStorageService.set(ISSUE_DATE, parseInt(value)); + }, + + /** + * Get the number of seconds after the issue date when this token + * is considered expired. + */ + getExpiresIn: function () { + return parseInt(localStorageService.get(EXPIRES_IN)) || 0; + }, + + /** + * Set the number of seconds from the issue date when this token + * will expire. + */ + setExpiresIn: function (value) { + return localStorageService.set(EXPIRES_IN, parseInt(value)); + }, + + /** + * Retrieve the access token. + */ + getAccessToken: function () { + return localStorageService.get(ACCESS_TOKEN) || null; + }, + + /** + * Set the access token. + */ + setAccessToken: function (value) { + return localStorageService.set(ACCESS_TOKEN, value); + }, + + /** + * Retrieve the refresh token. + */ + getRefreshToken: function () { + return localStorageService.get(REFRESH_TOKEN) || null; + }, + + /** + * Set the refresh token. + */ + setRefreshToken: function (value) { + return localStorageService.set(REFRESH_TOKEN, value); + }, + + /** + * Retrieve the id token. + */ + getIdToken: function () { + return localStorageService.get(ID_TOKEN) || null; + }, + + /** + * Set the id token. + */ + setIdToken: function (value) { + return localStorageService.set(ID_TOKEN, value); + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/auth/service/current_user.js b/webclient/src/app/auth/service/current_user.js new file mode 100644 index 0000000..7a58a28 --- /dev/null +++ b/webclient/src/app/auth/service/current_user.js @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * The current user service. It pays attention to changes in the application's + * session state, and loads the user found in the AccessToken when a valid + * session is detected. + */ +angular.module('db.auth').factory('CurrentUser', + function (SessionState, Session, AccessToken, $rootScope, $log, $q, User, + Notification, Priority) { + 'use strict'; + + /** + * The current user + */ + var currentUser = null; + var currentPromise = null; + + /** + * Resolve a current user. + */ + function resolveCurrentUser() { + // If we've got an in-flight promise, just return that and let + // the consumers chain off of that. + if (!!currentPromise) { + return currentPromise; + } + + // Construct a new resolution promise. + var deferred = $q.defer(); + currentPromise = deferred.promise; + + // Make sure we have a logged-in session. + resolveLoggedInSession().then( + function () { + // Now that we know we're logged in, do we have a + // currentUser yet? + if (!!currentUser) { + deferred.resolve(currentUser); + } else { + // Ok, we have to load. + User.get( + { + id: AccessToken.getIdToken() + }, + function (user) { + currentUser = user; + deferred.resolve(user); + }, + function (error) { + currentUser = null; + deferred.reject(error); + } + ); + } + }, + function (error) { + currentUser = null; + deferred.reject(error); + } + ); + + // Chain a resolution that'll make the currentPromise clear itself. + currentPromise.then( + function () { + currentPromise = null; + }, + function () { + currentPromise = null; + } + ); + + return currentPromise; + } + + /** + * A promise that only resolves if we're currently logged in. + */ + function resolveLoggedInSession() { + var deferred = $q.defer(); + + Session.resolveSessionState().then( + function (sessionState) { + + if (sessionState === SessionState.LOGGED_IN) { + deferred.resolve(sessionState); + } else { + deferred.reject(sessionState); + } + }, + function (error) { + deferred.reject(error); + } + ); + + return deferred.promise; + } + + // Add event listeners. + Notification.intercept(function (message) { + switch (message.type) { + case SessionState.LOGGED_IN: + resolveCurrentUser(); + break; + case SessionState.LOGGED_OUT: + currentUser = null; + break; + default: + break; + } + }, Priority.LAST); + + // Expose the methods for this service. + return { + + /** + * Resolves the current user with a promise. + */ + resolve: function () { + return resolveCurrentUser(); + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/auth/service/open_id.js b/webclient/src/app/auth/service/open_id.js new file mode 100644 index 0000000..2d7a22a --- /dev/null +++ b/webclient/src/app/auth/service/open_id.js @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Our OpenID token resource, which adheres to the OpenID connect specification + * found here; http://openid.net/specs/openid-connect-basic-1_0.html + */ +angular.module('db.auth').factory('OpenId', + function ($location, $window, $log, $http, $q, StringUtil, UrlUtil, + radarApiBase, localStorageService) { + 'use strict'; + + var storageKey = 'openid_authorize_state'; + var authorizeUrl = radarApiBase + '/openid/authorize'; + var tokenUrl = radarApiBase + '/openid/token'; + var redirectUri = UrlUtil.buildApplicationUrl('/auth/token'); + var clientId = $location.host(); + + return { + /** + * Asks the OAuth endpoint for an authorization token given + * the passed parameters. + */ + authorize: function () { + // Create and store a random state parameter. + var state = StringUtil.randomAlphaNumeric(20); + localStorageService.set(storageKey, state); + + var openIdParams = { + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: 'user', + state: state + }; + + $window.location.href = authorizeUrl + '?' + + UrlUtil.serializeParameters(openIdParams); + }, + + /** + * Asks our OpenID endpoint to convert an authorization code or a + * refresh token to an access token. + */ + token: function (params) { + var deferred = $q.defer(); + var grant_type = params.grant_type || 'authorization_code'; + var authorizationCode = params.code; + var refreshToken = params.refresh_token; + + var tokenParams = { + grant_type: grant_type + }; + + if (grant_type === 'authorization_code') { + tokenParams.code = authorizationCode; + } else { + tokenParams.refresh_token = refreshToken; + } + + var url = tokenUrl + '?' + + UrlUtil.serializeParameters(tokenParams); + + $http({method: 'POST', url: url}) + .then(function (response) { + $log.debug('Token creation succeeded.'); + // Extract the data + var data = response.data; + + // Derive an issue date, from the Date header if + // possible. + var dateHeader = response.headers('Date'); + if (!dateHeader) { + data.issue_date = Math.floor(Date.now() / 1000); + } else { + data.issue_date = Math.floor( + new Date(dateHeader) / 1000 + ); + } + + deferred.resolve(data); + }, + function (response) { + $log.debug('Token creation failed.'); + + // Construct a conformant error response. + var error = response.data; + if (!error.hasOwnProperty('error')) { + error = { + error: response.status, + error_description: response.data + }; + } + deferred.reject(error); + }); + + return deferred.promise; + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/auth/service/permission_manager.js b/webclient/src/app/auth/service/permission_manager.js new file mode 100644 index 0000000..db76c80 --- /dev/null +++ b/webclient/src/app/auth/service/permission_manager.js @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * This service maintains our permission state while the client is running. + * Rather than be based on a per-request basis whose responses can quickly + * become stale, it broadcasts events which views/directives can use to + * update their permissions. + * + * At the moment this is a fairly naive implementation, which assumes that + * permissions are defined as key/value pairs, and are globally scoped. + * For example, this is possible: + * + * isSuperuser: true + * + * But this is not. + * + * Project ID 4, canEdit: false + * + * We'll need to update this once we know what our permission structure + * looks like. + */ +angular.module('db.auth').factory('PermissionManager', + function ($log, $q, $rootScope, Session, SessionState, CurrentUser, + Notification, Priority) { + 'use strict'; + + // Our permission resolution cache. + var permCache = {}; + var NOTIFY_PERMISSIONS = 'notify_permissions'; + + /** + * Resolve a permission. + */ + function resolvePermission(permName) { + var deferred = $q.defer(); + + if (permCache.hasOwnProperty(permName)) { + deferred.resolve(permCache[permName]); + } else { + CurrentUser.resolve().then( + function (user) { + permCache[permName] = user[permName]; + deferred.resolve(permCache[permName]); + }, + function (error) { + deferred.reject(error); + } + ); + } + + return deferred.promise; + } + + /** + * Clear the permission cache and notify the system that it needs + * to re-resolve the permissions. + */ + function clearPermissionCache() { + $log.debug('Resetting permission cache.'); + permCache = {}; + $rootScope.$broadcast(NOTIFY_PERMISSIONS); + } + + /** + * Wrapper function which resolves the permission we're looking + * for and then invokes the passed handler. + */ + function permissionListenHandler(permName, handler) { + return function () { + resolvePermission(permName).then( + function (value) { + handler(value); + }, + function () { + handler(null); + } + ); + }; + } + + return { + /** + * Initialize the permission manager on the passed scope. + */ + initialize: function () { + $log.debug('Initializing permissions'); + + + // Always record the logged in state on the root scope. + var removeNotifier = Notification.intercept(function (message) { + switch (message.type) { + case SessionState.LOGGED_IN: + case SessionState.LOGGED_OUT: + clearPermissionCache(); + break; + default: + break; + } + }, Priority.LAST); + + $rootScope.$on('$destroy', removeNotifier); + + // Run update if the session state has already resolved. + // Otherwise wait for the above listeners. + if (Session.getSessionState() !== SessionState.PENDING) { + clearPermissionCache(); + } + }, + + /** + * Convenience method which allows a + * @param scope The view scope that wants to listen to permission + * changes. + * @param permName The name of the permission. + * @param handler The response handler + */ + listen: function (scope, permName, handler) { + var messageHandler = permissionListenHandler(permName, handler); + + scope.$on('$destroy', + $rootScope.$on(NOTIFY_PERMISSIONS, messageHandler) + ); + + // Trigger the handler once. + messageHandler(); + }, + + /** + * Resolve a specific permission. Loads from a resolution cache + * if this permission is currently unknown. + */ + resolve: function (permName) { + return resolvePermission(permName); + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/auth/service/pre_expire_delta.js b/webclient/src/app/auth/service/pre_expire_delta.js new file mode 100644 index 0000000..5a0672c --- /dev/null +++ b/webclient/src/app/auth/service/pre_expire_delta.js @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2014 Mirantis Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/* + * A constant time in seconds when an access token is considered "expires soon" + * default 10 minutes. + */ + +angular.module('db.auth').constant('preExpireDelta', 600); diff --git a/webclient/src/app/auth/service/refresh_manager.js b/webclient/src/app/auth/service/refresh_manager.js new file mode 100644 index 0000000..fdbab6b --- /dev/null +++ b/webclient/src/app/auth/service/refresh_manager.js @@ -0,0 +1,86 @@ +angular.module('db.auth').service('RefreshManager', + function ($q, $log, $timeout, preExpireDelta, AccessToken, OpenId) { + 'use strict'; + + var currentRefresh = null; + var nextRefreshPromise = null; + var scheduledForToken = null; + + // Try to refresh the expired access_token + var tryRefresh = function () { + + if (!currentRefresh) { + // Create our promise, since we should always return one. + currentRefresh = $q.defer(); + currentRefresh.promise.then( + function () { + currentRefresh = null; + }, + function () { + currentRefresh = null; + } + ); + + var refresh_token = AccessToken.getRefreshToken(); + var is_expired = AccessToken.isExpired(); + var expires_soon = AccessToken.expiresSoon(); + + // Do we have a refresh token to try? + if (!refresh_token) { + $log.info('No refresh token found. Aborting refresh.'); + currentRefresh.reject(); + } else if (!is_expired && !expires_soon) { + $log.info('No refresh required for current access token.'); + currentRefresh.resolve(true); + } else { + + $log.info('Trying to refresh token'); + OpenId.token({ + grant_type: 'refresh_token', + refresh_token: refresh_token + }).then( + function (data) { + AccessToken.setToken(data); + currentRefresh.resolve(true); + scheduleRefresh(); + }, + function () { + AccessToken.clear(); + currentRefresh.reject(); + } + ); + } + } + return currentRefresh.promise; + }; + + + var scheduleRefresh = function () { + if (!AccessToken.getRefreshToken() || AccessToken.isExpired()) { + $log.info('Current token does not require deferred refresh.'); + return; + } + + var expiresAt = AccessToken.getIssueDate() + + AccessToken.getExpiresIn(); + + if (!!nextRefreshPromise && + AccessToken.getAccessToken() === scheduledForToken) { + + $log.info('The refresh is already scheduled.'); + return; + } + + var now = Math.round((new Date()).getTime() / 1000); + var delay = (expiresAt - preExpireDelta - now) * 1000; + nextRefreshPromise = $timeout(tryRefresh, delay, false); + scheduledForToken = AccessToken.getAccessToken(); + + $log.info('Refresh scheduled to happen in ' + delay + ' ms'); + }; + + this.tryRefresh = tryRefresh; + this.scheduleRefresh = scheduleRefresh; + + } +); \ No newline at end of file diff --git a/webclient/src/app/auth/service/session.js b/webclient/src/app/auth/service/session.js new file mode 100644 index 0000000..0cee9af --- /dev/null +++ b/webclient/src/app/auth/service/session.js @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Session management service - keeps track of our current session state, mostly + * by verifying the token state returned from the OpenID service. + */ +angular.module('db.auth').factory('Session', + function (SessionState, AccessToken, $rootScope, $log, $q, $state, User, + RefreshManager, Notification, Severity) { + 'use strict'; + + /** + * The current session state. + * + * @type String + */ + var sessionState = SessionState.PENDING; + + /** + * Initialize the session. + */ + function initializeSession() { + var deferred = $q.defer(); + + if (!AccessToken.getAccessToken()) { + $log.debug('No token found'); + updateSessionState(SessionState.LOGGED_OUT); + deferred.resolve(); + } else { + // Validate the token currently in the cache. + validateToken() + .then(function () { + $log.debug('Token validated'); + updateSessionState(SessionState.LOGGED_IN); + deferred.resolve(sessionState); + }, function () { + $log.debug('Token not validated'); + AccessToken.clear(); + updateSessionState(SessionState.LOGGED_OUT); + deferred.resolve(sessionState); + }); + } + + return deferred.promise; + } + + /** + * Validate the token. + */ + function validateToken() { + + /** + * Try fresh call is necessary here because a User may try to + * validate a token after a long break in using Radar. + * Even if refresh is not necessary right now the tryRefresh method + * will just resolve immediately. + */ + + var deferred = $q.defer(); + RefreshManager.tryRefresh().then(function () { + var id = AccessToken.getIdToken(); + + User.get({id: id}, + function (user) { + deferred.resolve(user); + }, function (error) { + deferred.reject(error); + }); + }); + return deferred.promise; + } + + + /** + * Handles state updates and broadcasts. + */ + function updateSessionState(newState) { + if (newState !== sessionState) { + sessionState = newState; + Notification.send(newState, newState, Severity.SUCCESS); + } + } + + /** + * Destroy the session (Clear the token). + */ + function destroySession() { + AccessToken.clear(); + updateSessionState(SessionState.LOGGED_OUT); + $state.reload(); + } + + /** + * Initialize and test our current session token. + */ + initializeSession(); + + // Expose the methods for this service. + return { + /** + * The current session state. + */ + getSessionState: function () { + return sessionState; + }, + + /** + * Resolve the current session state, as a promise. + */ + resolveSessionState: function () { + var deferred = $q.defer(); + if (sessionState !== SessionState.PENDING) { + deferred.resolve(sessionState); + } else { + var unwatch = $rootScope.$watch(function () { + return sessionState; + }, function () { + if (sessionState !== SessionState.PENDING) { + deferred.resolve(sessionState); + unwatch(); + } + }); + } + + return deferred.promise; + }, + + /** + * Are we logged in? + */ + isLoggedIn: function () { + return sessionState === SessionState.LOGGED_IN; + }, + + /** + * Destroy the session. + */ + destroySession: function () { + destroySession(); + }, + + /** + * Update the session with a new (or null) token. + */ + updateSession: function (token) { + var deferred = $q.defer(); + if (!token) { + destroySession(); + deferred.resolve(sessionState); + } else { + AccessToken.setToken(token); + initializeSession().then( + function () { + deferred.resolve(sessionState); + }, + function () { + deferred.resolve(sessionState); + } + ); + } + + return deferred.promise; + } + }; + }); diff --git a/webclient/src/app/auth/service/session_modal_service.js b/webclient/src/app/auth/service/session_modal_service.js new file mode 100644 index 0000000..64255f2 --- /dev/null +++ b/webclient/src/app/auth/service/session_modal_service.js @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Session Modal service, which provides us with some session/auth related + * modals. + */ +angular.module('db.auth') + .factory('SessionModalService', function ($modal) { + 'use strict'; + + return { + + /** + * Show a modal that kindly tells our user that they should + * log in first. + */ + showLoginRequiredModal: function () { + var modalInstance = $modal.open( + { + templateUrl: 'app/auth/template' + + '/modal/login_required.html', + controller: 'LoginRequiredModalController' + } + ); + + // Return the modal's promise. + return modalInstance.result; + } + }; + } +); diff --git a/webclient/src/app/auth/template/busy.html b/webclient/src/app/auth/template/busy.html new file mode 100644 index 0000000..db222d0 --- /dev/null +++ b/webclient/src/app/auth/template/busy.html @@ -0,0 +1,26 @@ + + +
+
+
+

+
+ +

+
+
+
\ No newline at end of file diff --git a/webclient/src/app/auth/template/error.html b/webclient/src/app/auth/template/error.html new file mode 100644 index 0000000..bb78782 --- /dev/null +++ b/webclient/src/app/auth/template/error.html @@ -0,0 +1,40 @@ + + +
+
+
+

Oh no!

+ +

We encountered an unexpected error while trying to + log you in. The error message below should be helpful, + though if it's not you can contact our engineers in + #radar on + + Freenode + . +

+ +
+
Error Code:
+
{{error}}
+
Error Description:
+
{{errorDescription}}
+
+ +
+
+
diff --git a/webclient/src/app/auth/template/modal/login_required.html b/webclient/src/app/auth/template/modal/login_required.html new file mode 100644 index 0000000..f1ceb38 --- /dev/null +++ b/webclient/src/app/auth/template/modal/login_required.html @@ -0,0 +1,31 @@ + +
+
+ +

Authentication required

+
+ +
\ No newline at end of file diff --git a/webclient/src/app/dashboard/controller/dashboard_controller.js b/webclient/src/app/dashboard/controller/dashboard_controller.js new file mode 100644 index 0000000..d6328e5 --- /dev/null +++ b/webclient/src/app/dashboard/controller/dashboard_controller.js @@ -0,0 +1,6 @@ +angular.module('db.dashboard') + .controller('DashboardController', + function($scope) { + 'use strict'; + } + ) \ No newline at end of file diff --git a/webclient/src/app/dashboard/module.js b/webclient/src/app/dashboard/module.js new file mode 100644 index 0000000..6a4edfc --- /dev/null +++ b/webclient/src/app/dashboard/module.js @@ -0,0 +1,14 @@ +angular.module('db.dashboard', ['db.auth', 'ui.router']) +.config(function ($stateProvider, SessionResolver) { + 'use strict'; + $stateProvider + .state('dashboard', { + url: '/dashboard', + templateUrl: 'app/dashboard/template/dashboard_index.html', + controller: 'DashboardController', + resolve: { + sessionState: SessionResolver.requireLoggedIn, + currentUser: SessionResolver.requireCurrentUser + } + }) +}) \ No newline at end of file diff --git a/webclient/src/app/dashboard/template/dashboard_index.html b/webclient/src/app/dashboard/template/dashboard_index.html new file mode 100644 index 0000000..9a71e83 --- /dev/null +++ b/webclient/src/app/dashboard/template/dashboard_index.html @@ -0,0 +1 @@ +

Radar CI Dashboard Page

\ No newline at end of file diff --git a/webclient/src/app/header/controller/header_controller.js b/webclient/src/app/header/controller/header_controller.js new file mode 100644 index 0000000..6d6cf79 --- /dev/null +++ b/webclient/src/app/header/controller/header_controller.js @@ -0,0 +1,158 @@ +angular.module('db.header').controller('HeaderController', + function ($q, $scope, $rootScope, $state, $location, Session, + SessionState, CurrentUser, Criteria, Notification, Priority, + System, Operator) { + 'use strict'; + + $scope.isActive = function (viewLocation) { + return $location.path().lastIndexOf(viewLocation, 0) === 0; + } + + function resolveCurrentUser() { + CurrentUser.resolve().then( + function (user) { + $scope.currentUser = user; + }, + function () { + $scope.currentUser = null; + } + ); + } + + resolveCurrentUser(); + + /** + * Load and maintain the current user. + */ + $scope.currentUser = null; + + + + /** + * Log out the user. + */ + $scope.logout = function () { + Session.destroySession(); + }; + + /** + * Initialize the search string. + */ + $scope.searchString = ''; + + /** + * Send the user to search and clear the header search string. + */ + $scope.search = function (criteria) { + + switch (criteria.type) { + case 'Text': + $state.go('search', {q: criteria.value}); + break; + case 'System': + $state.go('systems.detail', {id: criteria.value}); + break; + case 'Operator': + $state.go('operators.detail', {id: criteria.value}); + break; + + } + + $scope.searchString = ''; + }; + + /** + * Filter down the search string to actual resources that we can + * browse to directly (Explicitly not including users here). If the + * search string is entirely numeric, we'll instead do a + * straightforward GET :id. + */ + $scope.quickSearch = function (searchString) { + var deferred = $q.defer(); + + searchString = searchString || ''; + + var searches = []; + + if (searchString.match(/^[0-9]+$/)) { + + var getSystemDeferred = $q.defer(); + var getOperatorDeferred = $q.defer(); + + System.get({id: searchString}, + function (result) { + getSystemDeferred.resolve(Criteria.create( + 'System', result.id, result.name + )); + }, function () { + getSystemDeferred.resolve(null); + }); + + Operator.get({id: searchString}, + function (result) { + getOperatorDeferred.resolve(Criteria.create( + 'Operator', result.id, result.name + )); + }, function () { + getOperatorDeferred.resolve(null); + }); + + // If the search string is entirely numeric, do a GET. + searches.push(getSystemDeferred.promise); + searches.push(getOperatorDeferred.promise); + + } else { + searches.push(System.criteriaResolver(searchString, 5)); + searches.push(Operator.criteriaResolver(searchString, 5)); + + } + $q.all(searches).then(function (searchResults) { + var criteria = [ + Criteria.create('Text', searchString) + ]; + + + /** + * Add a result to the returned criteria. + */ + var addResult = function (item) { + criteria.push(item); + }; + + for (var i = 0; i < searchResults.length; i++) { + var results = searchResults[i]; + + if (!results) { + continue; + } + + if (!!results.forEach) { + + // If it's iterable, do that. Otherwise just add it. + results.forEach(addResult); + } else { + addResult(results); + } + } + + deferred.resolve(criteria); + }); + + // Return the search promise. + return deferred.promise; + }; + + // Watch for changes to the session state. + Notification.intercept(function (message) { + switch (message.type) { + case SessionState.LOGGED_IN: + resolveCurrentUser(); + break; + case SessionState.LOGGED_OUT: + $scope.currentUser = null; + break; + default: + break; + } + }, Priority.LAST); + }); \ No newline at end of file diff --git a/webclient/src/app/header/module.js b/webclient/src/app/header/module.js new file mode 100644 index 0000000..e2c568a --- /dev/null +++ b/webclient/src/app/header/module.js @@ -0,0 +1,18 @@ +angular.module('db.header', ['db.auth']) + +.config(function ($stateProvider, SessionResolver) { + 'use strict'; + $stateProvider + .state('index.header', { + views: { + "header@": { + templateUrl: 'app/header/template/header_menu.html', + controller: 'HeaderController', + resolve: { + sessionState: SessionResolver.resolveSessionState + } + }, + }, + + }) +}) \ No newline at end of file diff --git a/webclient/src/app/header/template/header_menu.html b/webclient/src/app/header/template/header_menu.html new file mode 100644 index 0000000..f49d4ac --- /dev/null +++ b/webclient/src/app/header/template/header_menu.html @@ -0,0 +1,97 @@ +
+ + +
+
+ \ No newline at end of file diff --git a/webclient/src/app/home/controller/home_controller.js b/webclient/src/app/home/controller/home_controller.js new file mode 100644 index 0000000..405971b --- /dev/null +++ b/webclient/src/app/home/controller/home_controller.js @@ -0,0 +1,12 @@ +angular.module('db.home') + .controller('HomeController', + function ($state, $log, sessionState, SessionState) { + 'use strict'; + $log.debug('Home Controller loading. Session state: ' + sessionState); + // If we're logged in, go to the dashboard instead. + if (sessionState === SessionState.LOGGED_IN) { + $state.transitionTo('dashboard'); + } + }) + + \ No newline at end of file diff --git a/webclient/src/app/home/module.js b/webclient/src/app/home/module.js new file mode 100644 index 0000000..f4ee5fa --- /dev/null +++ b/webclient/src/app/home/module.js @@ -0,0 +1,22 @@ +angular.module('db.home', ['db.auth', 'db.services', 'ui.router', 'db.header']) +.config(function ($stateProvider, SessionResolver) { + 'use strict'; + $stateProvider + .state('index', { + url: '/', + templateUrl: 'app/home/template/welcome_page.html', + controller: 'HomeController', + resolve: { + sessionState: SessionResolver.resolveSessionState + } + }) +}) +.run(function ($log, $rootScope, $state) { + 'use strict'; + // Listen to changes on the root scope. If it's an error in the state + // changes (i.e. a 404) take the user back to the index. + $rootScope.$on('$stateChangeError', + function () { + $state.go('index'); + }); +}); \ No newline at end of file diff --git a/webclient/src/app/home/template/welcome_page.html b/webclient/src/app/home/template/welcome_page.html new file mode 100644 index 0000000..1923b01 --- /dev/null +++ b/webclient/src/app/home/template/welcome_page.html @@ -0,0 +1,2 @@ +

Radar CI Web Client

+Welcome to the Radar Javascript Web Client diff --git a/webclient/src/app/notification/controller/notifications_controller.js b/webclient/src/app/notification/controller/notifications_controller.js new file mode 100644 index 0000000..37d3a40 --- /dev/null +++ b/webclient/src/app/notification/controller/notifications_controller.js @@ -0,0 +1,49 @@ +angular.module('db.notification').controller('NotificationsController', + function ($scope, Notification) { + 'use strict'; + + var defaultDisplayCount = 5; + + $scope.displayCount = defaultDisplayCount; + + $scope.notifications = []; + + /** + * Remove a notification from the display list. + * + * @param notification + */ + $scope.remove = function (notification) { + var idx = $scope.notifications.indexOf(notification); + if (idx > -1) { + $scope.notifications.splice(idx, 1); + } + + // If the notification list length drops below default, make + // sure we reset the limit. + if ($scope.notifications.length <= defaultDisplayCount) { + $scope.displayCount = defaultDisplayCount; + } + }; + + /** + * Reveal more notifications, either current count + 5 or the total + * number of messages, whichever is smaller. + */ + $scope.showMore = function () { + // Set this to something big. + $scope.displayCount = Math.min($scope.notifications.length, + $scope.displayCount + 5); + }; + + /** + * Set up a notification subscriber, and make sure it's removed when + * the scope is destroyed. + */ + $scope.$on('$destroy', Notification.subscribe( + function (notification) { + $scope.notifications.push(notification); + } + ) + ); + }); \ No newline at end of file diff --git a/webclient/src/app/notification/directive/notifications.js b/webclient/src/app/notification/directive/notifications.js new file mode 100644 index 0000000..d7abbf9 --- /dev/null +++ b/webclient/src/app/notification/directive/notifications.js @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * This directive is a notification list renderer with all the trimmings. + * Errors broadcast throughout the system will be collected and displayed here. + */ +angular.module('db.notification').directive('notifications', + function () { + 'use strict'; + + return { + restrict: 'E', + templateUrl: 'app/notification/template/notifications.html', + controller: 'NotificationsController' + }; + }); \ No newline at end of file diff --git a/webclient/src/app/notification/module.js b/webclient/src/app/notification/module.js new file mode 100644 index 0000000..7546ed2 --- /dev/null +++ b/webclient/src/app/notification/module.js @@ -0,0 +1 @@ +angular.module('db.notification', []); \ No newline at end of file diff --git a/webclient/src/app/notification/provider/priority.js b/webclient/src/app/notification/provider/priority.js new file mode 100644 index 0000000..b5e993c --- /dev/null +++ b/webclient/src/app/notification/provider/priority.js @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +/** + * Useful priority constants. + */ + +angular.module('db.notification').constant('Priority', { + BEFORE: -1, + FIRST: 0, + LAST: 999, + AFTER: 1000 +}); \ No newline at end of file diff --git a/webclient/src/app/notification/provider/severity.js b/webclient/src/app/notification/provider/severity.js new file mode 100644 index 0000000..4872d5f --- /dev/null +++ b/webclient/src/app/notification/provider/severity.js @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +/** + * A list of severity levels used within this module. + */ + +angular.module('db.notification').constant('Severity', { + ERROR: 'error', + WARNING: 'warning', + INFO: 'info', + SUCCESS: 'success' +}); \ No newline at end of file diff --git a/webclient/src/app/notification/service/notification_service.js b/webclient/src/app/notification/service/notification_service.js new file mode 100644 index 0000000..d6d2a42 --- /dev/null +++ b/webclient/src/app/notification/service/notification_service.js @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * The centralized notification service, aka the central routing point for all + * broadcast notifications. You use it by registering interceptors and + * subscribers, and handling any messages that are sent(). + * + * Interceptors are intended to be both filters and decorators, where + * individual components can handle messages before either terminating + * the dispatch chain, or passing them on to the next interceptor. In this + * fashion it is easy to handle specific messages in one context while + * other messages in another. + * + * Subscribers are processors that handle all messages vetted by our + * interceptors. + */ +angular.module('db.notification').factory('Notification', + function ($log, Severity, Priority) { + 'use strict'; + + var subscribers = []; + var interceptors = []; + + return { + /** + * Send a notification. + * + * @param type A type identifier, such as a string. Use this for + * your subscribers to determine what kind of a message you're + * working with. + * @param message A human readable message for this notification. + * @param severity The severity of this message, any of the + * constants provided in Severity. + * @param cause The cause of this message, perhaps a large amount + * of debugging information. + * @param callback If this message prompts the user to do + * something, then pass a function here and it'll be rendered + * in the message. + * @param callbackLabel A custom label for the callback. + */ + send: function (type, message, severity, cause, callback, + callbackLabel) { + // Return the type. + if (!type || !message) { + $log.warn('Invoked Notification.send() without a type' + + ' or message.'); + return; + } + + // sanitize our data. + var n = { + 'type': type, + 'message': message, + 'severity': severity || Severity.INFO, + 'cause': cause || null, + 'callback': callback || null, + 'callbackLabel': callbackLabel || null, + 'date': new Date() + }; + + // Iterate through the interceptors. + for (var i = 0; i < interceptors.length; i++) { + if (!!interceptors[i].method(n)) { + return; + } + } + + // Iterate through the subscribers. + for (var j = 0; j < subscribers.length; j++) { + subscribers[j](n); + } + }, + + /** + * Add a message interceptor to the notification system, in order + * to determine which messages you'd like to handle. + * + * @param interceptor A method that accepts a notification. You can + * return true from the interceptor method to indicate that this + * message has been handled and should not be processed further. + * @param priority An optional priority (default 999). + * Interceptors with a lower priority will go first. + * @returns {Function} A method that may be called to remove the + * interceptor at a later time. + */ + intercept: function (interceptor, priority) { + + var i = { + 'priority': priority || Priority.LAST, + 'method': interceptor + }; + + // Add and sort the interceptors. We're using unshift here so + // that the sorting algorithm ends up being a single-pass + // bubble sort. + interceptors.unshift(i); + interceptors.sort(function (a, b) { + return a.priority - b.priority; + }); + + return function () { + var idx = interceptors.indexOf(i); + interceptors.splice(idx, 1); + }; + }, + + /** + * Subscribe to all messages that make it through our interceptors. + * + * @param subscriber A subscriber method that receives a + * notification. + * @returns {Function} A method that may be called to remove the + * subscriber at a later time. + */ + subscribe: function (subscriber) { + subscribers.push(subscriber); + + return function () { + subscribers.remove(subscriber); + }; + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/notification/template/notifications.html b/webclient/src/app/notification/template/notifications.html new file mode 100644 index 0000000..51abc7b --- /dev/null +++ b/webclient/src/app/notification/template/notifications.html @@ -0,0 +1,44 @@ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Showing {{displayCount}} of {{notifications.length}} + messages. + + + Show More + +
+
+ + + \ No newline at end of file diff --git a/webclient/src/app/operators/controller/event_filter_controller.js b/webclient/src/app/operators/controller/event_filter_controller.js new file mode 100644 index 0000000..8699d74 --- /dev/null +++ b/webclient/src/app/operators/controller/event_filter_controller.js @@ -0,0 +1,23 @@ +angular.module('db.operators').controller('EventFilterController', + function($scope, $modalInstance, Preference) { + 'use strict'; + + function init() { + $scope.enabled_event_types = + Preference.get('display_events_filter'); + } + + $scope.close = function () { + $modalInstance.dismiss('cancel'); + }; + + $scope.save = function () { + Preference.set('display_events_filter', + $scope.enabled_event_types); + return $modalInstance.close($scope.enabled_event_types); + }; + + init(); + + }) +; diff --git a/webclient/src/app/operators/controller/operator_detail_controller.js b/webclient/src/app/operators/controller/operator_detail_controller.js new file mode 100644 index 0000000..cf13532 --- /dev/null +++ b/webclient/src/app/operators/controller/operator_detail_controller.js @@ -0,0 +1,76 @@ +angular.module('db.operators').controller('OperatorDetailController', + function ($scope, $rootScope, $state, $stateParams, Operator, + Session) { + 'use strict'; + + // Parse the ID + var id = $stateParams.hasOwnProperty('id') ? + parseInt($stateParams.id, 10) : + null; + + /** + * The Operator we're manipulating right now. + * + * @type Operator + */ + $scope.Operator = {}; + + /** + * UI flag for when we're initially loading the view. + * + * @type {boolean} + */ + $scope.isLoading = true; + + /** + * UI view for when a change is round-tripping to the server. + * + * @type {boolean} + */ + $scope.isUpdating = false; + + /** + * Any error objects returned from the services. + * + * @type {{}} + */ + $scope.error = {}; + + /** + * Generic service error handler. Assigns errors to the view's scope, + * and unsets our flags. + */ + function handleServiceError(error) { + // We've encountered an error. + $scope.error = error; + $scope.isLoading = false; + $scope.isUpdating = false; + } + + /** + * Resets our loading flags. + */ + function handleServiceSuccess() { + $scope.isLoading = false; + $scope.isUpdating = false; + } + + /** + * Load the Operator + */ + function loadOperator() { + Operator.get( + {'id': id}, + function (result) { + // We've got a result, assign it to the view and unset our + // loading flag. + $scope.Operator = result; + + handleServiceSuccess(); + }, + handleServiceError + ); + } + + loadOperator(); + }); \ No newline at end of file diff --git a/webclient/src/app/operators/controller/operators_controller.js b/webclient/src/app/operators/controller/operators_controller.js new file mode 100644 index 0000000..b4b8122 --- /dev/null +++ b/webclient/src/app/operators/controller/operators_controller.js @@ -0,0 +1,57 @@ +angular.module('db.operators') +.controller('OperatorsListController', + function ($scope, $q, $log, Operator, ngTableParams, Preference, $http, OperatorsFactory, Marker) { + 'use strict'; + $scope.count=0; + $scope.data = [] + var marker=0; + $log.debug('loading operators'); + $scope.pageSize = Preference.get('page_size'); + var deferred = $q.defer(); + + var promise = OperatorsFactory.getCount() + promise.then( + function (payload) { + $scope.count = payload.data; + deferred.resolve(payload.data) + }, + + function (errorPayload) { + $log.error('failure loading count', errorPayload); + } + ) + .then(function () { + $scope.tableParams = new ngTableParams({ + page: 1, // show first page + count: $scope.pageSize // count per page + }, { + counts: [], + total: $scope.count, // length of data + getData: function($defer, params) { + marker = (params.page() - 1) * params.count() + var updatePromise = OperatorsFactory.updateMarker(marker) + updatePromise.then( + function(data) { + $log.debug('resolved marker') + }, + function(error) { + $log.debug('failed resolving marker: ' + error) + }).then(function() { + var promise = OperatorsFactory.getData(Operator, marker) + promise.then( + function (data) { + $defer.resolve(data) + }, + function (errorPayload) { + $log.error('failure loading operators', errorPayload); + })}) + } + } + ) + }, + + function (errorPayload) { + $log.error('failure loading operator', errorPayload); + }) + + }) \ No newline at end of file diff --git a/webclient/src/app/operators/filter/moment_filter.js b/webclient/src/app/operators/filter/moment_filter.js new file mode 100644 index 0000000..7354dac --- /dev/null +++ b/webclient/src/app/operators/filter/moment_filter.js @@ -0,0 +1,8 @@ +angular.module('db.operators') + .filter('moment', + function(TimeDateFormatter) { + return function(dateString) { + return TimeDateFormatter.getUtc(dateString); + } + } + ) \ No newline at end of file diff --git a/webclient/src/app/operators/module.js b/webclient/src/app/operators/module.js new file mode 100644 index 0000000..0998ff5 --- /dev/null +++ b/webclient/src/app/operators/module.js @@ -0,0 +1,28 @@ +angular.module('db.operators', ['ui.router', 'db.services', 'db.util', 'db.auth', + 'ngTable']) + .config(function ($stateProvider, $urlRouterProvider, SessionResolver) { + 'use strict'; + + $urlRouterProvider.when('/operators', '/operators/list'); + + // Set our page routes. + $stateProvider + .state('operators', { + abstract: true, + url: '/operators', + template: '
', + resolve: { + sessionState: SessionResolver.requireLoggedIn, + currentUser: SessionResolver.requireCurrentUser + } + }) + .state('operators.list', { + url: '/list', + templateUrl: 'app/operators/template/operator_list.html', + controller: 'OperatorsListController' + }) + .state('operators.detail', { + url: '/{id:[0-9]+}', + templateUrl: 'app/operators/template/operator_detail.html' + }); + }) \ No newline at end of file diff --git a/webclient/src/app/operators/service/operators_service.js b/webclient/src/app/operators/service/operators_service.js new file mode 100644 index 0000000..505b565 --- /dev/null +++ b/webclient/src/app/operators/service/operators_service.js @@ -0,0 +1,63 @@ +angular.module('db.operators') + .factory('OperatorsFactory', + function ($log, $q, $http, Marker) { + var initialized=0; + var baseMarker=0 + var marker=0 + $log.debug('loading operators service') + return { + getData: function (resource) { + var deferred = $q.defer(); + if (initialized == 0 ) { + resource.browse( + function (result) { + baseMarker = Marker.setMarker(result) + deferred.resolve(result); + }, function () { + deferred.resolve([]); + } + ); + initialized = 1; + + } + else { + resource.browse({marker: marker}, + function (result) { + deferred.resolve(result); + }, function () { + deferred.resolve([]); + } + ); + } + return deferred.promise; + + }, + + getCount: function() { + var deferred = $q.defer(); + $http.get('/api/v1/operators/count/') + .then(function (result) { + deferred.resolve(result) + }, + function (error) { + // We've encountered an error. + $log.debug('error: ' + error) + + deferred.resolve([]); + + }) + return deferred.promise; + }, + + updateMarker: function(newMarker) { + var deferred = $q.defer(); + deferred.promise.then(function() { + marker = baseMarker + newMarker; + $log.debug('marker: ' + marker) + }); + deferred.resolve(); + return deferred.promise; + } + + } + }) \ No newline at end of file diff --git a/webclient/src/app/operators/template/operator_detail.html b/webclient/src/app/operators/template/operator_detail.html new file mode 100644 index 0000000..99f0f2a --- /dev/null +++ b/webclient/src/app/operators/template/operator_detail.html @@ -0,0 +1,36 @@ +
+ +
\ No newline at end of file diff --git a/webclient/src/app/operators/template/operator_list.html b/webclient/src/app/operators/template/operator_list.html new file mode 100644 index 0000000..e5abf93 --- /dev/null +++ b/webclient/src/app/operators/template/operator_list.html @@ -0,0 +1,46 @@ +

Third-Party CI Operators

+ + + + + + + + +
+ + + + {{operator.operator_name}} + + + + {{operator.created_at | moment}} + + {{operator.updated_at | moment}} +
+ diff --git a/webclient/src/app/pages/module.js b/webclient/src/app/pages/module.js new file mode 100644 index 0000000..51cda66 --- /dev/null +++ b/webclient/src/app/pages/module.js @@ -0,0 +1,18 @@ +angular.module('db.pages', + [ 'db.services', 'db.templates', 'db.pages', 'ui.router'] + ) + .config(function ($stateProvider) { + 'use strict'; + + // Set our page routes. + $stateProvider + .state('page', { + abstract: true, + url: '/page', + template: '
' + }) + .state('page.about', { + url: '/about', + templateUrl: 'app/pages/template/about.html' + }); + }); \ No newline at end of file diff --git a/webclient/src/app/profile/controller/profile_preferences_controller.js b/webclient/src/app/profile/controller/profile_preferences_controller.js new file mode 100644 index 0000000..dea50e7 --- /dev/null +++ b/webclient/src/app/profile/controller/profile_preferences_controller.js @@ -0,0 +1,11 @@ +angular.module('db.profile').controller('ProfilePreferencesController', + function ($scope, Preference) { + 'use strict'; + + $scope.pageSize = Preference.get('page_size'); + + $scope.save = function () { + Preference.set('page_size', $scope.pageSize); + $scope.message = 'Preferences Saved!'; + }; + }); \ No newline at end of file diff --git a/webclient/src/app/profile/module.js b/webclient/src/app/profile/module.js new file mode 100644 index 0000000..68730d6 --- /dev/null +++ b/webclient/src/app/profile/module.js @@ -0,0 +1,26 @@ +angular.module('db.profile', + ['db.services', 'db.templates', 'db.auth', 'ui.router', 'ui.bootstrap'] + ) + .config(function ($stateProvider, SessionResolver, $urlRouterProvider) { + 'use strict'; + + // URL Defaults. + $urlRouterProvider.when('/profile', '/profile/preferences'); + + // Declare the states for this module. + $stateProvider + .state('profile', { + abstract: true, + template: '
', + url: '/profile', + resolve: { + isLoggedIn: SessionResolver.requireLoggedIn, + currentUser: SessionResolver.requireCurrentUser + } + }) + .state('profile.preferences', { + url: '/preferences', + templateUrl: 'app/profile/template/preferences_page.html', + controller: 'ProfilePreferencesController' + }); + }); \ No newline at end of file diff --git a/webclient/src/app/profile/template/preferences_page.html b/webclient/src/app/profile/template/preferences_page.html new file mode 100644 index 0000000..6d8842a --- /dev/null +++ b/webclient/src/app/profile/template/preferences_page.html @@ -0,0 +1,52 @@ +
+
+
+

Preferences

+
+
+
+
+
+
+ + +

+ How many results would you like to see when viewing + lists? +

+ +
+ + +   + + +   + + +   + + +
+
+ + +
+ +

{{message}}

+
+
+
+
+
\ No newline at end of file diff --git a/webclient/src/app/root/angular_bootstrap.js b/webclient/src/app/root/angular_bootstrap.js new file mode 100644 index 0000000..31bc989 --- /dev/null +++ b/webclient/src/app/root/angular_bootstrap.js @@ -0,0 +1,30 @@ +angular.element(document) +.ready(function () { + 'use strict'; + + var initInjector = angular.injector(['ng']); + var $http = initInjector.get('$http'); + var $log = initInjector.get('$log'); + + function initializeApplication(config) { + // Load everything we got into our module. + for (var key in config) { + $log.debug('Configuration: ' + key + ' -> ' + config[key]); + angular.module('db.root').constant(key, config[key]); + } + $log.debug('angular bootstrap') + angular.bootstrap(document, ['db.root']); + } + + $log.info('Attempting to load parameters from ./config.json'); + $http.get('./config.json').then( + function (response) { + initializeApplication(response.data); + }, + function () { + $log.warn('Cannot load ./config.json, using defaults.'); + initializeApplication({}); + } + ); +} +); diff --git a/webclient/src/app/root/module.js b/webclient/src/app/root/module.js new file mode 100644 index 0000000..a6330fa --- /dev/null +++ b/webclient/src/app/root/module.js @@ -0,0 +1,20 @@ +angular.module('db.root', + ['db.services', 'db.templates', 'db.header', 'db.home', 'db.dashboard', + 'db.pages', 'db.auth', 'db.systems', 'db.operators', 'db.profile', 'db.notification', + 'db.search', 'ui.router', 'ui.bootstrap', 'angular-data.DSCacheFactory', + 'angularMoment']) + + .config(function ($urlRouterProvider, $locationProvider, + $httpProvider) { + 'use strict'; + + // Default URL hashbang route + $urlRouterProvider.otherwise('/'); + + // Override the hash prefix for Google's AJAX crawling. + $locationProvider.hashPrefix('!'); + + // Attach common request headers out of courtesy to the API + $httpProvider.defaults.headers.common['X-Client'] = 'Dashboard'; + + }) \ No newline at end of file diff --git a/webclient/src/app/search/controller/search_criteria_controller.js b/webclient/src/app/search/controller/search_criteria_controller.js new file mode 100644 index 0000000..e1cc478 --- /dev/null +++ b/webclient/src/app/search/controller/search_criteria_controller.js @@ -0,0 +1,89 @@ +angular.module('db.search').controller('SearchCriteriaController', + function ($log, $q, $scope, Criteria) { + 'use strict'; + + /** + * Valid sets of resources that can be searched on. The default + * assumes no resources may be searched. + */ + var resourceTypes = []; + + /** + * Managed list of active criteria tags. + * + * @type {Array} + */ + $scope.criteria = []; + + /** + * Initialize this controller with different resource types and + * default search criteria. + * + * @param types + * @param defaultCriteria + */ + $scope.init = function (types, defaultCriteria) { + resourceTypes = types || resourceTypes; + $scope.criteria = defaultCriteria || []; + $scope.searchForCriteria = + Criteria.buildCriteriaSearch(resourceTypes, 5); + }; + + /** + * When a criteria is added, make sure we remove all previous criteria + * that have the same type. + */ + $scope.addCriteria = function (item) { + for (var i = $scope.criteria.length - 1; i >= 0; i--) { + var cItem = $scope.criteria[i]; + + // Don't remove exact duplicates. + if (cItem === item) { + continue; + } + + if (item.type === cItem.type) { + $scope.criteria.splice(i, 1); + } + } + }; + + /** + * Remove a criteria + */ + $scope.removeCriteria = function (item) { + var idx = $scope.criteria.indexOf(item); + if (idx > -1) { + $scope.criteria.splice(idx, 1); + } + }; + + /** + * Validate criteria when the list changes. + */ + $scope.$watchCollection(function () { + return $scope.criteria; + }, function () { + // Now, check all search resources to see if we have _any_ valid + // criteria. + $scope.hasSomeValidCriteria = false; + resourceTypes.forEach(function (resourceName) { + var validCriteria = Criteria + .filterCriteria(resourceName, $scope.criteria); + + if (validCriteria.length === $scope.criteria.length) { + $scope.hasSomeValidCriteria = true; + } + }); + }); + + /** + * Search for available search criteria. + */ + $scope.searchForCriteria = function () { + var deferred = $q.defer(); + deferred.resolve([]); + return deferred.promise; + }; + } +); \ No newline at end of file diff --git a/webclient/src/app/search/directive/search_results.js b/webclient/src/app/search/directive/search_results.js new file mode 100644 index 0000000..d24013e --- /dev/null +++ b/webclient/src/app/search/directive/search_results.js @@ -0,0 +1,71 @@ +/** + * A directive that displays a list of projects in a table. + * + * @see ProjectListController + */ +angular.module('db.search').directive('searchResults', + function ($log, $parse, Criteria, $injector) { + 'use strict'; + + return { + restrict: 'A', + scope: true, + link: function ($scope, $element, args) { + + // Extract the resource type. + var resourceName = args.searchResource; + var pageSize = args.searchPageSize || 20; + + $scope.isSearching = false; + $scope.searchResults = []; + + // Watch for changing criteria + $scope.$watchCollection($parse(args.searchCriteria), + function (criteria) { + + // Extract the valid critera from the provided ones. + $scope.validCriteria = Criteria + .filterCriteria(resourceName, criteria); + + // You have criteria, but they may not be valid. + $scope.hasCriteria = criteria.length > 0; + + // You have criteria, and all of them are valid for + // this resource. + $scope.hasValidCriteria = + $scope.validCriteria.length === + criteria.length && $scope.hasCriteria; + + // No need to search if our criteria aren't valid. + if (!$scope.hasValidCriteria) { + $scope.searchResults = []; + $scope.isSearching = false; + return; + } + + var params = Criteria.mapCriteria(resourceName, + $scope.validCriteria); + var resource = $injector.get(resourceName); + + if (!resource) { + $log.error('Invalid resource name: ' + + resourceName); + return; + } + + // Apply paging. + params.limit = pageSize; + + resource.query(params, + function (results) { + $scope.searchResults = results; + $scope.isSearching = false; + }, + function () { + $scope.isSearching = false; + } + ); + }); + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/search/module.js b/webclient/src/app/search/module.js new file mode 100644 index 0000000..9ad611d --- /dev/null +++ b/webclient/src/app/search/module.js @@ -0,0 +1,12 @@ +angular.module('db.search', + ['ui.router', 'db.services', 'db.util', 'db.auth']) + .config(function ($stateProvider) { + 'use strict'; + + // Set our page routes. + $stateProvider + .state('search', { + url: '/search?q', + templateUrl: 'app/search/template/index.html' + }); + }); \ No newline at end of file diff --git a/webclient/src/app/search/template/index.html b/webclient/src/app/search/template/index.html new file mode 100644 index 0000000..6421b47 --- /dev/null +++ b/webclient/src/app/search/template/index.html @@ -0,0 +1,263 @@ +
+
+
+

Search

+
+
+
+
+
+
+
+ +
+
+
+
+
+ What would you like to search by? +
+
+
+
+ Your search parameters are too restrictive. Try removing + some! +
+
+ +
+
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + +
+ No projects found. +
+ Your search criteria are not valid for this item. +
+ + +
+
+
+
+
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + +
+ No stories found. +
+ Your search criteria are not valid for this item. +
+ + +
+
+
+
+
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + +
+ No tasks found. +
+ Your search criteria are not valid for this item. +
+ + +
+
+
+
+
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + +
+ No users found. +
+ Your search criteria are not valid for this item. +
+ + +
+
+
+
+ + \ No newline at end of file diff --git a/webclient/src/app/search/template/typeahead_criteria_item.html b/webclient/src/app/search/template/typeahead_criteria_item.html new file mode 100644 index 0000000..68f11df --- /dev/null +++ b/webclient/src/app/search/template/typeahead_criteria_item.html @@ -0,0 +1,18 @@ + + +  {{match.model.title}} + + +  {{match.model.title}} + + +  {{match.model.title}} + + +  {{match.model.title}} + + +  {{match.model.type}} + + \ No newline at end of file diff --git a/webclient/src/app/services/criteria/criteria.js b/webclient/src/app/services/criteria/criteria.js new file mode 100644 index 0000000..85f7317 --- /dev/null +++ b/webclient/src/app/services/criteria/criteria.js @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * A service which centralizes management of search criteria: Creation, + * validation, filtering, criteria-to-parameter mapping, and more. + */ +angular.module('db.services').service('Criteria', + function ($q, $log, $injector, Preference) { + 'use strict'; + + return { + + /** + * This method takes a set of criteria, and filters out the + * ones not valid for the passed resource. + * + * @param resourceName The name of the resource to filter for. + * @param criteria The list of criteria. + * @return {Array} A map of URL parameters. + */ + filterCriteria: function (resourceName, criteria) { + + var resource = $injector.get(resourceName); + + // Sanity check: If we don't have this resource, wat? + if (!resource || !resource.hasOwnProperty('criteriaFilter')) { + $log.warn('Attempting to filter criteria for unknown ' + + 'resource "' + resourceName + '"'); + return []; + } + + return resource.criteriaFilter(criteria); + }, + + /** + * This method takes a set of criteria, and maps them against the + * query parameters available for the provided resource. It will + * skip any items not valid for this resource, and return an + * array of criteria that are valid + * + * @param resourceName + * @param criteria + * @return A map of URL parameters. + */ + mapCriteria: function (resourceName, criteria) { + var resource = $injector.get(resourceName); + + // Sanity check: If we don't have this resource, wat? + if (!resource || !resource.hasOwnProperty('criteriaMap')) { + $log.warn('Attempting to map criteria for unknown ' + + 'resource "' + resourceName + '"'); + return {}; + } + + return resource.criteriaMap(criteria); + }, + + /** + * Create a new build criteria object. + * + * @param type The type of the criteria tag. + * @param value Value of the tag. Unique DB ID, or text string. + * @param title The title of the criteria tag. + * @returns {Criteria} + */ + create: function (type, value, title) { + title = title || value; + return { + 'type': type, + 'value': value, + 'title': title + }; + }, + + /** + * Rather than actually performing a search, this method returns a + * customized lambda that will perform our browse search for us. + * + * @param types An array of resource types to browse. + * @param pageSize An optional page size for the criteria. Defaults + * to the global page_size preference. + */ + buildCriteriaSearch: function (types, pageSize) { + pageSize = pageSize || Preference.get('page_size'); + + var resolvers = []; + types.forEach(function (type) { + // Retrieve an instance of the declared resource. + var resource = $injector.get(type); + + if (!resource.hasOwnProperty('criteriaResolvers')) { + $log.warn('Resource type "' + type + + '" does not implement criteriaResolvers.'); + return; + } + + resource.criteriaResolvers().forEach(function (resolver) { + if (resolvers.indexOf(resolver) === -1) { + resolvers.push(resolver); + } + }); + }); + + /** + * Construct the search lambda that issues the search + * and assembles the results. + */ + return function (searchString) { + var deferred = $q.defer(); + + // Clear the criteria + var promises = []; + + resolvers.forEach(function (resolver) { + promises.push(resolver(searchString, pageSize)); + }); + + // Wrap everything into a collective promise + $q.all(promises).then(function (results) { + var criteria = []; + + results.forEach(function (result) { + result.forEach(function (item) { + criteria.push(item); + }); + }); + deferred.resolve(criteria); + }); + + // Return the search promise. + return deferred.promise; + }; + }, + + /** + * This method takes a set of criteria, and filters out the + * ones not valid for the passed resource. + * + * @param parameterMap A map of criteria types and parameters + * in the search query they correspond to. + * @return {Function} A criteria filter for the passed parameters. + */ + buildCriteriaFilter: function (parameterMap) { + return function (criteria) { + var filteredCriteria = []; + + criteria.forEach(function (item) { + if (parameterMap.hasOwnProperty(item.type)) { + filteredCriteria.push(item); + } + }); + return filteredCriteria; + }; + }, + + /** + * This method takes a set of criteria, and maps them against the + * query parameters available for the provided resource. It will + * skip any items not valid for this resource, and return an + * array of criteria that are valid + * + * @param parameterMap A map of criteria types and parameters + * in the search query they correspond to. + * @return {Function} A criteria mapper for the passed parameters. + */ + buildCriteriaMap: function (parameterMap) { + return function (criteria) { + var params = {}; + + criteria.forEach(function (item) { + if (parameterMap.hasOwnProperty(item.type)) { + params[parameterMap[item.type]] = item.value; + } + }); + return params; + }; + } + }; + } +); \ No newline at end of file diff --git a/webclient/src/app/services/criteria/text.js b/webclient/src/app/services/criteria/text.js new file mode 100644 index 0000000..9e0e638 --- /dev/null +++ b/webclient/src/app/services/criteria/text.js @@ -0,0 +1,19 @@ +angular.module('db.services').factory('Text', + function (Criteria, $q) { + 'use strict'; + + /** + * Return a text search parameter constructed from the passed search + * string. + */ + return { + criteriaResolver: function (searchString) { + searchString = 'q=' + searchString; + var deferred = $q.defer(); + + deferred.resolve([Criteria.create('Text', searchString)]); + + return deferred.promise; + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/services/http/http_cache_handler.js b/webclient/src/app/services/http/http_cache_handler.js new file mode 100644 index 0000000..798c5dd --- /dev/null +++ b/webclient/src/app/services/http/http_cache_handler.js @@ -0,0 +1,41 @@ +angular.module('db.services').factory('httpCacheHandler', + function ($q, $cacheFactory) { + 'use strict'; + + var $httpDefaultCache = $cacheFactory.get('$http'); + + return { + /** + * Handle a success response. + */ + response: function (response) { + var method = response.config.method; + var url = response.config.url; + var obj = response.data; + + // Ignore GET methods. + switch (method) { + case 'POST': + if (obj.hasOwnProperty('id')) { + $httpDefaultCache.put(url + '/' + obj.id, obj); + } + break; + case 'PUT': + $httpDefaultCache.put(url, obj); + break; + case 'DELETE': + $httpDefaultCache.remove(url); + break; + default: + break; + } + + return response; + } + }; + }) + // Attach the HTTP interceptor. + .config(function ($httpProvider) { + 'use strict'; + $httpProvider.interceptors.push('httpCacheHandler'); + }); \ No newline at end of file diff --git a/webclient/src/app/services/module.js b/webclient/src/app/services/module.js new file mode 100644 index 0000000..3326e30 --- /dev/null +++ b/webclient/src/app/services/module.js @@ -0,0 +1 @@ +angular.module('db.services', ['ngResource', 'db.notification']); \ No newline at end of file diff --git a/webclient/src/app/services/notification/http_message_filter.js b/webclient/src/app/services/notification/http_message_filter.js new file mode 100644 index 0000000..31f1f87 --- /dev/null +++ b/webclient/src/app/services/notification/http_message_filter.js @@ -0,0 +1,74 @@ +angular.module('db.services') + .run(function ($log, Notification, Priority) { + 'use strict'; + + /** + * Template load requests are done via $http, so we need to filter + * those out first. + */ + $log.info('starting notification controller') + function filterTemplateRequests(message) { + if (message.type !== 'http') { + return; + } + + var request = message.cause; + var url = request.config.url; + + if (url.substr(-5) === '.html') { + return true; + } + } + + /** + * A notification interceptor that filters successful HTTP requests. + * It's registered at priority 999 (the lowest) so that other + * interceptors can get access to this message first (ex: statistics). + */ + function filterSuccessful(message) { + var response = message.cause; + if (message.type !== 'http' || !response) { + return; + } + + // All 200 requests are filtered out. + if (response.status === 200) { + return true; + } + } + + /** + * A notification interceptor that rewrites HTTP status codes to + * human readable messages. + */ + function rewriteHttpStatus(message) { + + if (message.type !== 'http') { + // Do nothing. + return; + } + + var httpStatus = message.message; + var request = message.cause; + + if (!httpStatus || !request || !request.data) { + return; + } + var data = request.data; + var method = request.config.method; + var url = request.config.url; + + message.message = httpStatus + ': ' + method + ' ' + url + ': '; + + if (data.hasOwnProperty('faultstring')) { + message.message += data.faultstring; + } else { + message.message += 'No error details available.'; + } + } + + // Apply the interceptors. + Notification.intercept(filterTemplateRequests, Priority.BEFORE); + Notification.intercept(filterSuccessful, Priority.LAST); + Notification.intercept(rewriteHttpStatus, Priority.AFTER); + }); \ No newline at end of file diff --git a/webclient/src/app/services/provider/radar_api_base.js b/webclient/src/app/services/provider/radar_api_base.js new file mode 100644 index 0000000..54a9fbc --- /dev/null +++ b/webclient/src/app/services/provider/radar_api_base.js @@ -0,0 +1 @@ +angular.module('db.services').constant('radarApiBase', '/v1'); \ No newline at end of file diff --git a/webclient/src/app/services/resource/operator.js b/webclient/src/app/services/resource/operator.js new file mode 100644 index 0000000..36fb2d1 --- /dev/null +++ b/webclient/src/app/services/resource/operator.js @@ -0,0 +1,20 @@ +angular.module('db.services').factory('Operator', + function (ResourceFactory) { + 'use strict'; + + var resource = ResourceFactory.build( + '/operators/:id', + '/operators/search', + {id: '@id'}, + {marker: '@marker'} + ); + + ResourceFactory.applySearch( + 'Operator', + resource, + 'operator_name', + {Text: 'q'} + ); + + return resource; + }); \ No newline at end of file diff --git a/webclient/src/app/services/resource/preference.js b/webclient/src/app/services/resource/preference.js new file mode 100644 index 0000000..ee76f43 --- /dev/null +++ b/webclient/src/app/services/resource/preference.js @@ -0,0 +1,95 @@ +angular.module('db.services').provider('Preference', + function () { + 'use strict'; + + /** + * Our preference defaults. We're using underscore naming here in + * anticipation of these keys living on the python side of things. + */ + var defaults = { }; + + /** + * Preference name key generator. Basically it's poor man's + * namespacing. + */ + function preferenceName(key) { + return 'pref_' + key; + } + + /** + * Each module can manually declare its own preferences that it would + * like to keep track of, as well as set a default. During the config() + * phase, inject the Preference Provider and call 'addPreference()' to + * do so. An example is available at the bottom of this file. + */ + this.addPreference = function (preferenceName, preferenceDefault) { + defaults[preferenceName] = preferenceDefault; + }; + + /** + * The actual preference implementation. + */ + function Preference($log, localStorageService) { + /** + * Get a preference. + */ + this.get = function (key) { + + // Is this a valid preference? + if (!defaults.hasOwnProperty(key)) { + $log.warn('Attempt to get unregistered preference: ' + + key); + return null; + } + + var value = localStorageService.get(preferenceName(key)); + + // If the value is unset, and we have a default, set and use + // that. + if (value === null && defaults.hasOwnProperty(key)) { + var defaultValue = defaults[key]; + this.set(key, defaultValue); + return defaultValue; + } + + return value; + }; + + /** + * Set a preference. + */ + this.set = function (key, value) { + + // Is this a valid preference? + if (!defaults.hasOwnProperty(key)) { + $log.warn('Attempt to set unregistered preference: ' + + key); + return null; + } + + return localStorageService.set(preferenceName(key), value); + }; + } + + /** + * Factory getter - returns a configured instance of preference + * provider, as needed. + */ + this.$get = function ($log, localStorageService) { + return new Preference($log, localStorageService); + }; + }) + .config(function (PreferenceProvider) { + 'use strict'; + + // WARNING: In all modules OTHER than the services module, this config + // block can appear anywhere as long as this module is listed as a + // dependency. In the services module, the config() block must appear + // AFTER the provider block. For more information, + // @see https://github.com/angular/angular.js/issues/6723 + + // Let our preference provider know about page_size. + PreferenceProvider.addPreference('page_size', 10); + + }) +; \ No newline at end of file diff --git a/webclient/src/app/services/resource/subscription.js b/webclient/src/app/services/resource/subscription.js new file mode 100644 index 0000000..e6e69de --- /dev/null +++ b/webclient/src/app/services/resource/subscription.js @@ -0,0 +1,10 @@ +angular.module('db.services').factory('Subscription', + function (ResourceFactory) { + 'use strict'; + + return ResourceFactory.build( + '/subscriptions/:id', + '/subscriptions/search', + {id: '@id'} + ); + }); \ No newline at end of file diff --git a/webclient/src/app/services/resource/system.js b/webclient/src/app/services/resource/system.js new file mode 100644 index 0000000..ad3d1c6 --- /dev/null +++ b/webclient/src/app/services/resource/system.js @@ -0,0 +1,20 @@ +angular.module('db.services').factory('System', + function (ResourceFactory) { + 'use strict'; + + var resource = ResourceFactory.build( + '/systems/:id', + '/systems/search', + {id: '@id'}, + {marker: '@marker'} + ); + + ResourceFactory.applySearch( + 'System', + resource, + 'name', + {Text: 'q'} + ); + + return resource; + }); \ No newline at end of file diff --git a/webclient/src/app/services/resource/user.js b/webclient/src/app/services/resource/user.js new file mode 100644 index 0000000..1cf763a --- /dev/null +++ b/webclient/src/app/services/resource/user.js @@ -0,0 +1,19 @@ +angular.module('db.services').factory('User', + function (ResourceFactory) { + 'use strict'; + + var resource = ResourceFactory.build( + '/users/:id', + '/users/search', + {id: '@id'} + ); + + ResourceFactory.applySearch( + 'User', + resource, + 'full_name', + {Text: 'q'} + ); + + return resource; + }); \ No newline at end of file diff --git a/webclient/src/app/services/service/resource_factory.js b/webclient/src/app/services/service/resource_factory.js new file mode 100644 index 0000000..4918719 --- /dev/null +++ b/webclient/src/app/services/service/resource_factory.js @@ -0,0 +1,178 @@ +angular.module('db.services') + .service('ResourceFactory', + function ($q, $log, $injector, Criteria, $resource, radarApiBase, + Preference) { + 'use strict'; + + /** + * This method is used in our API signature to return a recent value + * for the user's pageSize preference. + * + * @returns {*} + */ + function getLimit() { + return Preference.get('page_size'); + + } + + /** + * Construct a full API signature for a specific resource. Includes + * CRUD, Browse, and Search. If the resource doesn't support it, + * don't use it :). + * + * @param searchUrl + * @returns An API signature that may be used with a $resource. + */ + function buildSignature(searchUrl) { + return { + 'create': { + method: 'POST' + }, + 'read': { + method: 'GET', + cache: false + }, + 'update': { + method: 'PUT' + }, + 'delete': { + method: 'DELETE' + }, + 'browse': { + method: 'GET', + isArray: true, + responseType: 'json', + params: { + limit: getLimit + } + }, + 'search': { + method: 'GET', + url: searchUrl, + isArray: true, + responseType: 'json', + params: { + limit: getLimit + } + } + }; + } + + + return { + + /** + * Build a resource URI. + * + * @param restUri + * @param searchUri + * @param resourceParameters + * @returns {*} + */ + build: function (restUri, searchUri, resourceParameters) { + + if (!restUri) { + $log.error('Cannot use resource factory ' + + 'without a base REST uri.'); + return null; + } + + var signature = buildSignature(radarApiBase + searchUri); + return $resource(radarApiBase + restUri, + resourceParameters, signature); + }, + + /** + * This method takes an already configured resource, and applies + * the static methods necessary to support the criteria search API. + * Browse parameters should be formatted as an object containing + * 'injector name': 'param'. For example, {'Project': 'project_id'}. + * + * @param resourceName The explicit resource name of this resource + * within the injection scope. + * @param resource The configured resource. + * @param nameField The name field to use while browsing criteria. + * @param searchParameters The search parameters to apply. + */ + applySearch: function (resourceName, resource, nameField, + searchParameters) { + + // List of criteria resolvers which we're building. + var criteriaResolvers = []; + + for (var type in searchParameters) { + + // If the requested type exists and has a criteriaResolver + // method, add it to the list of resolvable browse criteria. + var typeResource = $injector.get(type); + if (!!typeResource && + typeResource.hasOwnProperty('criteriaResolver')) { + criteriaResolvers.push(typeResource.criteriaResolver); + } + } + + /** + * Return a list of promise-returning methods that, given a + * browse string, will provide a list of search criteria. + * + * @returns {*[]} + */ + resource.criteriaResolvers = function () { + return criteriaResolvers; + }; + + + // If we found a browse parameter, add the ability to use + // this resource as a source of criteria. + if (!!nameField) { + /** + * Add the criteria resolver method. + */ + resource.criteriaResolver = + function (searchString, pageSize) { + pageSize = pageSize || Preference.get('page_size'); + + var deferred = $q.defer(); + + // build the query parameters. + var queryParams = {}; + queryParams[nameField] = searchString; + queryParams.limit = pageSize; + + resource.query(queryParams, + function (result) { + // Transform the results to criteria tags. + var criteriaResults = []; + result.forEach(function (item) { + criteriaResults.push( + Criteria.create(resourceName, + item.id, + item[nameField]) + ); + }); + deferred.resolve(criteriaResults); + }, function () { + deferred.resolve([]); + } + ); + + return deferred.promise; + }; + } + + + /** + * The criteria filter. + */ + resource.criteriaFilter = Criteria + .buildCriteriaFilter(searchParameters); + + /** + * The criteria map. + */ + resource.criteriaMap = Criteria + .buildCriteriaMap(searchParameters); + + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/subscription/directive/subscribe.js b/webclient/src/app/subscription/directive/subscribe.js new file mode 100644 index 0000000..91546e7 --- /dev/null +++ b/webclient/src/app/subscription/directive/subscribe.js @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2013 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * A directive which checks, enables, and disables subscriptions by resource. + * + * @author Michael Krotscheck + */ +angular.module('db.util').directive('subscribe', + function (CurrentUser, Notification, Priority, SessionState, Session, + Subscription) { + 'use strict'; + + return { + restrict: 'E', + scope: { + resource: '@', + resourceId: '=' + }, + templateUrl: 'app/subscription/template/subscribe.html', + link: function ($scope) { + + /** + * When we start, create a promise for the current user. + */ + var cuPromise = CurrentUser.resolve(); + + /** + * Is this control currently enabled? + * + * @type {boolean} + */ + $scope.enabled = Session.isLoggedIn(); + + /** + * Is this user subscribed to this resource? + * + * @type {boolean} + */ + $scope.subscribed = false; + + /** + * Is the control currently trying to resolve the user's + * subscription? + * + * @type {boolean} + */ + $scope.resolving = false; + + /** + * The loaded subscription resource + * + * @type {Object} + */ + $scope.subscription = null; + + /** + * The current user. + * + * @param currentUser + */ + $scope.currentUser = null; + cuPromise.then(function (user) { + $scope.currentUser = user; + }); + + /** + * Set or clear the subscription. + */ + function setSubscription(subscription) { + $scope.subscription = subscription || null; + $scope.subscribed = !!$scope.subscription; + } + + // Subscribe to login/logout events for enable/disable/resolve. + var removeNotifier = Notification.intercept(function (message) { + switch (message.type) { + case SessionState.LOGGED_IN: + $scope.enabled = true; + resolveSubscription(); + break; + case SessionState.LOGGED_OUT: + $scope.enabled = false; + $scope.subscribed = false; + break; + } + + }, Priority.LAST); + + // Remove the notifier when this scope is destroyed. + $scope.$on('$destroy', removeNotifier); + + /** + * Resolve whether the current user already has a subscription + * to this resource. + */ + function resolveSubscription() { + + if (!Session.isLoggedIn()) { + setSubscription(); + return; + } + + $scope.resolving = true; + + cuPromise.then( + function (user) { + Subscription.browse({ + user_id: user.id, + target_type: $scope.resource, + target_id: $scope.resourceId + }, + function (results) { + setSubscription(results[0]); + $scope.resolving = false; + }, + function () { + setSubscription(); + $scope.resolving = false; + } + ); + + } + ); + } + + /** + * When the user clicks on this control, activate/deactivate the + * subscription. + */ + $scope.toggleSubscribe = function () { + if ($scope.resolving) { + return; + } + + $scope.resolving = true; + + if (!!$scope.subscription) { + $scope.subscription.$delete(function () { + setSubscription(); + $scope.resolving = false; + }, function () { + $scope.resolving = false; + }); + } else { + + cuPromise.then( + function (user) { + var sub = new Subscription({ + user_id: user.id, + target_type: $scope.resource, + target_id: $scope.resourceId + }); + sub.$create(function (result) { + setSubscription(result); + $scope.resolving = false; + }, function () { + $scope.resolving = false; + }); + } + ); + } + }; + + // On initialization, resolve. + resolveSubscription(); + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/subscription/module.js b/webclient/src/app/subscription/module.js new file mode 100644 index 0000000..5c70e98 --- /dev/null +++ b/webclient/src/app/subscription/module.js @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * The Radar subscription module. Adds directives and services necessary + * for a user to subscribe to resource changes in radar + * + */ +angular.module('db.subscription', ['db.notification']); diff --git a/webclient/src/app/subscription/template/subscribe.html b/webclient/src/app/subscription/template/subscribe.html new file mode 100644 index 0000000..e27f04b --- /dev/null +++ b/webclient/src/app/subscription/template/subscribe.html @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/webclient/src/app/systems/controller/event_filter_controller.js b/webclient/src/app/systems/controller/event_filter_controller.js new file mode 100644 index 0000000..71497ae --- /dev/null +++ b/webclient/src/app/systems/controller/event_filter_controller.js @@ -0,0 +1,23 @@ +angular.module('db.systems').controller('EventFilterController', + function($scope, $modalInstance, Preference) { + 'use strict'; + + function init() { + $scope.enabled_event_types = + Preference.get('display_events_filter'); + } + + $scope.close = function () { + $modalInstance.dismiss('cancel'); + }; + + $scope.save = function () { + Preference.set('display_events_filter', + $scope.enabled_event_types); + return $modalInstance.close($scope.enabled_event_types); + }; + + init(); + + }) +; diff --git a/webclient/src/app/systems/controller/system_detail_controller.js b/webclient/src/app/systems/controller/system_detail_controller.js new file mode 100644 index 0000000..0133dff --- /dev/null +++ b/webclient/src/app/systems/controller/system_detail_controller.js @@ -0,0 +1,76 @@ +angular.module('db.systems').controller('SystemDetailController', + function ($scope, $rootScope, $state, $stateParams, System, + Session) { + 'use strict'; + + // Parse the ID + var id = $stateParams.hasOwnProperty('id') ? + parseInt($stateParams.id, 10) : + null; + + /** + * The System we're manipulating right now. + * + * @type System + */ + $scope.System = {}; + + /** + * UI flag for when we're initially loading the view. + * + * @type {boolean} + */ + $scope.isLoading = true; + + /** + * UI view for when a change is round-tripping to the server. + * + * @type {boolean} + */ + $scope.isUpdating = false; + + /** + * Any error objects returned from the services. + * + * @type {{}} + */ + $scope.error = {}; + + /** + * Generic service error handler. Assigns errors to the view's scope, + * and unsets our flags. + */ + function handleServiceError(error) { + // We've encountered an error. + $scope.error = error; + $scope.isLoading = false; + $scope.isUpdating = false; + } + + /** + * Resets our loading flags. + */ + function handleServiceSuccess() { + $scope.isLoading = false; + $scope.isUpdating = false; + } + + /** + * Load the System + */ + function loadSystem() { + System.get( + {'id': id}, + function (result) { + // We've got a result, assign it to the view and unset our + // loading flag. + $scope.System = result; + + handleServiceSuccess(); + }, + handleServiceError + ); + } + + loadSystem(); + }); \ No newline at end of file diff --git a/webclient/src/app/systems/controller/systems_controller.js b/webclient/src/app/systems/controller/systems_controller.js new file mode 100644 index 0000000..70f6a54 --- /dev/null +++ b/webclient/src/app/systems/controller/systems_controller.js @@ -0,0 +1,57 @@ +angular.module('db.systems') +.controller('SystemsListController', + function ($scope, $q, $log, System, ngTableParams, Preference, $http, SystemsFactory, Marker) { + 'use strict'; + $scope.count=0; + $scope.data = [] + var marker=0; + $log.debug('loading systems'); + $scope.pageSize = Preference.get('page_size'); + var deferred = $q.defer(); + + var promise = SystemsFactory.getCount() + promise.then( + function (payload) { + $scope.count = payload.data; + deferred.resolve(payload.data) + }, + + function (errorPayload) { + $log.error('failure loading count', errorPayload); + } + ) + .then(function () { + $scope.tableParams = new ngTableParams({ + page: 1, // show first page + count: $scope.pageSize // count per page + }, { + counts: [], + total: $scope.count, // length of data + getData: function($defer, params) { + marker = (params.page() - 1) * params.count() + var updatePromise = SystemsFactory.updateMarker(marker) + updatePromise.then( + function(data) { + $log.debug('resolved marker') + }, + function(error) { + $log.debug('failed resolving marker: ' + error) + }) + var promise = SystemsFactory.getData(System, marker) + promise.then( + function (data) { + $defer.resolve(data) + }, + function (errorPayload) { + $log.error('failure loading system', errorPayload); + }) + } + } + ) + }, + + function (errorPayload) { + $log.error('failure loading system', errorPayload); + }) + + }) \ No newline at end of file diff --git a/webclient/src/app/systems/filter/moment_filter.js b/webclient/src/app/systems/filter/moment_filter.js new file mode 100644 index 0000000..18ec74d --- /dev/null +++ b/webclient/src/app/systems/filter/moment_filter.js @@ -0,0 +1,8 @@ +angular.module('db.systems') + .filter('moment', + function(TimeDateFormatter) { + return function(dateString) { + return TimeDateFormatter.getUtc(dateString); + } + } + ) \ No newline at end of file diff --git a/webclient/src/app/systems/module.js b/webclient/src/app/systems/module.js new file mode 100644 index 0000000..65a8ec6 --- /dev/null +++ b/webclient/src/app/systems/module.js @@ -0,0 +1,28 @@ +angular.module('db.systems', ['ui.router', 'db.services', 'db.util', 'db.auth', + 'ngTable']) + .config(function ($stateProvider, $urlRouterProvider, SessionResolver) { + 'use strict'; + + $urlRouterProvider.when('/systems', '/systems/list'); + + // Set our page routes. + $stateProvider + .state('systems', { + abstract: true, + url: '/systems', + template: '
', + resolve: { + sessionState: SessionResolver.requireLoggedIn, + currentUser: SessionResolver.requireCurrentUser + } + }) + .state('systems.list', { + url: '/list', + templateUrl: 'app/systems/template/systems_list.html', + controller: 'SystemsListController' + }) + .state('systems.detail', { + url: '/{id:[0-9]+}', + templateUrl: 'app/systems/template/system_detail.html' + }); + }) \ No newline at end of file diff --git a/webclient/src/app/systems/service/systems_service.js b/webclient/src/app/systems/service/systems_service.js new file mode 100644 index 0000000..c389c92 --- /dev/null +++ b/webclient/src/app/systems/service/systems_service.js @@ -0,0 +1,60 @@ +angular.module('db.systems') + .factory('SystemsFactory', + function ($log, $q, $http) { + var initialized=0; + var marker=0 + $log.debug('loading systems service') + return { + getData: function (resource, marker) { + var deferred = $q.defer(); + if (initialized == 0 ) { + resource.browse( + function (result) { + deferred.resolve(result); + }, function () { + deferred.resolve([]); + } + ); + initialized = 1; + + } + else { + resource.browse({marker: marker}, + function (result) { + deferred.resolve(result); + }, function () { + deferred.resolve([]); + } + ); + } + return deferred.promise; + + }, + + getCount: function() { + var deferred = $q.defer(); + $http.get('/api/v1/systems/count/') + .then(function (result) { + deferred.resolve(result) + }, + function (error) { + // We've encountered an error. + $log.debug('error: ' + error) + + deferred.resolve([]); + + }) + return deferred.promise; + }, + + updateMarker: function(newMarker) { + var deferred = $q.defer(); + deferred.promise.then(function() { + marker = newMarker; + $log.debug('marker: ' + marker) + }); + deferred.resolve(); + return deferred.promise; + } + } + }) \ No newline at end of file diff --git a/webclient/src/app/systems/template/system_detail.html b/webclient/src/app/systems/template/system_detail.html new file mode 100644 index 0000000..a1dc94d --- /dev/null +++ b/webclient/src/app/systems/template/system_detail.html @@ -0,0 +1,36 @@ +
+ +
\ No newline at end of file diff --git a/webclient/src/app/systems/template/systems_list.html b/webclient/src/app/systems/template/systems_list.html new file mode 100644 index 0000000..6dc71b3 --- /dev/null +++ b/webclient/src/app/systems/template/systems_list.html @@ -0,0 +1,47 @@ +

Third-Party CI Systems

+ + + + + + + + +
+ + + + {{system.name}} + + + + + {{system.created_at | moment}} + + {{system.updated_at | moment}} +
+ diff --git a/webclient/src/app/util/directive/active_path.js b/webclient/src/app/util/directive/active_path.js new file mode 100644 index 0000000..8a93b19 --- /dev/null +++ b/webclient/src/app/util/directive/active_path.js @@ -0,0 +1,27 @@ +angular.module('db.util').directive('activePath', + function ($location, $rootScope) { + 'use strict'; + + return { + link: function ($scope, element, attrs) { + var activePath = attrs.activePath; + + function setActivePath() { + var path = $location.path(); + var isMatchedPath = path.match(activePath) !== null; + + element.toggleClass('active', isMatchedPath); + } + + // This is angularjs magic, the return method from any $on + // binding will return a function that will disconnect + // that binding. + var disconnectBinding = + $rootScope.$on('$stateChangeSuccess', setActivePath); + $scope.$on('$destroy', disconnectBinding); + + // INIT + setActivePath(); + } + }; + }); \ No newline at end of file diff --git a/webclient/src/app/util/helper/marker_util.js b/webclient/src/app/util/helper/marker_util.js new file mode 100644 index 0000000..3192563 --- /dev/null +++ b/webclient/src/app/util/helper/marker_util.js @@ -0,0 +1,23 @@ +angular.module('db.util') +.factory('Marker', + function ($log) { + + 'use strict'; + return { + /** + * Helper to generate a random alphanumeric string for the state + * parameter. + * + * @param length The length of the string to generate. + * @returns {string} A random alphanumeric string. + */ + + setMarker: function(input) { + var first = input[0]; + $log.debug('marker: ' + first.id) + return first.id; + } + + + } +}); \ No newline at end of file diff --git a/webclient/src/app/util/helper/string_util.js b/webclient/src/app/util/helper/string_util.js new file mode 100644 index 0000000..5731d89 --- /dev/null +++ b/webclient/src/app/util/helper/string_util.js @@ -0,0 +1,46 @@ +angular.module('db.util').factory('StringUtil', + function () { + 'use strict'; + + var defaultLength = 32; // MD5 length. Seems decent. + var alphaNumeric = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + 'abcdefghijklmnopqrstuvwxyz' + + '0123456789'; + + return { + /** + * Helper to generate a random alphanumeric string for the state + * parameter. + * + * @param length The length of the string to generate. + * @returns {string} A random alphanumeric string. + */ + randomAlphaNumeric: function (length) { + return this.random(length, alphaNumeric); + }, + + /** + * Helper to generate a random string of specified length, using a + * provided list of characters. + * + * @param length The length of the string to generate. + * @param characters The list of valid characters. + * @returns {string} A random string composed of provided + * characters. + */ + random: function (length, characters) { + length = length || defaultLength; + characters = characters || alphaNumeric; + + var text = ''; + + for (var i = 0; i < length; i++) { + text += characters.charAt(Math.floor( + Math.random() * characters.length)); + } + + return text; + } + }; + } +); diff --git a/webclient/src/app/util/helper/timedate_util.js b/webclient/src/app/util/helper/timedate_util.js new file mode 100644 index 0000000..7afdfe0 --- /dev/null +++ b/webclient/src/app/util/helper/timedate_util.js @@ -0,0 +1,11 @@ +angular.module('db.util').factory('TimeDateFormatter', + function () { + + 'use strict'; + return { + getUtc: function(input) { + return moment(input, moment.ISO_8601).utc().format("YYYY-MM-DD HH:mm:ss") + ' UTC' + } + } +}); + \ No newline at end of file diff --git a/webclient/src/app/util/helper/url_util.js b/webclient/src/app/util/helper/url_util.js new file mode 100644 index 0000000..574aeda --- /dev/null +++ b/webclient/src/app/util/helper/url_util.js @@ -0,0 +1,66 @@ +angular.module('db.util').factory('UrlUtil', + function ($location) { + 'use strict'; + + return { + /** + * Return the full URL prefix of the application, without the #! + * component. + */ + getFullUrlPrefix: function () { + var protocol = $location.protocol(); + var host = $location.host(); + var port = $location.port(); + + return protocol + '://' + host + ':' + port; + }, + + /** + * Build a HashBang url for this application given the provided + * fragment. + */ + buildApplicationUrl: function (fragment) { + return this.getFullUrlPrefix() + '/#!' + fragment; + }, + + /** + * Serialize an object into HTTP parameters. + */ + serializeParameters: function (params) { + var pairs = []; + for (var prop in params) { + // Filter out system params. + if (!params.hasOwnProperty(prop)) { + continue; + } + pairs.push( + encodeURIComponent(prop) + + '=' + + encodeURIComponent(params[prop]) + ); + } + return pairs.join('&'); + }, + + + /** + * Deserialize URI query parameters into an object. + */ + deserializeParameters: function (queryString) { + + var params = {}; + var queryComponents = queryString.split('&'); + for (var i = 0; i < queryComponents.length; i++) { + var parts = queryComponents[i].split('='); + var key = decodeURIComponent(parts[0]) || null; + var value = decodeURIComponent(parts[1]) || null; + + if (!!key && !!value) { + params[key] = value; + } + } + return params; + } + }; + } +); \ No newline at end of file diff --git a/webclient/src/app/util/module.js b/webclient/src/app/util/module.js new file mode 100644 index 0000000..8483e40 --- /dev/null +++ b/webclient/src/app/util/module.js @@ -0,0 +1,11 @@ +angular.module('db.util', ['ui.router', 'LocalStorageModule', 'angularMoment']) + .run(function () { + 'use strict'; + angular.element.prototype.hide = function () { + this.addClass('ng-hide'); + }; + + angular.element.prototype.show = function () { + this.removeClass('ng-hide'); + }; + }); \ No newline at end of file diff --git a/webclient/src/app/util/provider/search_param_provider.js b/webclient/src/app/util/provider/search_param_provider.js new file mode 100644 index 0000000..d70c83c --- /dev/null +++ b/webclient/src/app/util/provider/search_param_provider.js @@ -0,0 +1,15 @@ +angular.module('db.util').factory('$searchParams', + function ($window, UrlUtil) { + 'use strict'; + + var params = {}; + var search = $window.location.search; + if (!!search) { + if (search.charAt(0) === '?') { + search = search.substr(1); + } + + return UrlUtil.deserializeParameters(search); + } + return params; + }); \ No newline at end of file diff --git a/webclient/src/app/util/service/last_location.js b/webclient/src/app/util/service/last_location.js new file mode 100644 index 0000000..ebe53a0 --- /dev/null +++ b/webclient/src/app/util/service/last_location.js @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2014 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * A service that keeps track of the last page we visited. + */ +angular.module('db.util') + .factory('LastLocation', function ($rootScope, localStorageService) { + 'use strict'; + + /** + * The last detected length of the history + */ + + // When the location changes, store the new one. Since the $location + // object changes too quickly, we instead extract the hash manually. + function onLocationChange(event, toLocation) { + var url = new URL(toLocation); + var hash = url.hash || '#!/'; + var trimmed_hash = hash.slice(2); + if (trimmed_hash.indexOf('/auth') === -1) { + localStorageService.set('lastLocation', trimmed_hash); + } + + } + + // The published API. + return { + + /** + * Get the recorded history path at the provided index. + */ + get: function () { + return localStorageService.get('lastLocation'); + }, + + /** + * Initialize this service. + */ + _initialize: function () { + // Register (and disconnect) our listener. + $rootScope.$on('$destroy', + $rootScope.$on('$locationChangeStart', onLocationChange) + ); + } + }; + }) + .run(function (LastLocation) { + 'use strict'; + + // Initialize this service. + LastLocation._initialize(); + }); diff --git a/webclient/src/config.json b/webclient/src/config.json new file mode 100644 index 0000000..9c06145 --- /dev/null +++ b/webclient/src/config.json @@ -0,0 +1 @@ +{"radarApiBase": "/api/v1"} \ No newline at end of file diff --git a/webclient/src/index.html b/webclient/src/index.html new file mode 100644 index 0000000..04b4799 --- /dev/null +++ b/webclient/src/index.html @@ -0,0 +1,42 @@ + + + + + + + + + Radar Third Party Dashboard + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + diff --git a/webclient/src/theme/css/bootstrap-table.css b/webclient/src/theme/css/bootstrap-table.css new file mode 100644 index 0000000..c7681e5 --- /dev/null +++ b/webclient/src/theme/css/bootstrap-table.css @@ -0,0 +1,237 @@ +/* +* bootstrap-table - v1.4.0 - 2014-11-21 +* https://github.com/wenzhixin/bootstrap-table +* Copyright (c) 2014 zhixin wen +* Licensed MIT License +*/ + +.table { + margin-bottom: 0 !important; + border-bottom: 1px solid #ddd; + border-collapse: collapse !important; + border-radius: 1px +} + +.fixed-table-container { + position: relative; + clear: both; + border: 1px solid #ddd; + border-radius: 4px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px +} + +.fixed-table-header { + overflow: hidden; + border-radius: 4px 4px 0 0; + -webkit-border-radius: 4px 4px 0 0; + -moz-border-radius: 4px 4px 0 0 +} + +.fixed-table-body { + overflow-x: auto; + overflow-y: auto; + height: 100% +} + +.fixed-table-container table { + width: 100% +} + +.fixed-table-container thead th { + height: 0; + padding: 0; + margin: 0; + border-left: 1px solid #ddd +} + +.fixed-table-container thead th:first-child { + border-left: none; + border-top-left-radius: 4px; + -webkit-border-top-left-radius: 4px; + -moz-border-radius-topleft: 4px +} + +.fixed-table-container thead th .th-inner { + padding: 8px; + line-height: 24px; + vertical-align: top; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap +} + +.fixed-table-container thead th .sortable { + cursor: pointer +} + +.fixed-table-container tbody td { + border-left: 1px solid #ddd +} + +.fixed-table-container tbody tr:first-child td { + border-top: none +} + +.fixed-table-container tbody td:first-child { + border-left: none +} + +.fixed-table-container tbody .selected td { + background-color: #f5f5f5 +} + +.fixed-table-container .bs-checkbox { + text-align: center +} + +.fixed-table-container .bs-checkbox .th-inner { + padding: 8px 0 +} + +.fixed-table-container input[type=checkbox], .fixed-table-container input[type=radio] { + margin: 0 auto !important +} + +.fixed-table-container .no-records-found { + text-align: center +} + +.fixed-table-pagination .pagination, .fixed-table-pagination .pagination-detail { + margin-top: 10px; + margin-bottom: 10px +} + +.fixed-table-pagination .pagination a { + padding: 6px 12px; + line-height: 1.428571429 +} + +.fixed-table-pagination .pagination-info { + line-height: 34px; + margin-right: 5px +} + +.fixed-table-pagination .btn-group { + position: relative; + display: inline-block; + vertical-align: middle +} + +.fixed-table-pagination .dropup .dropdown-menu { + margin-bottom: 0 +} + +.fixed-table-pagination .page-list { + display: inline-block +} + +.fixed-table-toolbar .columns-left { + margin-right: 5px +} + +.fixed-table-toolbar .columns-right { + margin-left: 5px +} + +.fixed-table-toolbar .columns label { + display: block; + padding: 3px 20px; + clear: both; + font-weight: 400; + line-height: 1.428571429 +} + +.fixed-table-toolbar .bars, .fixed-table-toolbar .columns, .fixed-table-toolbar .search { + position: relative; + margin-top: 10px; + margin-bottom: 10px; + line-height: 34px +} + +.fixed-table-pagination li.disabled a { + pointer-events: none; + cursor: default +} + +.fixed-table-loading { + display: none; + position: absolute; + top: 42px; + right: 0; + bottom: 0; + left: 0; + z-index: 99; + background-color: #fff; + text-align: center +} + +.fixed-table-body .card-view .title { + font-weight: 700; + display: inline-block; + min-width: 30%; + text-align: left !important +} + +.fixed-table-body thead th .th-inner { + box-sizing: border-box +} + +.table td, .table th { + vertical-align: middle; + box-sizing: border-box +} + +.fixed-table-toolbar .dropdown-menu { + text-align: left; + max-height: 300px; + overflow: auto +} + +.fixed-table-toolbar .btn-group>.btn-group { + display: inline-block; + margin-left: -1px !important +} + +.fixed-table-toolbar .btn-group>.btn-group>.btn { + border-radius: 0 +} + +.fixed-table-toolbar .btn-group>.btn-group:first-child>.btn { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px +} + +.fixed-table-toolbar .btn-group>.btn-group:last-child>.btn { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px +} + +.table>thead>tr>th { + vertical-align: bottom; + border-bottom: 2px solid #ddd +} + +.table thead>tr>th { + padding: 0; + margin: 0 +} + +.pull-right .dropdown-menu { + right: 0; + left: auto +} + +p.fixed-table-scroll-inner { + width: 100%; + height: 200px +} + +div.fixed-table-scroll-outer { + top: 0; + left: 0; + visibility: hidden; + width: 200px; + height: 150px; + overflow: hidden +} diff --git a/webclient/src/theme/css/bootstrap.css b/webclient/src/theme/css/bootstrap.css new file mode 100644 index 0000000..62be186 --- /dev/null +++ b/webclient/src/theme/css/bootstrap.css @@ -0,0 +1,6840 @@ +/*! + * Bootstrap v3.3.1 (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: 700 +} + +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 silver +} + +legend { + padding: 0; + border: 0 +} + +textarea { + overflow: auto +} + +optgroup { + font-weight: 700 +} + +table { + border-spacing: 0; + border-collapse: collapse +} + +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 #ddd !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.woff) format('woff'), url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'), 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: 400; + 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: #333; + background-color: #fff +} + +input, button, select, textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit +} + +a { + color: #b74e33; + text-decoration: none +} + +a:hover, a:focus { + color: #632D1C; + 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: 400; + line-height: 1; + color: #777 +} + +h1, .h1, h2, .h2, h3, .h3 { + margin-top: 20px; + 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: 36px +} + +h2, .h2 { + font-size: 30px +} + +h3, .h3 { + font-size: 24px +} + +h4, .h4 { + font-size: 18px +} + +h5, .h5 { + font-size: 14px +} + +h6, .h6 { + font-size: 12px +} + +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: #337ab7 +} + +a.text-primary:hover { + color: #286090 +} + +.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: #337ab7 +} + +a.bg-primary:hover { + background-color: #286090 +} + +.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: 40px 0 20px; + 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: 700 +} + +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: 700; + -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; + margin-right: auto; + margin-left: auto +} + +@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: bottom; + 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 #ddd +} + +.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 #ddd +} + +.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: 700 +} + +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: #555; + 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 +} + +@media screen and (-webkit-min-device-pixel-ratio:0) { + input[type=date], input[type=time], input[type=datetime-local], + input[type=month] { + line-height: 34px + } + + input[type=date].input-sm, input[type=time].input-sm, input[type=datetime-local].input-sm, + input[type=month].input-sm { + line-height: 30px + } + + input[type=date].input-lg, input[type=time].input-lg, input[type=datetime-local].input-lg, + input[type=month].input-lg { + line-height: 46px + } +} + +.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: 400; + 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: 400; + 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 +} + +@media (min-width:768px) { + .form-horizontal .control-label { + padding-top: 7px; + margin-bottom: 0; + text-align: right + } +} + +.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: 400; + 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: #337ab7; + border-color: #2e6da4 +} + +.btn-primary:hover, .btn-primary:focus, .btn-primary.focus, .btn-primary:active, .btn-primary.active, .open>.dropdown-toggle.btn-primary { + color: #fff; + background-color: #286090; + border-color: #204d74 +} + +.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: #337ab7; + border-color: #2e6da4 +} + +.btn-primary .badge { + color: #337ab7; + 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: 400; + color: #337ab7; + 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: #23527c; + 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: 400; + 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: #337ab7; + 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+.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: 400; + 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 { + 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: #337ab7 +} + +.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 +} + +@media (min-width:768px) { + .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 +} + +@media (min-width:768px) { + .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: #337ab7 +} + +.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 +} + +@media (min-width:768px) { + .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 +} + +@media (min-width:768px) { + .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: 50px; + margin-bottom: 20px; + 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: 50px; + padding: 15px 15px; + font-size: 18px; + line-height: 20px +} + +.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 +} + +@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 + } +} + +@media (min-width:768px) { + .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-top-left-radius: 4px; + border-top-right-radius: 4px; + 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 +} + +@media (min-width:768px) { + .navbar-text { + float: left; + margin-right: 15px; + margin-left: 15px + } +} + +@media (min-width:768px) { + .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: #222; + border-color: #080808 +} + +.navbar-inverse .navbar-brand { + color: #9d9d9d +} + +.navbar-inverse .navbar-brand:hover, .navbar-inverse .navbar-brand:focus { + color: #fff; + background-color: transparent +} + +.navbar-inverse .navbar-text { + color: #9d9d9d +} + +.navbar-inverse .navbar-nav>li>a { + color: #9d9d9d +} + +.navbar-inverse .navbar-nav>li>a:hover, .navbar-inverse .navbar-nav>li>a:focus { + color: #fff; + 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: #337ab7; + 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: #23527c; + 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: #337ab7; + border-color: #337ab7 +} + +.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: 700; + 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: #337ab7 +} + +.label-primary[href]:hover, .label-primary[href]:focus { + background-color: #286090 +} + +.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: 700; + 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 +} + +.list-group-item.active>.badge, .nav-pills>.active>a>.badge { + color: #337ab7; + background-color: #fff +} + +.list-group-item>.badge { + float: right +} + +.list-group-item>.badge+.badge { + margin-right: 5px +} + +.nav-pills>li>a>.badge { + margin-left: 3px +} + +.jumbotron { + padding: 30px 15px; + margin-bottom: 30px; + 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, .container-fluid .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: #337ab7 +} + +.thumbnail .caption { + padding: 9px; + color: #333 +} + +.alert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px +} + +.alert h4 { + margin-top: 0; + color: inherit +} + +.alert .alert-link { + font-weight: 700 +} + +.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: #337ab7; + -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 +} + +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: #337ab7; + border-color: #337ab7 +} + +.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: #c7ddef +} + +.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 +} + +.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: #337ab7 +} + +.panel-primary>.panel-heading { + color: #fff; + background-color: #337ab7; + border-color: #337ab7 +} + +.panel-primary>.panel-heading+.panel-collapse>.panel-body { + border-top-color: #337ab7 +} + +.panel-primary>.panel-heading .badge { + color: #337ab7; + background-color: #fff +} + +.panel-primary>.panel-footer+.panel-collapse>.panel-body { + border-bottom-color: #337ab7 +} + +.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: 700; + 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: 0 0; + 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: absolute; + top: 0; + right: 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.43px; + 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-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 12px; + font-weight: 400; + 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 { + right: 5px; + bottom: 0; + margin-bottom: -5px; + border-width: 5px 5px 0; + border-top-color: #000 +} + +.tooltip.top-right .tooltip-arrow { + bottom: 0; + left: 5px; + margin-bottom: -5px; + 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; + right: 5px; + margin-top: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000 +} + +.tooltip.bottom-right .tooltip-arrow { + top: 0; + left: 5px; + margin-top: -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-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + font-weight: 400; + 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 + } +} + + + diff --git a/webclient/src/theme/css/dashboard.css b/webclient/src/theme/css/dashboard.css new file mode 100644 index 0000000..983515d --- /dev/null +++ b/webclient/src/theme/css/dashboard.css @@ -0,0 +1,377 @@ +/* + * Base structure + */ + +/* Move down content because we have a fixed navbar that is 50px tall */ +body { + padding-top: 50px; +} + + +/* + * Global add-ons + */ + +.sub-header { + padding-bottom: 10px; + border-bottom: 1px solid #eee; +} + +/* + * Top navigation + * Hide default border to remove 1px line. + */ +.navbar-fixed-top { + border: 0; +} + +/* + * Sidebar + */ + +/* Hide for mobile, show later */ +.sidebar { + display: none; +} +@media (min-width: 768px) { + .sidebar { + position: fixed; + top: 51px; + bottom: 0; + left: 0; + z-index: 1000; + display: block; + padding: 20px; + overflow-x: hidden; + overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ + background-color: #f5f5f5; + border-right: 1px solid #eee; + } +} + +/* Sidebar navigation */ +.nav-sidebar { + margin-right: -21px; /* 20px padding + 1px border */ + margin-bottom: 20px; + margin-left: -20px; +} +.nav-sidebar > li > a { + padding-right: 20px; + padding-left: 20px; +} +.nav-sidebar > .active > a, +.nav-sidebar > .active > a:hover, +.nav-sidebar > .active > a:focus { + color: #fff; + background-color: #AB3D02; +} + + +/* + * Main content + */ + +.main { + padding: 20px; +} +@media (min-width: 768px) { + .main { + padding-right: 40px; + padding-left: 40px; + } +} +.main .page-header { + margin-top: 0; +} + + +/* + * Placeholder dashboard ideas + */ + +.placeholders { + margin-bottom: 30px; + text-align: center; +} +.placeholders h4 { + margin-bottom: 0; +} +.placeholder { + margin-bottom: 20px; +} +.placeholder img { + display: inline-block; + border-radius: 50%; +} + +.logged-in .editable { + cursor: pointer +} + +.logged-in .editable:hover { + background-color: #eee +} + +.notification-container { + position: absolute; + z-index: 2000 +} + +@media (max-width:767px) { + .notification-container { + top: 10px; + right: 10px; + left: 10px + } +} + +@media (min-width:768px) and (max-width:991px) { + .notification-container { + top: 10px; + right: 10px; + width: 400px + } +} + +@media (min-width:992px) and (max-width:1199px) { + .notification-container { + top: 10px; + right: 10px; + width: 400px + } +} + +@media (min-width:1200px) { + .notification-container { + top: 10px; + right: 10px; + width: 400px + } +} + +.notification-container .alert { + box-shadow: 3px 3px 10px rgba(0, 0, 0, .5) +} +.navbar.navbar-fixed-side { + overflow: hidden; + border-radius: 0; + position: fixed; + margin: 0 +} + +.navbar.navbar-fixed-side.navbar-fixed-side-header-offset { + top: 50px +} + +.navbar.navbar-fixed-side.navbar-fixed-side-footer-offset { + bottom: 50px +} + +@media (max-width:767px) { + .navbar.navbar-fixed-side { + width: 50px; + top: 0; + right: 0; + bottom: 0 + } + + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu { + width: 90px + } + + .navbar.navbar-fixed-side .nav-submenu { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 40px + } +} + +@media (min-width:768px) and (max-width:991px) { + .navbar.navbar-fixed-side { + width: 50px; + top: 0; + left: 0; + bottom: 0 + } + + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu { + width: 90px + } + + .navbar.navbar-fixed-side .nav-submenu { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 40px + } +} + +@media (min-width:992px) and (max-width:1199px) { + .navbar.navbar-fixed-side { + width: 90px; + top: 0; + left: 0; + bottom: 0 + } + + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu { + width: 130px + } + + .navbar.navbar-fixed-side .nav-submenu { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 40px + } +} + +@media (min-width:1200px) { + .navbar.navbar-fixed-side { + width: 90px; + top: 0; + left: 0; + bottom: 0 + } + + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu { + width: 130px + } + + .navbar.navbar-fixed-side .nav-submenu { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 40px + } +} + +.navbar.navbar-fixed-side .nav-main { + padding: 5px 0 +} + +.navbar.navbar-fixed-side .nav-submenu { + display: none; + background-color: #555 +} + +.navbar.navbar-fixed-side .nav-submenu .nav.nav-pills>li { + margin-right: 0; + margin-left: 0 +} + +.navbar.navbar-fixed-side .nav-submenu .nav.nav-pills>li>a { + border-radius: 0; + background: 0 0 +} + +.navbar.navbar-fixed-side .nav-submenu .nav.nav-pills>li>a:hover { + background: 0 0 +} + +.navbar.navbar-fixed-side.navbar-inverse .nav>li { + margin-right: 5px; + margin-left: 5px +} + +.navbar.navbar-fixed-side.navbar-inverse .nav>li>a { + color: #777; + background: #222; + border-color: #080808; + padding: 10px 5px +} + +.navbar.navbar-fixed-side.navbar-inverse .nav>li>a:hover { + color: #fff; + background: 0 0; + border-color: #080808 +} + +.navbar.navbar-fixed-side.navbar-inverse .nav>li>a:active { + color: #fff; + background: #555; + border-color: #080808 +} + +.navbar.navbar-fixed-side.navbar-inverse .nav>li.active>a, .navbar.navbar-fixed-side.navbar-inverse .nav>li.active>a:active, .navbar.navbar-fixed-side.navbar-inverse .nav>li.active>a:hover { + color: #fff; + background: #555 +} + +.navbar.navbar-fixed-side.navbar-inverse .nav li.active { + background-color: #222 +} + +.navbar.navbar-fixed-side a { + color: #777 +} + +.navbar.navbar-fixed-side a:active, .navbar.navbar-fixed-side a:hover { + color: #fff +} + +.navbar.navbar-fixed-side .nav-bottom { + position: absolute; + bottom: 0 +} + +.navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-submenu { + display: inherit +} + +@media (max-width:767px) { + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-main { + margin-left: 40px + } + + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-main a { + border-top-left-radius: 0; + border-bottom-left-radius: 0 + } + + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-main li.active { + margin-left: 0 + } + + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-main li.active a { + padding-left: 10px + } +} + +@media (min-width:768px) and (max-width:991px) { + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-main { + margin-right: 40px + } +} + +@media (min-width:992px) and (max-width:1199px) { + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-main { + margin-right: 40px + } +} + +@media (min-width:1200px) { + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-main { + margin-right: 40px + } +} + +@media (min-width:768px) { + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-main li.active { + margin-right: 0 + } + + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-main li.active a { + padding-right: 10px + } + + .navbar.navbar-fixed-side.navbar-fixed-side-has-submenu .nav-main a { + border-top-right-radius: 0; + border-bottom-right-radius: 0 + } +} \ No newline at end of file diff --git a/webclient/src/theme/js/jquery.js b/webclient/src/theme/js/jquery.js new file mode 100644 index 0000000..7e0da6b --- /dev/null +++ b/webclient/src/theme/js/jquery.js @@ -0,0 +1,3181 @@ +/*! jQuery v1.11.1 | (c) 2005, 2014 jQuery Foundation, Inc. | jquery.org/license */ +!function(a, b) { + "object" == typeof module && "object" == typeof module.exports ? module.exports = a.document ? b(a, !0) : function(a) { + if (!a.document) + throw new Error("jQuery requires a window with a document"); + return b(a) + } : b(a) +}("undefined" != typeof window ? window : this, function(a, b) { + var c = [], d = c.slice, e = c.concat, f = c.push, g = c.indexOf, h = {}, i = h.toString, j = h.hasOwnProperty, k = {}, l = "1.11.1", m = function(a, b) { + return new m.fn.init(a, b) + }, n = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, o = /^-ms-/, p = /-([\da-z])/gi, q = function(a, b) { + return b.toUpperCase() + }; + m.fn = m.prototype = { + jquery: l, + constructor: m, + selector: "", + length: 0, + toArray: function() { + return d.call(this) + }, + get: function(a) { + return null != a ? 0 > a ? this[a + this.length] : this[a] : d.call(this) + }, + pushStack: function(a) { + var b = m.merge(this.constructor(), a); + return b.prevObject = this, b.context = this.context, b + }, + each: function(a, b) { + return m.each(this, a, b) + }, + map: function(a) { + return this.pushStack(m.map(this, function(b, c) { + return a.call(b, c, b) + })) + }, + slice: function() { + return this.pushStack(d.apply(this, arguments)) + }, + first: function() { + return this.eq(0) + }, + last: function() { + return this.eq( - 1) + }, + eq: function(a) { + var b = this.length, c =+ a + (0 > a ? b : 0); + return this.pushStack(c >= 0 && b > c ? [this[c]] : []) + }, + end: function() { + return this.prevObject || this.constructor(null) + }, + push: f, + sort: c.sort, + splice: c.splice + }, m.extend = m.fn.extend = function() { + var a, b, c, d, e, f, g = arguments[0] || {}, h = 1, i = arguments.length, j=!1; + for ("boolean" == typeof g && (j = g, g = arguments[h] || {}, h++), "object" == typeof g || m.isFunction(g) || (g = {}), h === i && (g = this, h--); i > h; h++) + if (null != (e = arguments[h])) + for (d in e) + a = g[d], c = e[d], g !== c && (j && c && (m.isPlainObject(c) || (b = m.isArray(c))) ? (b ? (b=!1, f = a && m.isArray(a) ? a : []) : f = a && m.isPlainObject(a) ? a : {}, g[d] = m.extend(j, f, c)) : void 0 !== c && (g[d] = c)); + return g + }, m.extend({ + expando: "jQuery" + (l + Math.random()).replace(/\D/g, ""), + isReady: !0, + error: function(a) { + throw new Error(a) + }, + noop: function() {}, + isFunction: function(a) { + return "function" === m.type(a) + }, + isArray: Array.isArray || function(a) { + return "array" === m.type(a) + }, + isWindow: function(a) { + return null != a && a == a.window + }, + isNumeric: function(a) { + return !m.isArray(a) && a - parseFloat(a) >= 0 + }, + isEmptyObject: function(a) { + var b; + for (b in a) + return !1; + return !0 + }, + isPlainObject: function(a) { + var b; + if (!a || "object" !== m.type(a) || a.nodeType || m.isWindow(a)) + return !1; + try { + if (a.constructor&&!j.call(a, "constructor")&&!j.call(a.constructor.prototype, "isPrototypeOf")) + return !1 + } catch (c) { + return !1 + } + if (k.ownLast) + for (b in a) + return j.call(a, b); + for (b in a); + return void 0 === b || j.call(a, b) + }, + type: function(a) { + return null == a ? a + "" : "object" == typeof a || "function" == typeof a ? h[i.call(a)] || "object" : typeof a + }, + globalEval: function(b) { + b && m.trim(b) && (a.execScript || function(b) { + a.eval.call(a, b) + })(b) + }, + camelCase: function(a) { + return a.replace(o, "ms-").replace(p, q) + }, + nodeName: function(a, b) { + return a.nodeName && a.nodeName.toLowerCase() === b.toLowerCase() + }, + each: function(a, b, c) { + var d, e = 0, f = a.length, g = r(a); + if (c) { + if (g) { + for (; f > e; e++) + if (d = b.apply(a[e], c), d===!1) + break + } else + for (e in a) + if (d = b.apply(a[e], c), d===!1) + break + } else if (g) { + for (; f > e; e++) + if (d = b.call(a[e], e, a[e]), d===!1) + break + } else + for (e in a) + if (d = b.call(a[e], e, a[e]), d===!1) + break; + return a + }, + trim: function(a) { + return null == a ? "" : (a + "").replace(n, "") + }, + makeArray: function(a, b) { + var c = b || []; + return null != a && (r(Object(a)) ? m.merge(c, "string" == typeof a ? [a] : a) : f.call(c, a)), c + }, + inArray: function(a, b, c) { + var d; + if (b) { + if (g) + return g.call(b, a, c); + for (d = b.length, c = c ? 0 > c ? Math.max(0, d + c) : c : 0; d > c; c++) + if (c in b && b[c] === a) + return c + } + return - 1 + }, + merge: function(a, b) { + var c =+ b.length, d = 0, e = a.length; + while (c > d) + a[e++] = b[d++]; + if (c !== c) + while (void 0 !== b[d]) + a[e++] = b[d++]; + return a.length = e, a + }, + grep: function(a, b, c) { + for (var d, e = [], f = 0, g = a.length, h=!c; g > f; f++) + d=!b(a[f], f), d !== h && e.push(a[f]); + return e + }, + map: function(a, b, c) { + var d, f = 0, g = a.length, h = r(a), i = []; + if (h) + for (; g > f; f++) + d = b(a[f], f, c), null != d && i.push(d); + else + for (f in a) + d = b(a[f], f, c), null != d && i.push(d); + return e.apply([], i) + }, + guid: 1, + proxy: function(a, b) { + var c, e, f; + return "string" == typeof b && (f = a[b], b = a, a = f), m.isFunction(a) ? (c = d.call(arguments, 2), e = function() { + return a.apply(b || this, c.concat(d.call(arguments))) + }, e.guid = a.guid = a.guid || m.guid++, e) : void 0 + }, + now: function() { + return + new Date + }, + support: k + }), m.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(a, b) { + h["[object " + b + "]"] = b.toLowerCase() + }); + function r(a) { + var b = a.length, c = m.type(a); + return "function" === c || m.isWindow(a)?!1 : 1 === a.nodeType && b?!0 : "array" === c || 0 === b || "number" == typeof b && b > 0 && b - 1 in a + } + var s = function(a) { + var b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u = "sizzle" +- new Date, v = a.document, w = 0, x = 0, y = gb(), z = gb(), A = gb(), B = function(a, b) { + return a === b && (l=!0), 0 + }, C = "undefined", D = 1<<31, E = {}.hasOwnProperty, F = [], G = F.pop, H = F.push, I = F.push, J = F.slice, K = F.indexOf || function(a) { + for (var b = 0, c = this.length; c > b; b++) + if (this[b] === a) + return b; + return - 1 + }, L = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped", M = "[\\x20\\t\\r\\n\\f]", N = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+", O = N.replace("w", "w#"), P = "\\[" + M + "*(" + N + ")(?:" + M + "*([*^$|!~]?=)" + M + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + O + "))|)" + M + "*\\]", Q = ":(" + N + ")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|" + P + ")*)|.*)\\)|)", R = new RegExp("^" + M + "+|((?:^|[^\\\\])(?:\\\\.)*)" + M + "+$", "g"), S = new RegExp("^" + M + "*," + M + "*"), T = new RegExp("^" + M + "*([>+~]|" + M + ")" + M + "*"), U = new RegExp("=" + M + "*([^\\]'\"]*?)" + M + "*\\]", "g"), V = new RegExp(Q), W = new RegExp("^" + O + "$"), X = { + ID: new RegExp("^#(" + N + ")"), + CLASS: new RegExp("^\\.(" + N + ")"), + TAG: new RegExp("^(" + N.replace("w", "w*") + ")"), + ATTR: new RegExp("^" + P), + PSEUDO: new RegExp("^" + Q), + CHILD: new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + M + "*(even|odd|(([+-]|)(\\d*)n|)" + M + "*(?:([+-]|)" + M + "*(\\d+)|))" + M + "*\\)|)", "i"), + bool: new RegExp("^(?:" + L + ")$", "i"), + needsContext: new RegExp("^" + M + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + M + "*((?:-\\d)?\\d*)" + M + "*\\)|)(?=[^-]|$)", "i") + }, Y = /^(?:input|select|textarea|button)$/i, Z = /^h\d$/i, $ = /^[^{]+\{\s*\[native \w/, _ = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, ab = /[+~]/, bb = /'|\\/g, cb = new RegExp("\\\\([\\da-f]{1,6}" + M + "?|(" + M + ")|.)", "ig"), db = function(a, b, c) { + var d = "0x" + b - 65536; + return d !== d || c ? b : 0 > d ? String.fromCharCode(d + 65536) : String.fromCharCode(d>>10 | 55296, 1023 & d | 56320) + }; + try { + I.apply(F = J.call(v.childNodes), v.childNodes), F[v.childNodes.length].nodeType + } catch (eb) { + I = { + apply: F.length ? function(a, b) { + H.apply(a, J.call(b)) + } + : function(a, b) { + var c = a.length, d = 0; + while (a[c++] = b[d++]); + a.length = c - 1 + } + } + } + function fb(a, b, d, e) { + var f, h, j, k, l, o, r, s, w, x; + if ((b ? b.ownerDocument || b : v) !== n && m(b), b = b || n, d = d || [], !a || "string" != typeof a) + return d; + if (1 !== (k = b.nodeType) && 9 !== k) + return []; + if (p&&!e) { + if (f = _.exec(a)) + if (j = f[1]) { + if (9 === k) { + if (h = b.getElementById(j), !h ||!h.parentNode) + return d; + if (h.id === j) + return d.push(h), d + } else if (b.ownerDocument && (h = b.ownerDocument.getElementById(j)) && t(b, h) && h.id === j) + return d.push(h), d + } else { + if (f[2]) + return I.apply(d, b.getElementsByTagName(a)), d; + if ((j = f[3]) && c.getElementsByClassName && b.getElementsByClassName) + return I.apply(d, b.getElementsByClassName(j)), d + } + if (c.qsa && (!q ||!q.test(a))) { + if (s = r = u, w = b, x = 9 === k && a, 1 === k && "object" !== b.nodeName.toLowerCase()) { + o = g(a), (r = b.getAttribute("id")) ? s = r.replace(bb, "\\$&") : b.setAttribute("id", s), s = "[id='" + s + "'] ", l = o.length; + while (l--) + o[l] = s + qb(o[l]); + w = ab.test(a) && ob(b.parentNode) || b, x = o.join(",") + } + if (x) + try { + return I.apply(d, w.querySelectorAll(x)), d + } catch (y) {} finally { + r || b.removeAttribute("id") + } + } + } + return i(a.replace(R, "$1"), b, d, e) + } + function gb() { + var a = []; + function b(c, e) { + return a.push(c + " ") > d.cacheLength && delete b[a.shift()], b[c + " "] = e + } + return b + } + function hb(a) { + return a[u]=!0, a + } + function ib(a) { + var b = n.createElement("div"); + try { + return !!a(b) + } catch (c) { + return !1 + } finally { + b.parentNode && b.parentNode.removeChild(b), b = null + } + } + function jb(a, b) { + var c = a.split("|"), e = a.length; + while (e--) + d.attrHandle[c[e]] = b + } + function kb(a, b) { + var c = b && a, d = c && 1 === a.nodeType && 1 === b.nodeType && (~b.sourceIndex || D) - (~a.sourceIndex || D); + if (d) + return d; + if (c) + while (c = c.nextSibling) + if (c === b) + return - 1; + return a ? 1 : - 1 + } + function lb(a) { + return function(b) { + var c = b.nodeName.toLowerCase(); + return "input" === c && b.type === a + } + } + function mb(a) { + return function(b) { + var c = b.nodeName.toLowerCase(); + return ("input" === c || "button" === c) && b.type === a + } + } + function nb(a) { + return hb(function(b) { + return b =+ b, hb(function(c, d) { + var e, f = a([], c.length, b), g = f.length; + while (g--) + c[e = f[g]] && (c[e]=!(d[e] = c[e])) + }) + }) + } + function ob(a) { + return a && typeof a.getElementsByTagName !== C && a + } + c = fb.support = {}, f = fb.isXML = function(a) { + var b = a && (a.ownerDocument || a).documentElement; + return b ? "HTML" !== b.nodeName : !1 + }, m = fb.setDocument = function(a) { + var b, e = a ? a.ownerDocument || a: v, g = e.defaultView; + return e !== n && 9 === e.nodeType && e.documentElement ? (n = e, o = e.documentElement, p=!f(e), g && g !== g.top && (g.addEventListener ? g.addEventListener("unload", function() { + m() + }, !1) : g.attachEvent && g.attachEvent("onunload", function() { + m() + })), c.attributes = ib(function(a) { + return a.className = "i", !a.getAttribute("className") + }), c.getElementsByTagName = ib(function(a) { + return a.appendChild(e.createComment("")), !a.getElementsByTagName("*").length + }), c.getElementsByClassName = $.test(e.getElementsByClassName) && ib(function(a) { + return a.innerHTML = "
", a.firstChild.className = "i", 2 === a.getElementsByClassName("i").length + }), c.getById = ib(function(a) { + return o.appendChild(a).id = u, !e.getElementsByName ||!e.getElementsByName(u).length + }), c.getById ? (d.find.ID = function(a, b) { + if (typeof b.getElementById !== C && p) { + var c = b.getElementById(a); + return c && c.parentNode ? [c] : [] + } + }, d.filter.ID = function(a) { + var b = a.replace(cb, db); + return function(a) { + return a.getAttribute("id") === b + } + }) : (delete d.find.ID, d.filter.ID = function(a) { + var b = a.replace(cb, db); + return function(a) { + var c = typeof a.getAttributeNode !== C && a.getAttributeNode("id"); + return c && c.value === b + } + }), d.find.TAG = c.getElementsByTagName ? function(a, b) { + return typeof b.getElementsByTagName !== C ? b.getElementsByTagName(a) : void 0 + } : function(a, b) { + var c, d = [], e = 0, f = b.getElementsByTagName(a); + if ("*" === a) { + while (c = f[e++]) + 1 === c.nodeType && d.push(c); + return d + } + return f + }, d.find.CLASS = c.getElementsByClassName && function(a, b) { + return typeof b.getElementsByClassName !== C && p ? b.getElementsByClassName(a) : void 0 + }, r = [], q = [], (c.qsa = $.test(e.querySelectorAll)) && (ib(function(a) { + a.innerHTML = "", a.querySelectorAll("[msallowclip^='']").length && q.push("[*^$]=" + M + "*(?:''|\"\")"), a.querySelectorAll("[selected]").length || q.push("\\[" + M + "*(?:value|" + L + ")"), a.querySelectorAll(":checked").length || q.push(":checked") + }), ib(function(a) { + var b = e.createElement("input"); + b.setAttribute("type", "hidden"), a.appendChild(b).setAttribute("name", "D"), a.querySelectorAll("[name=d]").length && q.push("name" + M + "*[*^$|!~]?="), a.querySelectorAll(":enabled").length || q.push(":enabled", ":disabled"), a.querySelectorAll("*,:x"), q.push(",.*:") + })), (c.matchesSelector = $.test(s = o.matches || o.webkitMatchesSelector || o.mozMatchesSelector || o.oMatchesSelector || o.msMatchesSelector)) && ib(function(a) { + c.disconnectedMatch = s.call(a, "div"), s.call(a, "[s!='']:x"), r.push("!=", Q) + }), q = q.length && new RegExp(q.join("|")), r = r.length && new RegExp(r.join("|")), b = $.test(o.compareDocumentPosition), t = b || $.test(o.contains) ? function(a, b) { + var c = 9 === a.nodeType ? a.documentElement: a, d = b && b.parentNode; + return a === d ||!(!d || 1 !== d.nodeType ||!(c.contains ? c.contains(d) : a.compareDocumentPosition && 16 & a.compareDocumentPosition(d))) + } : function(a, b) { + if (b) + while (b = b.parentNode) + if (b === a) + return !0; + return !1 + }, B = b ? function(a, b) { + if (a === b) + return l=!0, 0; + var d=!a.compareDocumentPosition-!b.compareDocumentPosition; + return d ? d : (d = (a.ownerDocument || a) === (b.ownerDocument || b) ? a.compareDocumentPosition(b) : 1, 1 & d ||!c.sortDetached && b.compareDocumentPosition(a) === d ? a === e || a.ownerDocument === v && t(v, a)?-1 : b === e || b.ownerDocument === v && t(v, b) ? 1 : k ? K.call(k, a) - K.call(k, b) : 0 : 4 & d?-1 : 1) + } : function(a, b) { + if (a === b) + return l=!0, 0; + var c, d = 0, f = a.parentNode, g = b.parentNode, h = [a], i = [b]; + if (!f ||!g) + return a === e?-1 : b === e ? 1 : f?-1 : g ? 1 : k ? K.call(k, a) - K.call(k, b) : 0; + if (f === g) + return kb(a, b); + c = a; + while (c = c.parentNode) + h.unshift(c); + c = b; + while (c = c.parentNode) + i.unshift(c); + while (h[d] === i[d]) + d++; + return d ? kb(h[d], i[d]) : h[d] === v?-1 : i[d] === v ? 1 : 0 + }, e) : n + }, fb.matches = function(a, b) { + return fb(a, null, null, b) + }, fb.matchesSelector = function(a, b) { + if ((a.ownerDocument || a) !== n && m(a), b = b.replace(U, "='$1']"), !(!c.matchesSelector ||!p || r && r.test(b) || q && q.test(b))) + try { + var d = s.call(a, b); + if (d || c.disconnectedMatch || a.document && 11 !== a.document.nodeType) + return d + } catch (e) {} + return fb(b, n, null, [a]).length > 0 + }, fb.contains = function(a, b) { + return (a.ownerDocument || a) !== n && m(a), t(a, b) + }, fb.attr = function(a, b) { + (a.ownerDocument || a) !== n && m(a); + var e = d.attrHandle[b.toLowerCase()], f = e && E.call(d.attrHandle, b.toLowerCase()) ? e(a, b, !p): void 0; + return void 0 !== f ? f : c.attributes ||!p ? a.getAttribute(b) : (f = a.getAttributeNode(b)) && f.specified ? f.value : null + }, fb.error = function(a) { + throw new Error("Syntax error, unrecognized expression: " + a) + }, fb.uniqueSort = function(a) { + var b, d = [], e = 0, f = 0; + if (l=!c.detectDuplicates, k=!c.sortStable && a.slice(0), a.sort(B), l) { + while (b = a[f++]) + b === a[f] && (e = d.push(f)); + while (e--) + a.splice(d[e], 1) + } + return k = null, a + }, e = fb.getText = function(a) { + var b, c = "", d = 0, f = a.nodeType; + if (f) { + if (1 === f || 9 === f || 11 === f) { + if ("string" == typeof a.textContent) + return a.textContent; + for (a = a.firstChild; a; a = a.nextSibling) + c += e(a) + } else if (3 === f || 4 === f) + return a.nodeValue + } else + while (b = a[d++]) + c += e(b); + return c + }, d = fb.selectors = { + cacheLength: 50, + createPseudo: hb, + match: X, + attrHandle: {}, + find: {}, + relative: { + ">": { + dir: "parentNode", + first: !0 + }, + " ": { + dir: "parentNode" + }, + "+": { + dir: "previousSibling", + first: !0 + }, + "~": { + dir: "previousSibling" + } + }, + preFilter: { + ATTR: function(a) { + return a[1] = a[1].replace(cb, db), a[3] = (a[3] || a[4] || a[5] || "").replace(cb, db), "~=" === a[2] && (a[3] = " " + a[3] + " "), a.slice(0, 4) + }, + CHILD: function(a) { + return a[1] = a[1].toLowerCase(), "nth" === a[1].slice(0, 3) ? (a[3] || fb.error(a[0]), a[4] =+ (a[4] ? a[5] + (a[6] || 1) : 2 * ("even" === a[3] || "odd" === a[3])), a[5] =+ (a[7] + a[8] || "odd" === a[3])) : a[3] && fb.error(a[0]), a + }, + PSEUDO: function(a) { + var b, c=!a[6] && a[2]; + return X.CHILD.test(a[0]) ? null : (a[3] ? a[2] = a[4] || a[5] || "" : c && V.test(c) && (b = g(c, !0)) && (b = c.indexOf(")", c.length - b) - c.length) && (a[0] = a[0].slice(0, b), a[2] = c.slice(0, b)), a.slice(0, 3)) + } + }, + filter: { + TAG: function(a) { + var b = a.replace(cb, db).toLowerCase(); + return "*" === a ? function() { + return !0 + } : function(a) { + return a.nodeName && a.nodeName.toLowerCase() === b + } + }, + CLASS: function(a) { + var b = y[a + " "]; + return b || (b = new RegExp("(^|" + M + ")" + a + "(" + M + "|$)")) && y(a, function(a) { + return b.test("string" == typeof a.className && a.className || typeof a.getAttribute !== C && a.getAttribute("class") || "") + }) + }, + ATTR: function(a, b, c) { + return function(d) { + var e = fb.attr(d, a); + return null == e ? "!=" === b : b ? (e += "", "=" === b ? e === c : "!=" === b ? e !== c : "^=" === b ? c && 0 === e.indexOf(c) : "*=" === b ? c && e.indexOf(c)>-1 : "$=" === b ? c && e.slice( - c.length) === c : "~=" === b ? (" " + e + " ").indexOf(c)>-1 : "|=" === b ? e === c || e.slice(0, c.length + 1) === c + "-" : !1) : !0 + } + }, + CHILD: function(a, b, c, d, e) { + var f = "nth" !== a.slice(0, 3), g = "last" !== a.slice( - 4), h = "of-type" === b; + return 1 === d && 0 === e ? function(a) { + return !!a.parentNode + } : function(b, c, i) { + var j, k, l, m, n, o, p = f !== g ? "nextSibling": "previousSibling", q = b.parentNode, r = h && b.nodeName.toLowerCase(), s=!i&&!h; + if (q) { + if (f) { + while (p) { + l = b; + while (l = l[p]) + if (h ? l.nodeName.toLowerCase() === r : 1 === l.nodeType) + return !1; + o = p = "only" === a&&!o && "nextSibling" + } + return !0 + } + if (o = [g ? q.firstChild: q.lastChild], g && s) { + k = q[u] || (q[u] = {}), j = k[a] || [], n = j[0] === w && j[1], m = j[0] === w && j[2], l = n && q.childNodes[n]; + while (l=++n && l && l[p] || (m = n = 0) || o.pop() + )if (1 === l.nodeType&&++m && l === b) { + k[a] = [w, n, m]; + break + } + } else if (s && (j = (b[u] || (b[u] = {}))[a]) && j[0] === w) + m = j[1]; + else + while (l=++n && l && l[p] || (m = n = 0) || o.pop() + )if ((h ? l.nodeName.toLowerCase() === r : 1 === l.nodeType)&&++m && (s && ((l[u] || (l[u] = {}) + )[a] = [w, m]), l === b))break; + return m -= e, m === d || m%d === 0 && m / d >= 0 + } + } + }, + PSEUDO: function(a, b) { + var c, e = d.pseudos[a] || d.setFilters[a.toLowerCase()] || fb.error("unsupported pseudo: " + a); + return e[u] ? e(b) : e.length > 1 ? (c = [a, a, "", b], d.setFilters.hasOwnProperty(a.toLowerCase()) ? hb(function(a, c) { + var d, f = e(a, b), g = f.length; + while (g--) + d = K.call(a, f[g]), a[d]=!(c[d] = f[g]) + }) : function(a) { + return e(a, 0, c) + }) : e + } + }, + pseudos: { + not: hb(function(a) { + var b = [], c = [], d = h(a.replace(R, "$1")); + return d[u] ? hb(function(a, b, c, e) { + var f, g = d(a, null, e, []), h = a.length; + while (h--)(f = g[h]) && (a[h]=!(b[h] = f)) + }) : function(a, e, f) { + return b[0] = a, d(b, null, f, c), !c.pop() + } + }), has : hb(function(a) { + return function(b) { + return fb(a, b).length > 0 + } + }), contains : hb(function(a) { + return function(b) { + return (b.textContent || b.innerText || e(b)).indexOf(a)>-1 + } + }), lang : hb(function(a) { + return W.test(a || "") || fb.error("unsupported lang: " + a), a = a.replace(cb, db).toLowerCase(), function(b) { + var c; + do + if (c = p ? b.lang : b.getAttribute("xml:lang") || b.getAttribute("lang")) + return c = c.toLowerCase(), c === a || 0 === c.indexOf(a + "-"); + while ((b = b.parentNode) && 1 === b.nodeType); + return !1 + } + }), target : function(b) { + var c = a.location && a.location.hash; + return c && c.slice(1) === b.id + }, root: function(a) { + return a === o + }, focus: function(a) { + return a === n.activeElement && (!n.hasFocus || n.hasFocus())&&!!(a.type || a.href||~a.tabIndex) + }, enabled: function(a) { + return a.disabled===!1 + }, disabled: function(a) { + return a.disabled===!0 + }, checked: function(a) { + var b = a.nodeName.toLowerCase(); + return "input" === b&&!!a.checked || "option" === b&&!!a.selected + }, selected: function(a) { + return a.parentNode && a.parentNode.selectedIndex, a.selected===!0 + }, empty: function(a) { + for (a = a.firstChild; a; a = a.nextSibling) + if (a.nodeType < 6) + return !1; + return !0 + }, parent: function(a) { + return !d.pseudos.empty(a) + }, header: function(a) { + return Z.test(a.nodeName) + }, input: function(a) { + return Y.test(a.nodeName) + }, button: function(a) { + var b = a.nodeName.toLowerCase(); + return "input" === b && "button" === a.type || "button" === b + }, text: function(a) { + var b; + return "input" === a.nodeName.toLowerCase() && "text" === a.type && (null == (b = a.getAttribute("type")) || "text" === b.toLowerCase()) + }, first: nb(function() { + return [0] + }), last : nb(function(a, b) { + return [b - 1] + }), eq : nb(function(a, b, c) { + return [0 > c ? c + b: c] + }), even : nb(function(a, b) { + for (var c = 0; b > c; c += 2) + a.push(c); + return a + }), odd : nb(function(a, b) { + for (var c = 1; b > c; c += 2) + a.push(c); + return a + }), lt : nb(function(a, b, c) { + for (var d = 0 > c ? c + b : c; --d >= 0;) + a.push(d); + return a + }), gt : nb(function(a, b, c) { + for (var d = 0 > c ? c + b : c; ++d < b;) + a.push(d); + return a + }) + } + }, d.pseudos.nth = d.pseudos.eq; for (b in{ + radio : !0, checkbox : !0, file : !0, password : !0, image : !0 + })d.pseudos[b] = lb(b); + for (b in{ + submit: !0, + reset: !0 + })d.pseudos[b] = mb(b); + function pb() {} + pb.prototype = d.filters = d.pseudos, d.setFilters = new pb, g = fb.tokenize = function(a, b) { + var c, e, f, g, h, i, j, k = z[a + " "]; + if (k) + return b ? 0 : k.slice(0); + h = a, i = [], j = d.preFilter; + while (h) { + (!c || (e = S.exec(h))) && (e && (h = h.slice(e[0].length) || h), i.push(f = [])), c=!1, (e = T.exec(h)) && (c = e.shift(), f.push({ + value: c, + type: e[0].replace(R, " ") + }), h = h.slice(c.length)); + for (g in d.filter) + !(e = X[g].exec(h)) || j[g]&&!(e = j[g](e)) || (c = e.shift(), f.push({ + value: c, + type: g, + matches: e + }), h = h.slice(c.length)); + if (!c) + break + } + return b ? h.length : h ? fb.error(a) : z(a, i).slice(0) + }; + function qb(a) { + for (var b = 0, c = a.length, d = ""; c > b; b++) + d += a[b].value; + return d + } + function rb(a, b, c) { + var d = b.dir, e = c && "parentNode" === d, f = x++; + return b.first ? function(b, c, f) { + while (b = b[d]) + if (1 === b.nodeType || e) + return a(b, c, f) + } : function(b, c, g) { + var h, i, j = [w, f]; + if (g) { + while (b = b[d]) + if ((1 === b.nodeType || e) && a(b, c, g)) + return !0 + } else + while (b = b[d]) + if (1 === b.nodeType || e) { + if (i = b[u] || (b[u] = {}), (h = i[d]) && h[0] === w && h[1] === f) + return j[2] = h[2]; + if (i[d] = j, j[2] = a(b, c, g)) + return !0 + } + } + } + function sb(a) { + return a.length > 1 ? function(b, c, d) { + var e = a.length; + while (e--) + if (!a[e](b, c, d)) + return !1; + return !0 + } : a[0] + } + function tb(a, b, c) { + for (var d = 0, e = b.length; e > d; d++) + fb(a, b[d], c); + return c + } + function ub(a, b, c, d, e) { + for (var f, g = [], h = 0, i = a.length, j = null != b; i > h; h++)(f = a[h]) + && (!c || c(f, d, e)) && (g.push(f), j && b.push(h)); + return g + } + function vb(a, b, c, d, e, f) { + return d&&!d[u] && (d = vb(d)), e&&!e[u] && (e = vb(e, f)), hb(function(f, g, h, i) { + var j, k, l, m = [], n = [], o = g.length, p = f || tb(b || "*", h.nodeType ? [h] : h, []), q=!a ||!f && b ? p : ub(p, m, a, h, i), r = c ? e || (f ? a : o || d) ? [] : g : q; + if (c && c(q, r, h, i), d) { + j = ub(r, n), d(j, [], h, i), k = j.length; + while (k--)(l = j[k]) && (r[n[k]]=!(q[n[k]] = l)) + } + if (f) { + if (e || a) { + if (e) { + j = [], k = r.length; + while (k--)(l = r[k]) && j.push(q[k] = l); + e(null, r = [], j, i) + } + k = r.length; + while (k--)(l = r[k]) && (j = e ? K.call(f, l) : m[k])>-1 && (f[j]=!(g[j] = l)) + } + } else + r = ub(r === g ? r.splice(o, r.length) : r), e ? e(null, g, r, i) : I.apply(g, r) + }) + } + function wb(a) { + for (var b, c, e, f = a.length, g = d.relative[a[0].type], h = g || d.relative[" "], i = g ? 1 : 0, k = rb(function(a) { + return a === b + }, h, !0), l = rb(function(a) { + return K.call(b, a)>-1 + }, h, !0), m = [function(a, c, d) { + return !g && (d || c !== j) || ((b = c).nodeType ? k(a, c, d) : l(a, c, d)) + } + ]; f > i; i++) + if (c = d.relative[a[i].type]) + m = [rb(sb(m), c)]; + else { + if (c = d.filter[a[i].type].apply(null, a[i].matches), c[u]) { + for (e=++i; f > e; e++) + if (d.relative[a[e].type]) + break; + return vb(i > 1 && sb(m), i > 1 && qb(a.slice(0, i - 1).concat({ + value: " " === a[i - 2].type ? "*": "" + })).replace(R, "$1"), c, e > i && wb(a.slice(i, e)), f > e && wb(a = a.slice(e)), f > e && qb(a)) + } + m.push(c) + } + return sb(m) + } + function xb(a, b) { + var c = b.length > 0, e = a.length > 0, f = function(f, g, h, i, k) { + var l, m, o, p = 0, q = "0", r = f && [], s = [], t = j, u = f || e && d.find.TAG("*", k), v = w += null == t ? 1: Math.random() || .1, x = u.length; + for (k && (j = g !== n && g); q !== x && null != (l = u[q]); q++) { + if (e && l) { + m = 0; + while (o = a[m++]) + if (o(l, g, h)) { + i.push(l); + break + } + k && (w = v) + } + c && ((l=!o && l) && p--, f && r.push(l)) + } + if (p += q, c && q !== p) { + m = 0; + while (o = b[m++]) + o(r, s, g, h); + if (f) { + if (p > 0) + while (q--) + r[q] || s[q] || (s[q] = G.call(i)); + s = ub(s) + } + I.apply(i, s), k&&!f && s.length > 0 && p + b.length > 1 && fb.uniqueSort(i) + } + return k && (w = v, j = t), r + }; + return c ? hb(f) : f + } + return h = fb.compile = function(a, b) { + var c, d = [], e = [], f = A[a + " "]; + if (!f) { + b || (b = g(a)), c = b.length; + while (c--) + f = wb(b[c]), f[u] ? d.push(f) : e.push(f); + f = A(a, xb(e, d)), f.selector = a + } + return f + }, i = fb.select = function(a, b, e, f) { + var i, j, k, l, m, n = "function" == typeof a && a, o=!f && g(a = n.selector || a); + if (e = e || [], 1 === o.length) { + if (j = o[0] = o[0].slice(0), j.length > 2 && "ID" === (k = j[0]).type && c.getById && 9 === b.nodeType && p && d.relative[j[1].type]) { + if (b = (d.find.ID(k.matches[0].replace(cb, db), b) || [])[0], !b) + return e; + n && (b = b.parentNode), a = a.slice(j.shift().value.length) + } + i = X.needsContext.test(a) ? 0 : j.length; + while (i--) { + if (k = j[i], d.relative[l = k.type]) + break; + if ((m = d.find[l]) && (f = m(k.matches[0].replace(cb, db), ab.test(j[0].type) && ob(b.parentNode) || b))) { + if (j.splice(i, 1), a = f.length && qb(j), !a) + return I.apply(e, f), e; + break + } + } + } + return (n || h(a, o))(f, b, !p, e, ab.test(a) && ob(b.parentNode) || b), e + }, c.sortStable = u.split("").sort(B).join("") === u, c.detectDuplicates=!!l, m(), c.sortDetached = ib(function(a) { + return 1 & a.compareDocumentPosition(n.createElement("div")) + }), ib(function(a) { + return a.innerHTML = "", "#" === a.firstChild.getAttribute("href") + }) || jb("type|href|height|width", function(a, b, c) { + return c ? void 0 : a.getAttribute(b, "type" === b.toLowerCase() ? 1 : 2) + }), c.attributes && ib(function(a) { + return a.innerHTML = "", a.firstChild.setAttribute("value", ""), "" === a.firstChild.getAttribute("value") + }) || jb("value", function(a, b, c) { + return c || "input" !== a.nodeName.toLowerCase() ? void 0 : a.defaultValue + }), ib(function(a) { + return null == a.getAttribute("disabled") + }) || jb(L, function(a, b, c) { + var d; + return c ? void 0 : a[b]===!0 ? b.toLowerCase() : (d = a.getAttributeNode(b)) && d.specified ? d.value : null + }), fb + }(a); m.find = s, m.expr = s.selectors, m.expr[":"] = m.expr.pseudos, m.unique = s.uniqueSort, m.text = s.getText, m.isXMLDoc = s.isXML, m.contains = s.contains; var t = m.expr.match.needsContext, u = /^<(\w+)\s*\/?>(?:<\/\1>|)$/, v = /^.[^:#\[\.,]*$/; function w(a, b, c) { + if (m.isFunction(b)) + return m.grep(a, function(a, d) { + return !!b.call(a, d, a) !== c + }); + if (b.nodeType) + return m.grep(a, function(a) { + return a === b !== c + }); + if ("string" == typeof b) { + if (v.test(b)) + return m.filter(b, a, c); + b = m.filter(b, a) + } + return m.grep(a, function(a) { + return m.inArray(a, b) >= 0 !== c + }) + } + m.filter = function(a, b, c) { + var d = b[0]; + return c && (a = ":not(" + a + ")"), 1 === b.length && 1 === d.nodeType ? m.find.matchesSelector(d, a) ? [d] : [] : m.find.matches(a, m.grep(b, function(a) { + return 1 === a.nodeType + })) + }, m.fn.extend({ + find: function(a) { + var b, c = [], d = this, e = d.length; + if ("string" != typeof a) + return this.pushStack(m(a).filter(function() { + for (b = 0; e > b; b++) + if (m.contains(d[b], this)) + return !0 + })); + for (b = 0; e > b; b++) + m.find(a, d[b], c); + return c = this.pushStack(e > 1 ? m.unique(c) : c), c.selector = this.selector ? this.selector + " " + a : a, c + }, + filter: function(a) { + return this.pushStack(w(this, a || [], !1)) + }, + not: function(a) { + return this.pushStack(w(this, a || [], !0)) + }, + is: function(a) { + return !!w(this, "string" == typeof a && t.test(a) ? m(a) : a || [], !1).length + } + }); + var x, y = a.document, z = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/, A = m.fn.init = function(a, b) { + var c, d; + if (!a) + return this; + if ("string" == typeof a) { + if (c = "<" === a.charAt(0) && ">" === a.charAt(a.length - 1) && a.length >= 3 ? [null, a, null] : z.exec(a), !c ||!c[1] && b) + return !b || b.jquery ? (b || x).find(a) : this.constructor(b).find(a); + if (c[1]) { + if (b = b instanceof m ? b[0] : b, m.merge(this, m.parseHTML(c[1], b && b.nodeType ? b.ownerDocument || b : y, !0)), u.test(c[1]) && m.isPlainObject(b)) + for (c in b) + m.isFunction(this[c]) ? this[c](b[c]) : this.attr(c, b[c]); + return this + } + if (d = y.getElementById(c[2]), d && d.parentNode) { + if (d.id !== c[2]) + return x.find(a); + this.length = 1, this[0] = d + } + return this.context = y, this.selector = a, this + } + return a.nodeType ? (this.context = this[0] = a, this.length = 1, this) : m.isFunction(a) ? "undefined" != typeof x.ready ? x.ready(a) : a(m) : (void 0 !== a.selector && (this.selector = a.selector, this.context = a.context), m.makeArray(a, this)) + }; + A.prototype = m.fn, x = m(y); + var B = /^(?:parents|prev(?:Until|All))/, C = { + children: !0, + contents: !0, + next: !0, + prev: !0 + }; + m.extend({ + dir: function(a, b, c) { + var d = [], e = a[b]; + while (e && 9 !== e.nodeType && (void 0 === c || 1 !== e.nodeType ||!m(e).is(c)) + )1 === e.nodeType && d.push(e), e = e[b]; + return d + }, + sibling: function(a, b) { + for (var c = []; a; a = a.nextSibling) + 1 === a.nodeType && a !== b && c.push(a); + return c + } + }), m.fn.extend({ + has: function(a) { + var b, c = m(a, this), d = c.length; + return this.filter(function() { + for (b = 0; d > b; b++) + if (m.contains(this, c[b])) + return !0 + }) + }, + closest: function(a, b) { + for (var c, d = 0, e = this.length, f = [], g = t.test(a) || "string" != typeof a ? m(a, b || this.context) : 0; e > d; d++) + for (c = this[d]; c && c !== b; c = c.parentNode) + if (c.nodeType < 11 && (g ? g.index(c)>-1 : 1 === c.nodeType && m.find.matchesSelector(c, a))) { + f.push(c); + break + } + return this.pushStack(f.length > 1 ? m.unique(f) : f) + }, + index: function(a) { + return a ? "string" == typeof a ? m.inArray(this[0], m(a)) : m.inArray(a.jquery ? a[0] : a, this) : this[0] && this[0].parentNode ? this.first().prevAll().length : - 1 + }, + add: function(a, b) { + return this.pushStack(m.unique(m.merge(this.get(), m(a, b)))) + }, + addBack: function(a) { + return this.add(null == a ? this.prevObject : this.prevObject.filter(a)) + } + }); + function D(a, b) { + do + a = a[b]; + while (a && 1 !== a.nodeType); + return a + } + m.each({ + parent: function(a) { + var b = a.parentNode; + return b && 11 !== b.nodeType ? b : null + }, + parents: function(a) { + return m.dir(a, "parentNode") + }, + parentsUntil: function(a, b, c) { + return m.dir(a, "parentNode", c) + }, + next: function(a) { + return D(a, "nextSibling") + }, + prev: function(a) { + return D(a, "previousSibling") + }, + nextAll: function(a) { + return m.dir(a, "nextSibling") + }, + prevAll: function(a) { + return m.dir(a, "previousSibling") + }, + nextUntil: function(a, b, c) { + return m.dir(a, "nextSibling", c) + }, + prevUntil: function(a, b, c) { + return m.dir(a, "previousSibling", c) + }, + siblings: function(a) { + return m.sibling((a.parentNode || {}).firstChild, a) + }, + children: function(a) { + return m.sibling(a.firstChild) + }, + contents: function(a) { + return m.nodeName(a, "iframe") ? a.contentDocument || a.contentWindow.document : m.merge([], a.childNodes) + } + }, function(a, b) { + m.fn[a] = function(c, d) { + var e = m.map(this, b, c); + return "Until" !== a.slice( - 5) && (d = c), d && "string" == typeof d && (e = m.filter(d, e)), this.length > 1 && (C[a] || (e = m.unique(e)), B.test(a) && (e = e.reverse())), this.pushStack(e) + } + }); + var E = /\S+/g, F = {}; + function G(a) { + var b = F[a] = {}; + return m.each(a.match(E) || [], function(a, c) { + b[c]=!0 + }), b + } + m.Callbacks = function(a) { + a = "string" == typeof a ? F[a] || G(a) : m.extend({}, a); + var b, c, d, e, f, g, h = [], i=!a.once && [], j = function(l) { + for (c = a.memory && l, d=!0, f = g || 0, g = 0, e = h.length, b=!0; h && e > f; f++) + if (h[f].apply(l[0], l[1])===!1 && a.stopOnFalse) { + c=!1; + break + } + b=!1, h && (i ? i.length && j(i.shift()) : c ? h = [] : k.disable()) + }, k = { + add: function() { + if (h) { + var d = h.length; + !function f(b) { + m.each(b, function(b, c) { + var d = m.type(c); + "function" === d ? a.unique && k.has(c) || h.push(c) : c && c.length && "string" !== d && f(c) + }) + }(arguments), b ? e = h.length : c && (g = d, j(c)) + } + return this + }, + remove: function() { + return h && m.each(arguments, function(a, c) { + var d; + while ((d = m.inArray(c, h, d))>-1) + h.splice(d, 1), b && (e >= d && e--, f >= d && f--) + }), this + }, + has: function(a) { + return a ? m.inArray(a, h)>-1 : !(!h ||!h.length) + }, + empty: function() { + return h = [], e = 0, this + }, + disable: function() { + return h = i = c = void 0, this + }, + disabled: function() { + return !h + }, + lock: function() { + return i = void 0, c || k.disable(), this + }, + locked: function() { + return !i + }, + fireWith: function(a, c) { + return !h || d&&!i || (c = c || [], c = [a, c.slice ? c.slice(): c], b ? i.push(c) : j(c)), this + }, + fire: function() { + return k.fireWith(this, arguments), this + }, + fired: function() { + return !!d + } + }; + return k + }, m.extend({ + Deferred: function(a) { + var b = [["resolve", "done", m.Callbacks("once memory"), "resolved"], ["reject", "fail", m.Callbacks("once memory"), "rejected"], ["notify", "progress", m.Callbacks("memory")]], c = "pending", d = { + state: function() { + return c + }, + always: function() { + return e.done(arguments).fail(arguments), this + }, + then: function() { + var a = arguments; + return m.Deferred(function(c) { + m.each(b, function(b, f) { + var g = m.isFunction(a[b]) && a[b]; + e[f[1]](function() { + var a = g && g.apply(this, arguments); + a && m.isFunction(a.promise) ? a.promise().done(c.resolve).fail(c.reject).progress(c.notify) : c[f[0] + "With"](this === d ? c.promise() : this, g ? [a] : arguments) + }) + }), a = null + }).promise() + }, + promise: function(a) { + return null != a ? m.extend(a, d) : d + } + }, e = {}; + return d.pipe = d.then, m.each(b, function(a, f) { + var g = f[2], h = f[3]; + d[f[1]] = g.add, h && g.add(function() { + c = h + }, b[1^a][2].disable, b[2][2].lock), e[f[0]] = function() { + return e[f[0] + "With"](this === e ? d : this, arguments), this + }, e[f[0] + "With"] = g.fireWith + }), d.promise(e), a && a.call(e, e), e + }, + when: function(a) { + var b = 0, c = d.call(arguments), e = c.length, f = 1 !== e || a && m.isFunction(a.promise) ? e: 0, g = 1 === f ? a: m.Deferred(), h = function(a, b, c) { + return function(e) { + b[a] = this, c[a] = arguments.length > 1 ? d.call(arguments) : e, c === i ? g.notifyWith(b, c) : --f || g.resolveWith(b, c) + } + }, i, j, k; + if (e > 1) + for (i = new Array(e), j = new Array(e), k = new Array(e); e > b; b++) + c[b] && m.isFunction(c[b].promise) ? c[b].promise().done(h(b, k, c)).fail(g.reject).progress(h(b, j, i)) : --f; + return f || g.resolveWith(k, c), g.promise() + } + }); + var H; + m.fn.ready = function(a) { + return m.ready.promise().done(a), this + }, m.extend({ + isReady: !1, + readyWait: 1, + holdReady: function(a) { + a ? m.readyWait++ : m.ready(!0) + }, + ready: function(a) { + if (a===!0?!--m.readyWait : !m.isReady) { + if (!y.body) + return setTimeout(m.ready); + m.isReady=!0, a!==!0&&--m.readyWait > 0 || (H.resolveWith(y, [m]), m.fn.triggerHandler && (m(y).triggerHandler("ready"), m(y).off("ready"))) + } + } + }); + function I() { + y.addEventListener ? (y.removeEventListener("DOMContentLoaded", J, !1), a.removeEventListener("load", J, !1)) : (y.detachEvent("onreadystatechange", J), a.detachEvent("onload", J)) + } + function J() { + (y.addEventListener || "load" === event.type || "complete" === y.readyState) && (I(), m.ready()) + } + m.ready.promise = function(b) { + if (!H) + if (H = m.Deferred(), "complete" === y.readyState) + setTimeout(m.ready); + else if (y.addEventListener) + y.addEventListener("DOMContentLoaded", J, !1), a.addEventListener("load", J, !1); + else { + y.attachEvent("onreadystatechange", J), a.attachEvent("onload", J); + var c=!1; + try { + c = null == a.frameElement && y.documentElement + } catch (d) {} + c && c.doScroll&&!function e() { + if (!m.isReady) { + try { + c.doScroll("left") + } catch (a) { + return setTimeout(e, 50) + } + I(), m.ready() + } + }() + } + return H.promise(b) + }; + var K = "undefined", L; + for (L in m(k)) + break; + k.ownLast = "0" !== L, k.inlineBlockNeedsLayout=!1, m(function() { + var a, b, c, d; + c = y.getElementsByTagName("body")[0], c && c.style && (b = y.createElement("div"), d = y.createElement("div"), d.style.cssText = "position:absolute;border:0;width:0;height:0;top:0;left:-9999px", c.appendChild(d).appendChild(b), typeof b.style.zoom !== K && (b.style.cssText = "display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1", k.inlineBlockNeedsLayout = a = 3 === b.offsetWidth, a && (c.style.zoom = 1)), c.removeChild(d)) + }), function() { + var a = y.createElement("div"); + if (null == k.deleteExpando) { + k.deleteExpando=!0; + try { + delete a.test + } catch (b) { + k.deleteExpando=!1 + } + } + a = null + }(), m.acceptData = function(a) { + var b = m.noData[(a.nodeName + " ").toLowerCase()], c =+ a.nodeType || 1; + return 1 !== c && 9 !== c?!1 : !b || b!==!0 && a.getAttribute("classid") === b + }; + var M = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/, N = /([A-Z])/g; + function O(a, b, c) { + if (void 0 === c && 1 === a.nodeType) { + var d = "data-" + b.replace(N, "-$1").toLowerCase(); + if (c = a.getAttribute(d), "string" == typeof c) { + try { + c = "true" === c?!0 : "false" === c?!1 : "null" === c ? null : + c + "" === c?+c : M.test(c) ? m.parseJSON(c) : c + } catch (e) {} + m.data(a, b, c) + } else + c = void 0 + } + return c + } + function P(a) { + var b; + for (b in a) + if (("data" !== b ||!m.isEmptyObject(a[b])) && "toJSON" !== b) + return !1; + return !0 + } + function Q(a, b, d, e) { + if (m.acceptData(a)) { + var f, g, h = m.expando, i = a.nodeType, j = i ? m.cache: a, k = i ? a[h]: a[h] && h; + if (k && j[k] && (e || j[k].data) || void 0 !== d || "string" != typeof b) + return k || (k = i ? a[h] = c.pop() || m.guid++ : h), j[k] || (j[k] = i ? {} : { + toJSON: m.noop + }), ("object" == typeof b || "function" == typeof b) && (e ? j[k] = m.extend(j[k], b) : j[k].data = m.extend(j[k].data, b)), g = j[k], e || (g.data || (g.data = {}), g = g.data), void 0 !== d && (g[m.camelCase(b)] = d), "string" == typeof b ? (f = g[b], null == f && (f = g[m.camelCase(b)])) : f = g, f + } + } + function R(a, b, c) { + if (m.acceptData(a)) { + var d, e, f = a.nodeType, g = f ? m.cache: a, h = f ? a[m.expando]: m.expando; + if (g[h]) { + if (b && (d = c ? g[h] : g[h].data)) { + m.isArray(b) ? b = b.concat(m.map(b, m.camelCase)) : b in d ? b = [b] : (b = m.camelCase(b), b = b in d ? [b] : b.split(" ")), e = b.length; + while (e--) + delete d[b[e]]; + if (c?!P(d) : !m.isEmptyObject(d) + )return + }(c || (delete g[h].data, P(g[h]))) && (f ? m.cleanData([a], !0) : k.deleteExpando || g != g.window ? delete g[h] : g[h] = null) + } + } + } + m.extend({ + cache: {}, + noData: { + "applet ": !0, + "embed ": !0, + "object ": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000" + }, + hasData: function(a) { + return a = a.nodeType ? m.cache[a[m.expando]] : a[m.expando], !!a&&!P(a) + }, + data: function(a, b, c) { + return Q(a, b, c) + }, + removeData: function(a, b) { + return R(a, b) + }, + _data: function(a, b, c) { + return Q(a, b, c, !0) + }, + _removeData: function(a, b) { + return R(a, b, !0) + } + }), m.fn.extend({ + data: function(a, b) { + var c, d, e, f = this[0], g = f && f.attributes; + if (void 0 === a) { + if (this.length && (e = m.data(f), 1 === f.nodeType&&!m._data(f, "parsedAttrs"))) { + c = g.length; + while (c--) + g[c] && (d = g[c].name, 0 === d.indexOf("data-") && (d = m.camelCase(d.slice(5)), O(f, d, e[d]))); + m._data(f, "parsedAttrs", !0) + } + return e + } + return "object" == typeof a ? this.each(function() { + m.data(this, a) + }) : arguments.length > 1 ? this.each(function() { + m.data(this, a, b) + }) : f ? O(f, a, m.data(f, a)) : void 0 + }, + removeData: function(a) { + return this.each(function() { + m.removeData(this, a) + }) + } + }), m.extend({ + queue: function(a, b, c) { + var d; + return a ? (b = (b || "fx") + "queue", d = m._data(a, b), c && (!d || m.isArray(c) ? d = m._data(a, b, m.makeArray(c)) : d.push(c)), d || []) : void 0 + }, + dequeue: function(a, b) { + b = b || "fx"; + var c = m.queue(a, b), d = c.length, e = c.shift(), f = m._queueHooks(a, b), g = function() { + m.dequeue(a, b) + }; + "inprogress" === e && (e = c.shift(), d--), e && ("fx" === b && c.unshift("inprogress"), delete f.stop, e.call(a, g, f)), !d && f && f.empty.fire() + }, + _queueHooks: function(a, b) { + var c = b + "queueHooks"; + return m._data(a, c) || m._data(a, c, { + empty: m.Callbacks("once memory").add(function() { + m._removeData(a, b + "queue"), m._removeData(a, c) + }) + }) + } + }), m.fn.extend({ + queue: function(a, b) { + var c = 2; + return "string" != typeof a && (b = a, a = "fx", c--), arguments.length < c ? m.queue(this[0], a) : void 0 === b ? this : this.each(function() { + var c = m.queue(this, a, b); + m._queueHooks(this, a), "fx" === a && "inprogress" !== c[0] && m.dequeue(this, a) + }) + }, + dequeue: function(a) { + return this.each(function() { + m.dequeue(this, a) + }) + }, + clearQueue: function(a) { + return this.queue(a || "fx", []) + }, + promise: function(a, b) { + var c, d = 1, e = m.Deferred(), f = this, g = this.length, h = function() { + --d || e.resolveWith(f, [f]) + }; + "string" != typeof a && (b = a, a = void 0), a = a || "fx"; + while (g--) + c = m._data(f[g], a + "queueHooks"), c && c.empty && (d++, c.empty.add(h)); + return h(), e.promise(b) + } + }); + var S = /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source, T = ["Top", "Right", "Bottom", "Left"], U = function(a, b) { + return a = b || a, "none" === m.css(a, "display") ||!m.contains(a.ownerDocument, a) + }, V = m.access = function(a, b, c, d, e, f, g) { + var h = 0, i = a.length, j = null == c; + if ("object" === m.type(c)) { + e=!0; + for (h in c) + m.access(a, b, h, c[h], !0, f, g) + } else if (void 0 !== d && (e=!0, m.isFunction(d) || (g=!0), j && (g ? (b.call(a, d), b = null) : (j = b, b = function(a, b, c) { + return j.call(m(a), c) + })), b))for (; i > h; h++) + b(a[h], c, g ? d : d.call(a[h], h, b(a[h], c))); + return e ? a : j ? b.call(a) : i ? b(a[0], c) : f + }, W = /^(?:checkbox|radio)$/i; + !function() { + var a = y.createElement("input"), b = y.createElement("div"), c = y.createDocumentFragment(); + if (b.innerHTML = "
a", k.leadingWhitespace = 3 === b.firstChild.nodeType, k.tbody=!b.getElementsByTagName("tbody").length, k.htmlSerialize=!!b.getElementsByTagName("link").length, k.html5Clone = "<:nav>" !== y.createElement("nav").cloneNode(!0).outerHTML, a.type = "checkbox", a.checked=!0, c.appendChild(a), k.appendChecked = a.checked, b.innerHTML = "", k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue, c.appendChild(b), b.innerHTML = "", k.checkClone = b.cloneNode(!0).cloneNode(!0).lastChild.checked, k.noCloneEvent=!0, b.attachEvent && (b.attachEvent("onclick", function() { + k.noCloneEvent=!1 + }), b.cloneNode(!0).click()), null == k.deleteExpando) { + k.deleteExpando=!0; + try { + delete b.test + } catch (d) { + k.deleteExpando=!1 + } + } + }(), function() { + var b, c, d = y.createElement("div"); + for (b in{ + submit: !0, + change: !0, + focusin: !0 + }) + c = "on" + b, (k[b + "Bubbles"] = c in a) || (d.setAttribute(c, "t"), k[b + "Bubbles"] = d.attributes[c].expando===!1); + d = null + }(); + var X = /^(?:input|select|textarea)$/i, Y = /^key/, Z = /^(?:mouse|pointer|contextmenu)|click/, $ = /^(?:focusinfocus|focusoutblur)$/, _ = /^([^.]*)(?:\.(.+)|)$/; + function ab() { + return !0 + } + function bb() { + return !1 + } + function cb() { + try { + return y.activeElement + } catch (a) {} + } + m.event = { + global: {}, + add: function(a, b, c, d, e) { + var f, g, h, i, j, k, l, n, o, p, q, r = m._data(a); + if (r) { + c.handler && (i = c, c = i.handler, e = i.selector), c.guid || (c.guid = m.guid++), (g = r.events) || (g = r.events = {}), (k = r.handle) || (k = r.handle = function(a) { + return typeof m === K || a && m.event.triggered === a.type ? void 0 : m.event.dispatch.apply(k.elem, arguments) + }, k.elem = a), b = (b || "").match(E) || [""], h = b.length; + while (h--) + f = _.exec(b[h]) || [], o = q = f[1], p = (f[2] || "").split(".").sort(), o && (j = m.event.special[o] || {}, o = (e ? j.delegateType : j.bindType) || o, j = m.event.special[o] || {}, l = m.extend({ + type: o, + origType: q, + data: d, + handler: c, + guid: c.guid, + selector: e, + needsContext: e && m.expr.match.needsContext.test(e), + namespace: p.join(".") + }, i), (n = g[o]) || (n = g[o] = [], n.delegateCount = 0, j.setup && j.setup.call(a, d, p, k)!==!1 || (a.addEventListener ? a.addEventListener(o, k, !1) : a.attachEvent && a.attachEvent("on" + o, k))), j.add && (j.add.call(a, l), l.handler.guid || (l.handler.guid = c.guid)), e ? n.splice(n.delegateCount++, 0, l) : n.push(l), m.event.global[o]=!0); + a = null + } + }, + remove: function(a, b, c, d, e) { + var f, g, h, i, j, k, l, n, o, p, q, r = m.hasData(a) && m._data(a); + if (r && (k = r.events)) { + b = (b || "").match(E) || [""], j = b.length; + while (j--) + if (h = _.exec(b[j]) || [], o = q = h[1], p = (h[2] || "").split(".").sort(), o) { + l = m.event.special[o] || {}, o = (d ? l.delegateType : l.bindType) || o, n = k[o] || [], h = h[2] && new RegExp("(^|\\.)" + p.join("\\.(?:.*\\.|)") + "(\\.|$)"), i = f = n.length; + while (f--) + g = n[f], !e && q !== g.origType || c && c.guid !== g.guid || h&&!h.test(g.namespace) || d && d !== g.selector && ("**" !== d ||!g.selector) || (n.splice(f, 1), g.selector && n.delegateCount--, l.remove && l.remove.call(a, g)); + i&&!n.length && (l.teardown && l.teardown.call(a, p, r.handle)!==!1 || m.removeEvent(a, o, r.handle), delete k[o]) + } else + for (o in k) + m.event.remove(a, o + b[j], c, d, !0); + m.isEmptyObject(k) && (delete r.handle, m._removeData(a, "events")) + } + }, + trigger: function(b, c, d, e) { + var f, g, h, i, k, l, n, o = [d || y], p = j.call(b, "type") ? b.type: b, q = j.call(b, "namespace") ? b.namespace.split("."): []; + if (h = l = d = d || y, 3 !== d.nodeType && 8 !== d.nodeType&&!$.test(p + m.event.triggered) && (p.indexOf(".") >= 0 && (q = p.split("."), p = q.shift(), q.sort()), g = p.indexOf(":") < 0 && "on" + p, b = b[m.expando] ? b : new m.Event(p, "object" == typeof b && b), b.isTrigger = e ? 2 : 3, b.namespace = q.join("."), b.namespace_re = b.namespace ? new RegExp("(^|\\.)" + q.join("\\.(?:.*\\.|)") + "(\\.|$)") : null, b.result = void 0, b.target || (b.target = d), c = null == c ? [b] : m.makeArray(c, [b]), k = m.event.special[p] || {}, e ||!k.trigger || k.trigger.apply(d, c)!==!1) + ) { + if (!e&&!k.noBubble&&!m.isWindow(d)) { + for (i = k.delegateType || p, $.test(i + p) || (h = h.parentNode); h; h = h.parentNode) + o.push(h), l = h; + l === (d.ownerDocument || y) && o.push(l.defaultView || l.parentWindow || a) + } + n = 0; + while ((h = o[n++])&&!b.isPropagationStopped()) + b.type = n > 1 ? i : k.bindType || p, f = (m._data(h, "events") || {})[b.type] && m._data(h, "handle"), f && f.apply(h, c), f = g && h[g], f && f.apply && m.acceptData(h) && (b.result = f.apply(h, c), b.result===!1 && b.preventDefault()); + if (b.type = p, !e&&!b.isDefaultPrevented() && (!k._default || k._default.apply(o.pop(), c)===!1) && m.acceptData(d) && g && d[p]&&!m.isWindow(d)) { + l = d[g], l && (d[g] = null), m.event.triggered = p; + try { + d[p]() + } catch (r) {} + m.event.triggered = void 0, l && (d[g] = l) + } + return b.result + } + }, + dispatch: function(a) { + a = m.event.fix(a); + var b, c, e, f, g, h = [], i = d.call(arguments), j = (m._data(this, "events") || {})[a.type] || [], k = m.event.special[a.type] || {}; + if (i[0] = a, a.delegateTarget = this, !k.preDispatch || k.preDispatch.call(this, a)!==!1) { + h = m.event.handlers.call(this, a, j), b = 0; + while ((f = h[b++])&&!a.isPropagationStopped()) { + a.currentTarget = f.elem, g = 0; + while ((e = f.handlers[g++])&&!a.isImmediatePropagationStopped())(!a.namespace_re || a.namespace_re.test(e.namespace)) && (a.handleObj = e, a.data = e.data, c = ((m.event.special[e.origType] || {}).handle || e.handler).apply(f.elem, i), void 0 !== c && (a.result = c)===!1 && (a.preventDefault(), a.stopPropagation()) + ) + } + return k.postDispatch && k.postDispatch.call(this, a), a.result + } + }, + handlers: function(a, b) { + var c, d, e, f, g = [], h = b.delegateCount, i = a.target; + if (h && i.nodeType && (!a.button || "click" !== a.type)) + for (; i != this; i = i.parentNode || this) + if (1 === i.nodeType && (i.disabled!==!0 || "click" !== a.type)) { + for (e = [], f = 0; h > f; f++) + d = b[f], c = d.selector + " ", void 0 === e[c] && (e[c] = d.needsContext ? m(c, this).index(i) >= 0 : m.find(c, this, null, [i]).length), e[c] && e.push(d); + e.length && g.push({ + elem: i, + handlers: e + }) + } + return h < b.length && g.push({ + elem: this, + handlers: b.slice(h) + }), g + }, + fix: function(a) { + if (a[m.expando]) + return a; + var b, c, d, e = a.type, f = a, g = this.fixHooks[e]; + g || (this.fixHooks[e] = g = Z.test(e) ? this.mouseHooks : Y.test(e) ? this.keyHooks : {}), d = g.props ? this.props.concat(g.props) : this.props, a = new m.Event(f), b = d.length; + while (b--) + c = d[b], a[c] = f[c]; + return a.target || (a.target = f.srcElement || y), 3 === a.target.nodeType && (a.target = a.target.parentNode), a.metaKey=!!a.metaKey, g.filter ? g.filter(a, f) : a + }, + props: "altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), + fixHooks: {}, + keyHooks: { + props: "char charCode key keyCode".split(" "), + filter: function(a, b) { + return null == a.which && (a.which = null != b.charCode ? b.charCode : b.keyCode), a + } + }, + mouseHooks: { + props: "button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "), + filter: function(a, b) { + var c, d, e, f = b.button, g = b.fromElement; + return null == a.pageX && null != b.clientX && (d = a.target.ownerDocument || y, e = d.documentElement, c = d.body, a.pageX = b.clientX + (e && e.scrollLeft || c && c.scrollLeft || 0) - (e && e.clientLeft || c && c.clientLeft || 0), a.pageY = b.clientY + (e && e.scrollTop || c && c.scrollTop || 0) - (e && e.clientTop || c && c.clientTop || 0)), !a.relatedTarget && g && (a.relatedTarget = g === a.target ? b.toElement : g), a.which || void 0 === f || (a.which = 1 & f ? 1 : 2 & f ? 3 : 4 & f ? 2 : 0), a + } + }, + special: { + load: { + noBubble: !0 + }, + focus: { + trigger: function() { + if (this !== cb() && this.focus) + try { + return this.focus(), !1 + } catch (a) {} + }, + delegateType: "focusin" + }, + blur: { + trigger: function() { + return this === cb() && this.blur ? (this.blur(), !1) : void 0 + }, + delegateType: "focusout" + }, + click: { + trigger: function() { + return m.nodeName(this, "input") && "checkbox" === this.type && this.click ? (this.click(), !1) : void 0 + }, + _default: function(a) { + return m.nodeName(a.target, "a") + } + }, + beforeunload: { + postDispatch: function(a) { + void 0 !== a.result && a.originalEvent && (a.originalEvent.returnValue = a.result) + } + } + }, + simulate: function(a, b, c, d) { + var e = m.extend(new m.Event, c, { + type: a, + isSimulated: !0, + originalEvent: {} + }); + d ? m.event.trigger(e, null, b) : m.event.dispatch.call(b, e), e.isDefaultPrevented() && c.preventDefault() + } + }, m.removeEvent = y.removeEventListener ? function(a, b, c) { + a.removeEventListener && a.removeEventListener(b, c, !1) + } : function(a, b, c) { + var d = "on" + b; + a.detachEvent && (typeof a[d] === K && (a[d] = null), a.detachEvent(d, c)) + }, m.Event = function(a, b) { + return this instanceof m.Event ? (a && a.type ? (this.originalEvent = a, this.type = a.type, this.isDefaultPrevented = a.defaultPrevented || void 0 === a.defaultPrevented && a.returnValue===!1 ? ab : bb) : this.type = a, b && m.extend(this, b), this.timeStamp = a && a.timeStamp || m.now(), void(this[m.expando]=!0)) : new m.Event(a, b) + }, m.Event.prototype = { + isDefaultPrevented: bb, + isPropagationStopped: bb, + isImmediatePropagationStopped: bb, + preventDefault: function() { + var a = this.originalEvent; + this.isDefaultPrevented = ab, a && (a.preventDefault ? a.preventDefault() : a.returnValue=!1) + }, + stopPropagation: function() { + var a = this.originalEvent; + this.isPropagationStopped = ab, a && (a.stopPropagation && a.stopPropagation(), a.cancelBubble=!0) + }, + stopImmediatePropagation: function() { + var a = this.originalEvent; + this.isImmediatePropagationStopped = ab, a && a.stopImmediatePropagation && a.stopImmediatePropagation(), this.stopPropagation() + } + }, m.each({ + mouseenter: "mouseover", + mouseleave: "mouseout", + pointerenter: "pointerover", + pointerleave: "pointerout" + }, function(a, b) { + m.event.special[a] = { + delegateType: b, + bindType: b, + handle: function(a) { + var c, d = this, e = a.relatedTarget, f = a.handleObj; + return (!e || e !== d&&!m.contains(d, e)) && (a.type = f.origType, c = f.handler.apply(this, arguments), a.type = b), c + } + } + }), k.submitBubbles || (m.event.special.submit = { + setup: function() { + return m.nodeName(this, "form")?!1 : void m.event.add(this, "click._submit keypress._submit", function(a) { + var b = a.target, c = m.nodeName(b, "input") || m.nodeName(b, "button") ? b.form: void 0; + c&&!m._data(c, "submitBubbles") && (m.event.add(c, "submit._submit", function(a) { + a._submit_bubble=!0 + }), m._data(c, "submitBubbles", !0)) + }) + }, + postDispatch: function(a) { + a._submit_bubble && (delete a._submit_bubble, this.parentNode&&!a.isTrigger && m.event.simulate("submit", this.parentNode, a, !0)) + }, + teardown: function() { + return m.nodeName(this, "form")?!1 : void m.event.remove(this, "._submit") + } + }), k.changeBubbles || (m.event.special.change = { + setup: function() { + return X.test(this.nodeName) ? (("checkbox" === this.type || "radio" === this.type) && (m.event.add(this, "propertychange._change", function(a) { + "checked" === a.originalEvent.propertyName && (this._just_changed=!0) + }), m.event.add(this, "click._change", function(a) { + this._just_changed&&!a.isTrigger && (this._just_changed=!1), m.event.simulate("change", this, a, !0) + })), !1) : void m.event.add(this, "beforeactivate._change", function(a) { + var b = a.target; + X.test(b.nodeName)&&!m._data(b, "changeBubbles") && (m.event.add(b, "change._change", function(a) { + !this.parentNode || a.isSimulated || a.isTrigger || m.event.simulate("change", this.parentNode, a, !0) + }), m._data(b, "changeBubbles", !0)) + }) + }, + handle: function(a) { + var b = a.target; + return this !== b || a.isSimulated || a.isTrigger || "radio" !== b.type && "checkbox" !== b.type ? a.handleObj.handler.apply(this, arguments) : void 0 + }, + teardown: function() { + return m.event.remove(this, "._change"), !X.test(this.nodeName) + } + }), k.focusinBubbles || m.each({ + focus: "focusin", + blur: "focusout" + }, function(a, b) { + var c = function(a) { + m.event.simulate(b, a.target, m.event.fix(a), !0) + }; + m.event.special[b] = { + setup: function() { + var d = this.ownerDocument || this, e = m._data(d, b); + e || d.addEventListener(a, c, !0), m._data(d, b, (e || 0) + 1) + }, + teardown: function() { + var d = this.ownerDocument || this, e = m._data(d, b) - 1; + e ? m._data(d, b, e) : (d.removeEventListener(a, c, !0), m._removeData(d, b)) + } + } + }), m.fn.extend({ + on: function(a, b, c, d, e) { + var f, g; + if ("object" == typeof a) { + "string" != typeof b && (c = c || b, b = void 0); + for (f in a) + this.on(f, b, c, a[f], e); + return this + } + if (null == c && null == d ? (d = b, c = b = void 0) : null == d && ("string" == typeof b ? (d = c, c = void 0) : (d = c, c = b, b = void 0)), d===!1) + d = bb; + else if (!d) + return this; + return 1 === e && (g = d, d = function(a) { + return m().off(a), g.apply(this, arguments) + }, d.guid = g.guid || (g.guid = m.guid++)), this.each(function() { + m.event.add(this, a, d, c, b) + }) + }, + one: function(a, b, c, d) { + return this.on(a, b, c, d, 1) + }, + off: function(a, b, c) { + var d, e; + if (a && a.preventDefault && a.handleObj) + return d = a.handleObj, m(a.delegateTarget).off(d.namespace ? d.origType + "." + d.namespace : d.origType, d.selector, d.handler), this; + if ("object" == typeof a) { + for (e in a) + this.off(e, b, a[e]); + return this + } + return (b===!1 || "function" == typeof b) && (c = b, b = void 0), c===!1 && (c = bb), this.each(function() { + m.event.remove(this, a, c, b) + }) + }, + trigger: function(a, b) { + return this.each(function() { + m.event.trigger(a, b, this) + }) + }, + triggerHandler: function(a, b) { + var c = this[0]; + return c ? m.event.trigger(a, b, c, !0) : void 0 + } + }); + function db(a) { + var b = eb.split("|"), c = a.createDocumentFragment(); + if (c.createElement) + while (b.length) + c.createElement(b.pop()); + return c + } + var eb = "abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video", fb = / jQuery\d+="(?:null|\d+)"/g, gb = new RegExp("<(?:" + eb + ")[\\s/>]", "i"), hb = /^\s+/, ib = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi, jb = /<([\w:]+)/, kb = /\s*$/g, rb = { + option: [1, ""], + legend: [1, "
", "
"], + area: [1, "", ""], + param: [1, "", ""], + thead: [1, "", "
"], + tr: [2, "", "
"], + col: [2, "", "
"], + td: [3, "", "
"], + _default: k.htmlSerialize ? [0, "", ""]: [1, "X
", "
"] + }, sb = db(y), tb = sb.appendChild(y.createElement("div")); + rb.optgroup = rb.option, rb.tbody = rb.tfoot = rb.colgroup = rb.caption = rb.thead, rb.th = rb.td; + function ub(a, b) { + var c, d, e = 0, f = typeof a.getElementsByTagName !== K ? a.getElementsByTagName(b || "*"): typeof a.querySelectorAll !== K ? a.querySelectorAll(b || "*"): void 0; + if (!f) + for (f = [], c = a.childNodes || a; null != (d = c[e]); e++) + !b || m.nodeName(d, b) ? f.push(d) : m.merge(f, ub(d, b)); + return void 0 === b || b && m.nodeName(a, b) ? m.merge([a], f) : f + } + function vb(a) { + W.test(a.type) && (a.defaultChecked = a.checked) + } + function wb(a, b) { + return m.nodeName(a, "table") && m.nodeName(11 !== b.nodeType ? b : b.firstChild, "tr") ? a.getElementsByTagName("tbody")[0] || a.appendChild(a.ownerDocument.createElement("tbody")) : a + } + function xb(a) { + return a.type = (null !== m.find.attr(a, "type")) + "/" + a.type, a + } + function yb(a) { + var b = pb.exec(a.type); + return b ? a.type = b[1] : a.removeAttribute("type"), a + } + function zb(a, b) { + for (var c, d = 0; null != (c = a[d]); d++) + m._data(c, "globalEval", !b || m._data(b[d], "globalEval")) + } + function Ab(a, b) { + if (1 === b.nodeType && m.hasData(a)) { + var c, d, e, f = m._data(a), g = m._data(b, f), h = f.events; + if (h) { + delete g.handle, g.events = {}; + for (c in h) + for (d = 0, e = h[c].length; e > d; d++) + m.event.add(b, c, h[c][d]) + } + g.data && (g.data = m.extend({}, g.data)) + } + } + function Bb(a, b) { + var c, d, e; + if (1 === b.nodeType) { + if (c = b.nodeName.toLowerCase(), !k.noCloneEvent && b[m.expando]) { + e = m._data(b); + for (d in e.events) + m.removeEvent(b, d, e.handle); + b.removeAttribute(m.expando) + } + "script" === c && b.text !== a.text ? (xb(b).text = a.text, yb(b)) : "object" === c ? (b.parentNode && (b.outerHTML = a.outerHTML), k.html5Clone && a.innerHTML&&!m.trim(b.innerHTML) && (b.innerHTML = a.innerHTML)) : "input" === c && W.test(a.type) ? (b.defaultChecked = b.checked = a.checked, b.value !== a.value && (b.value = a.value)) : "option" === c ? b.defaultSelected = b.selected = a.defaultSelected : ("input" === c || "textarea" === c) && (b.defaultValue = a.defaultValue) + } + } + m.extend({ + clone: function(a, b, c) { + var d, e, f, g, h, i = m.contains(a.ownerDocument, a); + if (k.html5Clone || m.isXMLDoc(a) ||!gb.test("<" + a.nodeName + ">") ? f = a.cloneNode(!0) : (tb.innerHTML = a.outerHTML, tb.removeChild(f = tb.firstChild)), !(k.noCloneEvent && k.noCloneChecked || 1 !== a.nodeType && 11 !== a.nodeType || m.isXMLDoc(a))) + for (d = ub(f), h = ub(a), g = 0; null != (e = h[g]); ++g) + d[g] && Bb(e, d[g]); + if (b) + if (c) + for (h = h || ub(a), d = d || ub(f), g = 0; null != (e = h[g]); g++) + Ab(e, d[g]); + else + Ab(a, f); + return d = ub(f, "script"), d.length > 0 && zb(d, !i && ub(a, "script")), d = h = e = null, f + }, + buildFragment: function(a, b, c, d) { + for (var e, f, g, h, i, j, l, n = a.length, o = db(b), p = [], q = 0; n > q; q++) + if (f = a[q], f || 0 === f) + if ("object" === m.type(f)) + m.merge(p, f.nodeType ? [f] : f); + else if (lb.test(f)) { + h = h || o.appendChild(b.createElement("div")), i = (jb.exec(f) || ["", ""])[1].toLowerCase(), l = rb[i] || rb._default, h.innerHTML = l[1] + f.replace(ib, "<$1>") + l[2], e = l[0]; + while (e--) + h = h.lastChild; + if (!k.leadingWhitespace && hb.test(f) && p.push(b.createTextNode(hb.exec(f)[0])), !k.tbody) { + f = "table" !== i || kb.test(f) ? "" !== l[1] || kb.test(f) ? 0 : h : h.firstChild, e = f && f.childNodes.length; + while (e--) + m.nodeName(j = f.childNodes[e], "tbody")&&!j.childNodes.length && f.removeChild(j) + } + m.merge(p, h.childNodes), h.textContent = ""; + while (h.firstChild) + h.removeChild(h.firstChild); + h = o.lastChild + } else + p.push(b.createTextNode(f)); + h && o.removeChild(h), k.appendChecked || m.grep(ub(p, "input"), vb), q = 0; + while (f = p[q++]) + if ((!d||-1 === m.inArray(f, d)) && (g = m.contains(f.ownerDocument, f), h = ub(o.appendChild(f), "script"), g && zb(h), c)) { + e = 0; + while (f = h[e++]) + ob.test(f.type || "") && c.push(f) + } + return h = null, o + }, + cleanData: function(a, b) { + for (var d, e, f, g, h = 0, i = m.expando, j = m.cache, l = k.deleteExpando, n = m.event.special; null != (d = a[h]); h++) + if ((b || m.acceptData(d)) && (f = d[i], g = f && j[f])) { + if (g.events) + for (e in g.events) + n[e] ? m.event.remove(d, e) : m.removeEvent(d, e, g.handle); + j[f] && (delete j[f], l ? delete d[i] : typeof d.removeAttribute !== K ? d.removeAttribute(i) : d[i] = null, c.push(f)) + } + } + }), m.fn.extend({ + text: function(a) { + return V(this, function(a) { + return void 0 === a ? m.text(this) : this.empty().append((this[0] && this[0].ownerDocument || y).createTextNode(a)) + }, null, a, arguments.length) + }, + append: function() { + return this.domManip(arguments, function(a) { + if (1 === this.nodeType || 11 === this.nodeType || 9 === this.nodeType) { + var b = wb(this, a); + b.appendChild(a) + } + }) + }, + prepend: function() { + return this.domManip(arguments, function(a) { + if (1 === this.nodeType || 11 === this.nodeType || 9 === this.nodeType) { + var b = wb(this, a); + b.insertBefore(a, b.firstChild) + } + }) + }, + before: function() { + return this.domManip(arguments, function(a) { + this.parentNode && this.parentNode.insertBefore(a, this) + }) + }, + after: function() { + return this.domManip(arguments, function(a) { + this.parentNode && this.parentNode.insertBefore(a, this.nextSibling) + }) + }, + remove: function(a, b) { + for (var c, d = a ? m.filter(a, this) : this, e = 0; null != (c = d[e]); e++) + b || 1 !== c.nodeType || m.cleanData(ub(c)), c.parentNode && (b && m.contains(c.ownerDocument, c) && zb(ub(c, "script")), c.parentNode.removeChild(c)); + return this + }, + empty: function() { + for (var a, b = 0; null != (a = this[b]); b++) { + 1 === a.nodeType && m.cleanData(ub(a, !1)); + while (a.firstChild) + a.removeChild(a.firstChild); + a.options && m.nodeName(a, "select") && (a.options.length = 0) + } + return this + }, + clone: function(a, b) { + return a = null == a?!1 : a, b = null == b ? a : b, this.map(function() { + return m.clone(this, a, b) + }) + }, + html: function(a) { + return V(this, function(a) { + var b = this[0] || {}, c = 0, d = this.length; + if (void 0 === a) + return 1 === b.nodeType ? b.innerHTML.replace(fb, "") : void 0; + if (!("string" != typeof a || mb.test(a) ||!k.htmlSerialize && gb.test(a) ||!k.leadingWhitespace && hb.test(a) || rb[(jb.exec(a) || ["", ""])[1].toLowerCase()])) { + a = a.replace(ib, "<$1>"); + try { + for (; d > c; c++) + b = this[c] || {}, 1 === b.nodeType && (m.cleanData(ub(b, !1)), b.innerHTML = a); + b = 0 + } catch (e) {} + } + b && this.empty().append(a) + }, null, a, arguments.length) + }, + replaceWith: function() { + var a = arguments[0]; + return this.domManip(arguments, function(b) { + a = this.parentNode, m.cleanData(ub(this)), a && a.replaceChild(b, this) + }), a && (a.length || a.nodeType) ? this : this.remove() + }, + detach: function(a) { + return this.remove(a, !0) + }, + domManip: function(a, b) { + a = e.apply([], a); + var c, d, f, g, h, i, j = 0, l = this.length, n = this, o = l - 1, p = a[0], q = m.isFunction(p); + if (q || l > 1 && "string" == typeof p&&!k.checkClone && nb.test(p)) + return this.each(function(c) { + var d = n.eq(c); + q && (a[0] = p.call(this, c, d.html())), d.domManip(a, b) + }); + if (l && (i = m.buildFragment(a, this[0].ownerDocument, !1, this), c = i.firstChild, 1 === i.childNodes.length && (i = c), c)) { + for (g = m.map(ub(i, "script"), xb), f = g.length; l > j; j++) + d = i, j !== o && (d = m.clone(d, !0, !0), f && m.merge(g, ub(d, "script"))), b.call(this[j], d, j); + if (f) + for (h = g[g.length - 1].ownerDocument, m.map(g, yb), j = 0; f > j; j++) + d = g[j], ob.test(d.type || "")&&!m._data(d, "globalEval") && m.contains(h, d) && (d.src ? m._evalUrl && m._evalUrl(d.src) : m.globalEval((d.text || d.textContent || d.innerHTML || "").replace(qb, ""))); + i = c = null + } + return this + } + }), m.each({ + appendTo: "append", + prependTo: "prepend", + insertBefore: "before", + insertAfter: "after", + replaceAll: "replaceWith" + }, function(a, b) { + m.fn[a] = function(a) { + for (var c, d = 0, e = [], g = m(a), h = g.length - 1; h >= d; d++) + c = d === h ? this : this.clone(!0), m(g[d])[b](c), f.apply(e, c.get()); + return this.pushStack(e) + } + }); + var Cb, Db = {}; + function Eb(b, c) { + var d, e = m(c.createElement(b)).appendTo(c.body), f = a.getDefaultComputedStyle && (d = a.getDefaultComputedStyle(e[0])) ? d.display: m.css(e[0], "display"); + return e.detach(), f + } + function Fb(a) { + var b = y, c = Db[a]; + return c || (c = Eb(a, b), "none" !== c && c || (Cb = (Cb || m("