From 0e083d836c4e0f501b607d031e82fb50ab80238e Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 13 Jun 2017 16:52:37 -0600 Subject: [PATCH 001/215] made stable --- tethys_apps/base/app_base.py | 9 +++------ tethys_apps/base/{ => testing}/testing.py | 18 ++---------------- tethys_apps/cli/manage_commands.py | 3 ++- tethys_sdk/testing.py | 1 - 4 files changed, 7 insertions(+), 24 deletions(-) rename tethys_apps/base/{ => testing}/testing.py (92%) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index b33c43c06..ad807a23c 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -10,16 +10,13 @@ import os import sys +from django.conf import settings from django.http import HttpRequest from django.utils.functional import SimpleLazyObject -from django.conf import settings - from sqlalchemy import create_engine - - -from tethys_apps.base.workspace import TethysWorkspace from tethys_apps.base.handoff import HandoffManager -from tethys_apps.base.testing import is_testing_environment +from tethys_apps.base.testing.environment import is_testing_environment +from tethys_apps.base.workspace import TethysWorkspace class TethysAppBase(object): diff --git a/tethys_apps/base/testing.py b/tethys_apps/base/testing/testing.py similarity index 92% rename from tethys_apps/base/testing.py rename to tethys_apps/base/testing/testing.py index cd2bee739..22811b1c4 100644 --- a/tethys_apps/base/testing.py +++ b/tethys_apps/base/testing/testing.py @@ -1,6 +1,7 @@ from django.test import TestCase from django.test import Client -from os import environ, unsetenv +from environment import set_testing_environment +from ..app_base import TethysAppBase class TethysTestCase(TestCase): @@ -54,7 +55,6 @@ def create_test_persistent_stores_for_app(app_class): """ set_testing_environment(True) - from app_base import TethysAppBase if not issubclass(app_class, TethysAppBase): raise TypeError('The app_class argument was not of the correct type. ' 'It must be a class that inherits from .') @@ -99,7 +99,6 @@ def destroy_test_persistent_stores_for_app(app_class): """ set_testing_environment(True) - from app_base import TethysAppBase if not issubclass(app_class, TethysAppBase): raise TypeError('The app_class argument was not of the correct type. ' 'It must be a class that inherits from .') @@ -150,16 +149,3 @@ def get_test_client(): Client object """ return Client() - - -def set_testing_environment(val): - if val: - environ['TETHYS_TESTING_IN_PROGRESS'] = 'true' - else: - environ['TETHYS_TESTING_IN_PROGRESS'] = '' - del environ['TETHYS_TESTING_IN_PROGRESS'] - unsetenv('TETHYS_TESTING_IN_PROGRESS') - - -def is_testing_environment(): - return environ.get('TETHYS_TESTING_IN_PROGRESS') diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index 80902e7ad..131347ad8 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -10,7 +10,8 @@ import os import subprocess -from tethys_apps.base.testing import set_testing_environment + +from tethys_apps.base.testing.environment import set_testing_environment #/usr/lib/tethys/src/tethys_apps/cli CURRENT_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/tethys_sdk/testing.py b/tethys_sdk/testing.py index d1f0302c3..c679a1281 100644 --- a/tethys_sdk/testing.py +++ b/tethys_sdk/testing.py @@ -8,4 +8,3 @@ ******************************************************************************** """ # DO NOT ERASE -from tethys_apps.base.testing import TethysTestCase, set_testing_environment, is_testing_environment \ No newline at end of file From d8473af2ae0d46ef76956e32994aadc229ad7d65 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Wed, 14 Jun 2017 16:29:18 -0600 Subject: [PATCH 002/215] testing environment functions isolated --- tethys_apps/base/testing/__init__.py | 0 tethys_apps/base/testing/environment.py | 14 ++++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 tethys_apps/base/testing/__init__.py create mode 100644 tethys_apps/base/testing/environment.py diff --git a/tethys_apps/base/testing/__init__.py b/tethys_apps/base/testing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_apps/base/testing/environment.py b/tethys_apps/base/testing/environment.py new file mode 100644 index 000000000..2db099938 --- /dev/null +++ b/tethys_apps/base/testing/environment.py @@ -0,0 +1,14 @@ +from os import environ, unsetenv + + +def set_testing_environment(val): + if val: + environ['TETHYS_TESTING_IN_PROGRESS'] = 'true' + else: + environ['TETHYS_TESTING_IN_PROGRESS'] = '' + del environ['TETHYS_TESTING_IN_PROGRESS'] + unsetenv('TETHYS_TESTING_IN_PROGRESS') + + +def is_testing_environment(): + return environ.get('TETHYS_TESTING_IN_PROGRESS') \ No newline at end of file From 2f3466e58dc7c439f3f1059b3dca7657875f0691 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Fri, 16 Jun 2017 17:56:10 -0600 Subject: [PATCH 003/215] bugfix: postgis extension was not being enabled --- tethys_apps/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 1a83da7c4..9bc7f6380 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -636,7 +636,8 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False # -------------------------------------------------------------------------------------------------------------# if self.spatial: # Connect to new database - new_db_connection = engine.connect() + new_db_engine = self.get_engine(with_db=True) + new_db_connection = new_db_engine.connect() # Notify user log.info('Enabling PostGIS on database "{0}" for app "{1}"...'.format( @@ -660,7 +661,7 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False # 4. Run initialization function # -------------------------------------------------------------------------------------------------------------# if self.initializer: - log.info('Initializing PostGIS on database "{0}" for app "{1}" with initializer "{2}"...'.format( + log.info('Initializing database "{0}" for app "{1}" with initializer "{2}"...'.format( self.name, self.tethys_app.package, self.initializer From ec1940f8c05c8eb3a3328ac9ef040032156885ad Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 19 Jun 2017 14:26:16 -0600 Subject: [PATCH 004/215] added imports to testing sdk --- tethys_sdk/testing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tethys_sdk/testing.py b/tethys_sdk/testing.py index c679a1281..7df0121b9 100644 --- a/tethys_sdk/testing.py +++ b/tethys_sdk/testing.py @@ -8,3 +8,5 @@ ******************************************************************************** """ # DO NOT ERASE +from tethys_apps.base.testing.testing import TethysTestCase +from tethys_apps.base.testing.environment import set_testing_environment, is_testing_environment \ No newline at end of file From 450502419ea5366e10889a8bcc44e8ecc2ec770f Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 10 Jul 2017 09:50:33 -0600 Subject: [PATCH 005/215] ignore rmtree errors on condor folders delete --- tethys_compute/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tethys_compute/models.py b/tethys_compute/models.py index ff686a88d..62f66fa6d 100644 --- a/tethys_compute/models.py +++ b/tethys_compute/models.py @@ -694,7 +694,7 @@ def condor_job_pre_save(sender, instance, raw, using, update_fields, **kwargs): def condor_job_pre_delete(sender, instance, using, **kwargs): try: instance.condor_object.close_remote() - shutil.rmtree(instance.initial_dir) + shutil.rmtree(instance.initial_dir, ignore_errors=True) except Exception, e: log.exception(e.message) @@ -812,7 +812,7 @@ def condor_workflow_pre_save(sender, instance, raw, using, update_fields, **kwar def condor_workflow_pre_delete(sender, instance, using, **kwargs): try: instance.condor_object.close_remote() - shutil.rmtree(instance.workspace) + shutil.rmtree(instance.workspace, ignore_errors=True) except Exception, e: log.exception(e.message) From 234f1d4f757af141c0de75b211ac369a30ac0624 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 20 Jul 2017 16:01:21 -0600 Subject: [PATCH 006/215] fixed issues with testing framework --- tethys_apps/base/app_base.py | 76 ++++++++++++------------- tethys_apps/base/testing/environment.py | 13 ++++- tethys_apps/base/testing/testing.py | 54 +++++++----------- tethys_apps/models.py | 12 ++-- tethys_sdk/testing.py | 2 +- 5 files changed, 80 insertions(+), 77 deletions(-) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index cdfe82e15..ed6aeecff 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -15,7 +15,7 @@ from django.http import HttpRequest from django.utils.functional import SimpleLazyObject -from tethys_apps.base.testing.environment import is_testing_environment +from tethys_apps.base.testing.environment import is_testing_environment, get_test_db_name, TESTING_DB_FLAG from .handoff import HandoffManager from .workspace import TethysWorkspace from ..exceptions import TethysAppSettingDoesNotExist @@ -698,7 +698,7 @@ def get_web_processing_service(cls, name, as_public_endpoint=False, as_endpoint= return wps_service @classmethod - def get_persistent_store_connection(cls, name, as_url=False, as_sessionmaker=False): + def get_persistent_store_connection(cls, name, for_db=False, as_url=False, as_sessionmaker=False): """ Gets an SQLAlchemy Engine or URL object for the named persistent store connection. @@ -725,12 +725,11 @@ def get_persistent_store_connection(cls, name, as_url=False, as_sessionmaker=Fal """ from tethys_apps.models import TethysApp db_app = TethysApp.objects.get(package=cls.package) - ps_connection_settings = db_app.persistent_store_connection_settings - if is_testing_environment(): - if 'tethys-testing_' not in name: - test_store_name = 'tethys-testing_{0}'.format(name) - name = test_store_name + if for_db: + ps_connection_settings = db_app.persistent_store_database_settings + else: + ps_connection_settings = db_app.persistent_store_connection_settings try: ps_connection_setting = ps_connection_settings.get(name=name) @@ -768,15 +767,13 @@ def get_persistent_store_database(cls, name, as_url=False, as_sessionmaker=False db_app = TethysApp.objects.get(package=cls.package) ps_database_settings = db_app.persistent_store_database_settings - if is_testing_environment(): - if 'tethys-testing_' not in name: - test_store_name = 'tethys-testing_{0}'.format(name) - name = test_store_name + verified_name = name if not is_testing_environment() else get_test_db_name(name) try: - ps_database_setting = ps_database_settings.get(name=name) + ps_database_setting = ps_database_settings.get(name=verified_name) except ObjectDoesNotExist: - raise TethysAppSettingDoesNotExist('PersistentStoreDatabaseSetting named "{0}" does not exist.'.format(name)) + raise TethysAppSettingDoesNotExist('PersistentStoreDatabaseSetting named "{0}" does not exist.' + .format(verified_name)) return ps_database_setting.get_engine(with_db=True, as_url=as_url, as_sessionmaker=as_sessionmaker) @@ -788,7 +785,7 @@ def create_persistent_store(cls, db_name, connection_name, spatial=False, initia Args: db_name(string): Name of the persistent store that will be created. - connection_name(string): Name of persistent store connection. + connection_name(string|None): Name of persistent store connection, or None if creating a test copy of an existing persistent (only while in the testing environment) spatial(bool): Enable spatial extension on the database being created when True. Connection must have superuser role. Defaults to False. initializer(string): Dot-notation path to initializer function (e.g.: 'my_first_app.models.init_db'). refresh(bool): Drop database if it exists and create again when True. Defaults to False. @@ -819,28 +816,38 @@ def create_persistent_store(cls, db_name, connection_name, spatial=False, initia ps_connection_settings = db_app.persistent_store_connection_settings if is_testing_environment(): - if 'tethys-testing_' not in connection_name: - test_store_name = 'test_{0}'.format(connection_name) - connection_name = test_store_name + verified_db_name = get_test_db_name(db_name) + else: + verified_db_name = db_name + if connection_name is None: + raise ValueError('The connection_name cannot be None unless running in the testing environment.') try: - ps_connection_setting = ps_connection_settings.get(name=connection_name) - except ObjectDoesNotExist: - raise TethysAppSettingDoesNotExist( - 'PersistentStoreConnectionSetting named "{0}" does not exist.'.format(connection_name)) - - ps_service = ps_connection_setting.persistent_store_service + if connection_name is None: + ps_database_settings = db_app.persistent_store_database_settings + ps_setting = ps_database_settings.get(name=db_name) + else: + ps_setting = ps_connection_settings.get(name=connection_name) + except ObjectDoesNotExist as e: + if connection_name is None: + raise TethysAppSettingDoesNotExist( + 'PersistentStoreDatabaseSetting named "{0}" does not exist.'.format(db_name)) + else: + raise TethysAppSettingDoesNotExist( + 'PersistentStoreConnectionSetting named "{0}" does not exist.'.format(connection_name)) + + ps_service = ps_setting.persistent_store_service # Check if persistent store database setting already exists before creating it try: - db_setting = db_app.persistent_store_database_settings.get(name=db_name) + db_setting = db_app.persistent_store_database_settings.get(name=verified_db_name) db_setting.persistent_store_service = ps_service db_setting.initializer = initializer db_setting.save() except ObjectDoesNotExist: # Create new PersistentStoreDatabaseSetting db_setting = PersistentStoreDatabaseSetting( - name=db_name, + name=verified_db_name, description='', required=False, initializer=initializer, @@ -889,14 +896,10 @@ def drop_persistent_store(cls, name): from tethys_apps.models import TethysApp db_app = TethysApp.objects.get(package=cls.package) ps_database_settings = db_app.persistent_store_database_settings - - if is_testing_environment(): - if 'tethys-testing_' not in name: - test_store_name = 'tethys-testing_{0}'.format(name) - name = test_store_name + verified_name = name if not is_testing_environment() else get_test_db_name(name) try: - ps_database_setting = ps_database_settings.get(name=name) + ps_database_setting = ps_database_settings.get(name=verified_name) except ObjectDoesNotExist: return True @@ -937,7 +940,7 @@ def list_persistent_store_databases(cls, dynamic_only=False, static_only=False): elif static_only: ps_database_settings = ps_database_settings.filter(persistentstoredatabasesetting__dynamic=False) return [ps_database_setting.name for ps_database_setting in ps_database_settings - if 'tethys-testing_' not in ps_database_setting.name] + if TESTING_DB_FLAG not in ps_database_setting.name] @classmethod def list_persistent_store_connections(cls): @@ -961,7 +964,7 @@ def list_persistent_store_connections(cls): db_app = TethysApp.objects.get(package=cls.package) ps_connection_settings = db_app.persistent_store_connection_settings return [ps_connection_setting.name for ps_connection_setting in ps_connection_settings - if 'tethys-testing_' not in ps_database_setting.name] + if TESTING_DB_FLAG not in ps_database_setting.name] @classmethod def persistent_store_exists(cls, name): @@ -991,14 +994,11 @@ def persistent_store_exists(cls, name): db_app = TethysApp.objects.get(package=cls.package) ps_database_settings = db_app.persistent_store_database_settings - if is_testing_environment(): - if 'tethys-testing_' not in name: - test_store_name = 'tethys-testing_{0}'.format(name) - name = test_store_name + verified_name = name if not is_testing_environment() else get_test_db_name(name) try: # If it exists return True - ps_database_setting = ps_database_settings.get(name=name) + ps_database_setting = ps_database_settings.get(name=verified_name) except ObjectDoesNotExist: # Else return False return False diff --git a/tethys_apps/base/testing/environment.py b/tethys_apps/base/testing/environment.py index 5ed8c5464..30b9564e6 100644 --- a/tethys_apps/base/testing/environment.py +++ b/tethys_apps/base/testing/environment.py @@ -1,5 +1,7 @@ from os import environ, unsetenv +TESTING_DB_FLAG = 'tethys-testing_' + def is_testing_environment(): return environ.get('TETHYS_TESTING_IN_PROGRESS') @@ -11,4 +13,13 @@ def set_testing_environment(val): else: environ['TETHYS_TESTING_IN_PROGRESS'] = '' del environ['TETHYS_TESTING_IN_PROGRESS'] - unsetenv('TETHYS_TESTING_IN_PROGRESS') \ No newline at end of file + unsetenv('TETHYS_TESTING_IN_PROGRESS') + + +def get_test_db_name(orig_name): + if TESTING_DB_FLAG not in orig_name: + test_db_name = '{0}{1}'.format(TESTING_DB_FLAG, orig_name) + else: + test_db_name = orig_name + + return test_db_name diff --git a/tethys_apps/base/testing/testing.py b/tethys_apps/base/testing/testing.py index 342a6a60c..495afb958 100644 --- a/tethys_apps/base/testing/testing.py +++ b/tethys_apps/base/testing/testing.py @@ -2,7 +2,7 @@ from django.test import TestCase from tethys_apps.base.app_base import TethysAppBase -from tethys_apps.base.testing.environment import set_testing_environment +from tethys_apps.base.testing.environment import is_testing_environment, get_test_db_name class TethysTestCase(TestCase): @@ -54,38 +54,27 @@ def create_test_persistent_stores_for_app(app_class): Return: None """ - set_testing_environment(True) + from tethys_apps.models import TethysApp + + if not is_testing_environment(): + raise EnvironmentError('This function will only execute properly if executed in the testing environment.') if not issubclass(app_class, TethysAppBase): raise TypeError('The app_class argument was not of the correct type. ' 'It must be a class that inherits from .') - for store in app_class().list_persistent_store_databases(static_only=True): - if app_class.persistent_store_exists(store.name): - app_class.drop_persistent_store(store.name) - - create_store_success = app_class.create_persistent_store(store.name, spatial=store.spatial) - - error = False - if create_store_success: - retry_counter = 0 - while True: - if retry_counter < 5: - try: - store.initializer_function(True) - break - except Exception as e: - if 'terminating connection due to administrator command' in str(e): - pass - else: - error = True - else: - error = True - break - else: - error = True - - if error: + db_app = TethysApp.objects.get(package=app_class.package) + + ps_database_settings = db_app.persistent_store_database_settings + for db_setting in ps_database_settings: + create_store_success = app_class.create_persistent_store(db_name=db_setting.name, + connection_name=None, + spatial=db_setting.spatial, + initializer=db_setting.initializer, + refresh=True, + force_first_time=True) + + if not create_store_success: raise SystemError('The test store was not able to be created') @staticmethod @@ -98,15 +87,16 @@ def destroy_test_persistent_stores_for_app(app_class): Return: None """ - set_testing_environment(True) + if not is_testing_environment(): + raise EnvironmentError('This function will only execute properly if executed in the testing environment.') if not issubclass(app_class, TethysAppBase): raise TypeError('The app_class argument was not of the correct type. ' 'It must be a class that inherits from .') - for store in app_class().list_persistent_store_databases(static_only=True): - test_store_name = 'tethys-testing_{0}'.format(store.name) - app_class.drop_persistent_store(test_store_name) + for db_name in app_class.list_persistent_store_databases(static_only=True): + test_db_name = get_test_db_name(db_name) + app_class.drop_persistent_store(test_db_name) @staticmethod def create_test_user(username, password, email=None): diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 0834765ff..06b945f91 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -17,6 +17,7 @@ PersistentStoreInitializerError from tethys_compute.utilities import ListField from sqlalchemy.orm import sessionmaker +from tethys_sdk.testing import is_testing_environment, get_test_db_name try: from tethys_services.models import (DatasetService, SpatialDatasetService, @@ -477,8 +478,8 @@ def get_namespaced_persistent_store_name(self): safe_name = self.name.lower().replace(' ', '_') # If testing environment, the engine for the "test" version of the persistent store should be fetched - if hasattr(settings, 'TESTING') and settings.TESTING: - safe_name = 'test_{0}'.format(safe_name) + if is_testing_environment(): + safe_name = get_test_db_name(safe_name) return '_'.join((self.tethys_app.package, safe_name)) @@ -556,7 +557,7 @@ def drop_persistent_store_database(self): namespaced_ps_name = self.get_namespaced_persistent_store_name() # Drop db - drop_db_statement = 'DROP DATABASE IF EXISTS {0}'.format(namespaced_ps_name) + drop_db_statement = 'DROP DATABASE IF EXISTS "{0}"'.format(namespaced_ps_name) try: drop_connection = engine.connect() @@ -619,7 +620,7 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False # Create db create_db_statement = ''' - CREATE DATABASE {0} + CREATE DATABASE "{0}" WITH OWNER {1} TEMPLATE template0 ENCODING 'UTF8' @@ -629,7 +630,8 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False create_connection.execute('commit') try: create_connection.execute(create_db_statement) - except sqlalchemy.exc.ProgrammingError: + except sqlalchemy.exc.ProgrammingError as e: + print e raise PersistentStorePermissionError('Database user "{0}" has insufficient permissions to create ' 'the persistent store database "{1}": must have CREATE DATABASES ' 'permission at a minimum.'.format(url.username, self.name)) diff --git a/tethys_sdk/testing.py b/tethys_sdk/testing.py index b0c1807f9..8ff844d41 100644 --- a/tethys_sdk/testing.py +++ b/tethys_sdk/testing.py @@ -9,4 +9,4 @@ """ # DO NOT ERASE from tethys_apps.base.testing.testing import TethysTestCase -from tethys_apps.base.testing.environment import set_testing_environment, is_testing_environment +from tethys_apps.base.testing.environment import set_testing_environment, is_testing_environment, get_test_db_name From d7b27ce2a2e2f10d6c3137d5caae700f44e5fbcf Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 20 Jul 2017 16:08:34 -0600 Subject: [PATCH 007/215] remove temp troubleshoot prints --- tethys_apps/base/app_base.py | 2 +- tethys_apps/models.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index ed6aeecff..58c538c5f 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -828,7 +828,7 @@ def create_persistent_store(cls, db_name, connection_name, spatial=False, initia ps_setting = ps_database_settings.get(name=db_name) else: ps_setting = ps_connection_settings.get(name=connection_name) - except ObjectDoesNotExist as e: + except ObjectDoesNotExist: if connection_name is None: raise TethysAppSettingDoesNotExist( 'PersistentStoreDatabaseSetting named "{0}" does not exist.'.format(db_name)) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 06b945f91..ccdc33437 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -630,8 +630,7 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False create_connection.execute('commit') try: create_connection.execute(create_db_statement) - except sqlalchemy.exc.ProgrammingError as e: - print e + except sqlalchemy.exc.ProgrammingError: raise PersistentStorePermissionError('Database user "{0}" has insufficient permissions to create ' 'the persistent store database "{1}": must have CREATE DATABASES ' 'permission at a minimum.'.format(url.username, self.name)) From 9b48e93ef0e756874643302f6a58bd10b6271564 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 20 Jul 2017 16:15:55 -0600 Subject: [PATCH 008/215] remove unused changes --- tethys_apps/base/app_base.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 58c538c5f..4d9979af7 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -698,7 +698,7 @@ def get_web_processing_service(cls, name, as_public_endpoint=False, as_endpoint= return wps_service @classmethod - def get_persistent_store_connection(cls, name, for_db=False, as_url=False, as_sessionmaker=False): + def get_persistent_store_connection(cls, name, as_url=False, as_sessionmaker=False): """ Gets an SQLAlchemy Engine or URL object for the named persistent store connection. @@ -725,11 +725,7 @@ def get_persistent_store_connection(cls, name, for_db=False, as_url=False, as_se """ from tethys_apps.models import TethysApp db_app = TethysApp.objects.get(package=cls.package) - - if for_db: - ps_connection_settings = db_app.persistent_store_database_settings - else: - ps_connection_settings = db_app.persistent_store_connection_settings + ps_connection_settings = db_app.persistent_store_connection_settings try: ps_connection_setting = ps_connection_settings.get(name=name) From 7c526137dbdb4dab1b7859473c9beb7aa53f4cca Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 20 Jul 2017 16:20:14 -0600 Subject: [PATCH 009/215] minor cleanup --- tethys_apps/base/app_base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 4d9979af7..57a0bc90a 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -730,7 +730,8 @@ def get_persistent_store_connection(cls, name, as_url=False, as_sessionmaker=Fal try: ps_connection_setting = ps_connection_settings.get(name=name) except ObjectDoesNotExist: - raise TethysAppSettingDoesNotExist('PersistentStoreConnectionSetting named "{0}" does not exist.'.format(name)) + raise TethysAppSettingDoesNotExist('PersistentStoreConnectionSetting named "{0}" does not exist.' + .format(name)) return ps_connection_setting.get_engine(as_url=as_url, as_sessionmaker=as_sessionmaker) @@ -781,7 +782,7 @@ def create_persistent_store(cls, db_name, connection_name, spatial=False, initia Args: db_name(string): Name of the persistent store that will be created. - connection_name(string|None): Name of persistent store connection, or None if creating a test copy of an existing persistent (only while in the testing environment) + connection_name(string|None): Name of persistent store connection or None if creating a test copy of an existing persistent store (only while in the testing environment) spatial(bool): Enable spatial extension on the database being created when True. Connection must have superuser role. Defaults to False. initializer(string): Dot-notation path to initializer function (e.g.: 'my_first_app.models.init_db'). refresh(bool): Drop database if it exists and create again when True. Defaults to False. From 717dbb0f061dceef6f95a2034377b08def0f8c27 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 29 Aug 2017 14:31:06 -0600 Subject: [PATCH 010/215] get feature layer from group layer on wfs requests --- tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index 95d6cde9f..500a7909e 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -1744,7 +1744,7 @@ var TETHYS_MAP_VIEW = (function() { bbox = bbox.replace('{{maxy}}', y + tolerance); cql_filter = '&CQL_FILTER=BBOX(' + geometry_attribute + '%2C' + bbox + '%2C%27EPSG%3A3857%27)'; layer_params = source.getParams(); - layer_name = layer_params.LAYERS; + layer_name = layer_params.LAYERS.replace('_group', ''); layer_view_params = layer_params.VIEWPARAMS ? layer_params.VIEWPARAMS : ''; if (source instanceof ol.source.ImageWMS) { @@ -1833,7 +1833,7 @@ var TETHYS_MAP_VIEW = (function() { + '?SERVICE=wfs' + '&VERSION=2.0.0' + '&REQUEST=GetFeature' - + '&TYPENAMES=' + layer_name + + '&TYPENAMES=' + layer_name.replace('_group', '') + '&VIEWPARAMS=' + layer_view_params + '&OUTPUTFORMAT=text/javascript' + '&FORMAT_OPTIONS=callback:TETHYS_MAP_VIEW.jsonResponseHandler;' From 98c5e8bdf34cad329d0739825625f58bf645b5ea Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Sun, 17 Sep 2017 15:08:19 -0600 Subject: [PATCH 011/215] Add docker stuff --- Dockerfile | 55 ++++ docker/README.md | 65 +++++ docker/install_tethys.sh | 548 +++++++++++++++++++++++++++++++++++++++ docker/setup_tethys.sh | 516 ++++++++++++++++++++++++++++++++++++ 4 files changed, 1184 insertions(+) create mode 100644 Dockerfile create mode 100644 docker/README.md create mode 100644 docker/install_tethys.sh create mode 100644 docker/setup_tethys.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..cf6705d5e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# Use an official Python runtime as a parent image +FROM python:2.7-slim + +WORKDIR /usr/lib/tethys + +# Add files to docker image +ADD docker/install_tethys.sh /usr/lib/tethys/install_tethys.sh +ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh +ADD . /usr/lib/tethys/src + +# Arguments +ARG TETHYSBUILD_BRANCH=release +ARG TETHYSBUILD_PY_VERSION=2 +ARG TETHYSBUILD_TETHYS_HOME=/usr/lib/tethys +ARG TETHYSBUILD_CONDA_HOME=/usr/lib/tethys/miniconda +ARG TETHYSBUILD_CONDA_ENV_NAME=tethys + +# Run Scripts to Get Files +RUN pwd \ + && apt-get update \ + && apt-get install -y wget bzip2 git \ + && bash install_tethys.sh \ + -b $TETHYSBUILD_BRANCH \ + --python-version $TETHYSBUILD_PY_VERSION \ + --tethys-home $TETHYSBUILD_TETHYS_HOME \ + --conda-home $TETHYSBUILD_CONDA_HOME \ + --conda-env-name $TETHYSBUILD_CONDA_ENV_NAME \ + + +# Make port 8000 available to the outside world +EXPOSE 8000 + +# Configure Tethys +ENV PATH ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda}/envs/tethys/bin:$PATH + +# Install Tethys + +CMD echo Stating Tethys Setup \ + && bash setup_tethys.sh \ + -b ${TETHYSBUILD_BRANCH:-release} \ + --allowed-host ${TETHYSBUILD_ALLOWED_HOST:-127.0.0.1} \ + --python-version ${TETHYSBUILD_PY_VERSION:-2} \ + --db-username ${TETHYSBUILD_DB_USERNAME:-tethys_default} \ + --db-password ${TETHYSBUILD_DB_PASSWORD:-pass} \ + --db-host ${TETHYSBUILD_DB_HOST:-127.0.0.1} \ + --db-port ${TETHYSBUILD_DB_PORT:-5432} \ + --superuser ${TETHYSBUILD_SUPERUSER:-tethys_super} \ + --superuser-pass ${TETHYSBUILD_SUPERUSER_PASS:-admin} \ + --tethys-home ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys} \ + --conda-home ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda} \ + && echo Setup Complete \ + && cd ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys}/src \ + && echo Source Directory: $PWD \ + && echo Starting Tethys on ${TETHYSBUILD_ALLOWED_HOST:-0.0.0.0}:${TETHYSBUILD_HOST_PORT:-8000} \ + && tethys manage start -p ${TETHYSBUILD_DOCKER_IP:-0.0.0.0}:${TETHYSBUILD_HOST_PORT:-8000} diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 000000000..aec27ee34 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,65 @@ +# Tethys Core Docker + +This project houses the docker file and scripts needed to make the tethyscore +docker. + +### Building the Docker +To build the docker use the following commands in the terminal after +pulling the latest source code: + +1. Make sure that there isn't already a docker container or docker +images with the desired name +``` +> docker rm tethyscore +> docker rmi tethyscore +``` + +2. Build a new docker with the desired name and tag +``` +> docker build -t tethyscore:latest +``` +You can also use build arguments in this to change certain features +that you may find useful, such as the branch, and the configuration + +Use the following syntax with arguments listed in the table + +``` +> docker build [--build-arg ARG=VAL] -t tethyscore:latest +``` + +| Argument | Description | Default | +|--------------------------|---------------------------|------------------------| +|TETHYSBUILD_BRANCH | Tethys branch to be used | release | +|TETHYSBUILD_PY_VERSION | Version of python | 2 | +|TETHYSBUILD_TETHYS_HOME | Path to Tethys home dir | /usr/lib/tethys | +|TETHYSBUILD_CONDA_HOME | Path to Conda home dir | /usr/lib/tethys/conda/ | +|TETHYSBUILD_CONDA_ENV_NAME| Tethys environment name | tethys | + +### Running the docker +To run the docker you can use the following flags + +use the following flag with the arguments listed in the table. (NOTE: +args in the build arg table can be used here as well) + +``` +-e TETHYSBUILD_CONDA_ENV='tethys' +``` + +| Argument | Description | Default | +|--------------------------|----------------------------|---------------| +|TETHYSBUILD_ALLOWED_HOST | Django Allowed Hosts | 127.0.0.1 | +|TETHYSBUILD_DB_USERNAME | Database Username | tethys_default| +|TETHYSBUILD_DB_PASSWORD | Password for Database | pass | +|TETHYSBUILD_DB_HOST | IPAddress for Database host| 127.0.0.1 | +|TETHYSBUILD_DB_PORT | Port on Database host | 5432 | +|TETHYSBUILD_SUPERUSER | Tethys Superuser | tethys_super | +|TETHYSBUILD_SUPERUSER_PASS| Tethys Superuser Password | admin | + +Example of Command: +``` +> docker run -p 127.0.0.1:8000:8000 --name tethyscore \ + -e TETHYSBUILD_CONDA_ENV='tethys' -e TETHYSBUILD_CONFIG='develop' \ + -e TETHYSBUILD_DB_USERNAME='tethys_super' -e TETHYSBUILD_DB_PASSWORD='3x@m9139@$$' \ + -e TETHYSBUILD_DB_PORT='5432' TETHYSBUILD_SUPERUSER='admin' \ + -e TETHYSBUILD_SUPERUSER_PASS='admin' tethyscore +``` \ No newline at end of file diff --git a/docker/install_tethys.sh b/docker/install_tethys.sh new file mode 100644 index 000000000..e8b271c2f --- /dev/null +++ b/docker/install_tethys.sh @@ -0,0 +1,548 @@ +#!/bin/bash + +USAGE="USAGE: . install_tethys.sh [options]\n +\n +OPTIONS:\n + -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n + -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n + -p, --port Port on which to serve tethys. Default is 8000.\n + -b, --branch Branch to checkout from version control. Default is 'release'.\n + -c, --conda-home Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n + -n, --conda-env-name Name for tethys conda environment. Default is 'tethys'. + --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n + --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n + --db-password Password that the tethys database server will use. Default is 'pass'.\n + --db-port Port that the tethys database server will use. Default is 5436.\n + -S, --superuser Tethys super user name. Default is 'admin'.\n + -E, --superuser-email Tethys super user email. Default is ''.\n + -P, --superuser-pass Tethys super user password. Default is 'pass'.\n + --skip-tethys-install Flag to skip the Tethys installation so that the Docker installation or production installation can be added to an existing Tethys installation.\n + --install-docker Flag to include Docker installation as part of the install script (Linux only).\n + --docker-options Command line options to pass to the 'tethys docker init' call if --install-docker is used. Default is \"'-d'\".\n + --production Flag to install Tethys in a production configuration.\n + --configure-selinux Flag to perform configuration of SELinux for production installation. (Linux only).\n + -x Flag to turn on shell command echoing.\n + -h, --help Print this help information.\n +" + +print_usage () +{ + echo -e ${USAGE} + exit +} +set -e # exit on error + +# Set platform specific default options +if [ "$(uname)" = "Linux" ] +then + # LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") + # convert to lower case + # LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} + MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" + BASH_PROFILE=".bashrc" + resolve_relative_path () + { + local __path_var="$1" + eval $__path_var="'$(readlink -f $2)'" + } +elif [ "$(uname)" = "Darwin" ] # i.e. MacOSX +then + MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh" + BASH_PROFILE=".bash_profile" + resolve_relative_path () + { + local __path_var="$1" + eval $__path_var="'$(python -c "import os; print(os.path.abspath('$2'))")'" + } +else + echo $(uname) is not a supported operating system. + exit +fi + +# Set default options +ALLOWED_HOST='127.0.0.1' +TETHYS_REPO='https://github.com/tethysplatform/tethys.git' +TETHYS_HOME=~/tethys +TETHYS_PORT=8000 +TETHYS_DB_USERNAME='tethys_default' +TETHYS_DB_PASSWORD='pass' +TETHYS_DB_PORT=5436 +CONDA_ENV_NAME='tethys' +PYTHON_VERSION='2' +BRANCH='release' + +TETHYS_SUPER_USER='admin' +TETHYS_SUPER_USER_EMAIL='' +TETHYS_SUPER_USER_PASS='pass' + +DOCKER_OPTIONS='-d' + +# parse command line options +set_option_value () +{ + local __option_key="$1" + value="$2" + if [[ $value == -* ]] + then + print_usage + fi + eval $__option_key="$value" +} +while [[ $# -gt 0 ]] +do +key="$1" + +case $key in + -t|--tethys-home) + set_option_value TETHYS_HOME "$2" + shift # past argument + ;; + -a|--allowed-host) + set_option_value ALLOWED_HOST "$2" + shift # past argument + ;; + -p|--port) + set_option_value TETHYS_PORT "$2" + shift # past argument + ;; + --tethys-repo) + set_option_value TETHYS_REPO "$2" + shift # past argument + ;; + -b|--branch) + set_option_value BRANCH "$2" + shift # past argument + ;; + -c|--conda-home) + set_option_value CONDA_HOME "$2" + shift # past argument + ;; + -n|--conda-env-name) + set_option_value CONDA_ENV_NAME "$2" + shift # past argument + ;; + --python-version) + set_option_value PYTHON_VERSION "$2" + shift # past argument + ;; + --db-username) + set_option_value TETHYS_DB_USERNAME "$2" + shift # past argument + ;; + --db-password) + set_option_value TETHYS_DB_PASS "$2" + shift # past argument + ;; + --db-port) + set_option_value TETHYS_DB_PORT "$2" + shift # past argument + ;; + -S|--superuser) + set_option_value TETHYS_SUPER_USER "$2" + shift # past argument + ;; + -E|--superuser-email) + set_option_value TETHYS_SUPER_USER_EMAIL "$2" + shift # past argument + ;; + -P|--superuser-pass) + set_option_value TETHYS_SUPER_USER_PASS "$2" + shift # past argument + ;; + --skip-tethys-install) + SKIP_TETHYS_INSTALL="true" + ;; + --install-docker) + if [ "$(uname)" = "Linux" ] + then + INSTALL_DOCKER="true" + else + echo Automatic installation of Docker is not supported on $(uname). Ignoring option $key. + fi + ;; + --docker-options) + set_option_value DOCKER_OPTIONS "$2" + shift # past argument + ;; + --production) + if [ "$(uname)" = "Linux" ] + then + PRODUCTION="true" + else + echo Automatic production installation is not supported on $(uname). Ignoring option $key. + fi + ;; + --configure-selinux) + if [ "$(uname)" = "Linux" ] + then + SELINUX="true" + else + echo SELinux confiuration is not supported on $(uname). Ignoring option $key. + fi + ;; + -x) + ECHO_COMMANDS="true" + ;; + -h|--help) + print_usage + ;; + *) # unknown option + echo Ignoring unrecognized option: $key + ;; +esac +shift # past argument or value +done + +# resolve relative paths +resolve_relative_path TETHYS_HOME ${TETHYS_HOME} + +# set CONDA_HOME relative to TETHYS_HOME if not already set +if [ -z ${CONDA_HOME} ] +then + CONDA_HOME="${TETHYS_HOME}/miniconda" +else + resolve_relative_path CONDA_HOME ${CONDA_HOME} +fi + + + +if [ -n "${ECHO_COMMANDS}" ] +then + set -x # echo commands as they are executed +fi + +# Set paths for environment activate/deactivate scripts +ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" +DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" +ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" +DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" + + +if [ -z "${SKIP_TETHYS_INSTALL}" ] +then + echo "Starting Tethys Installation..." + + mkdir -p "${TETHYS_HOME}" + + # install miniconda + # first see if Miniconda is already installed + if [ -f "${CONDA_HOME}/bin/activate" ] + then + echo "Using existing Miniconda installation..." + else + echo "Installing Miniconda..." + wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" || (echo -using curl instead; curl ${MINICONDA_URL} -o "${TETHYS_HOME}/miniconda.sh") + pushd ./ + cd "${TETHYS_HOME}" + bash miniconda.sh -b -p "${CONDA_HOME}" + popd + fi + export PATH="${CONDA_HOME}/bin:$PATH" + + # clone Tethys repo + echo "Cloning the Tethys Platform repo..." + conda install --yes git + # git clone ${TETHYS_REPO} "${TETHYS_HOME}/src" + cd "${TETHYS_HOME}/src" + git checkout ${BRANCH} + + # create conda env and install Tethys + echo "Setting up the ${CONDA_ENV_NAME} environment..." + conda env create -n ${CONDA_ENV_NAME} -f "environment_py${PYTHON_VERSION}.yml" + . activate ${CONDA_ENV_NAME} + python setup.py develop + + # only pass --allowed-hosts option to gen settings command if it is not the default + # if [ ${ALLOWED_HOST} != "127.0.0.1" ] + # then + # ALLOWED_HOST_OPT="--allowed-host ${ALLOWED_HOST}" + # fi + # tethys gen settings ${ALLOWED_HOST_OPT} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} + + # Setup local database + # echo "Setting up the Tethys database..." + # initdb -U postgres -D "${TETHYS_HOME}/psql/data" + # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" + # echo "Waiting for databases to startup..."; sleep 10 + # psql -U postgres -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" + # createdb -U postgres -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 + + # Initialze Tethys database + # tethys manage syncdb + # echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell + # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" stop + # . deactivate + + + # Create environment activate/deactivate scripts + # mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" + + # echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" + # echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" + # echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" + # echo "export CONDA_HOME='${CONDA_HOME}'" >> "${ACTIVATE_SCRIPT}" + # echo "export CONDA_ENV_NAME='${CONDA_ENV_NAME}'" >> "${ACTIVATE_SCRIPT}" + # echo "alias tethys_start_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" -l \"\${TETHYS_HOME}/psql/logfile\" start -o \"-p \${TETHYS_DB_PORT}\"'" >> "${ACTIVATE_SCRIPT}" + # echo "alias tstartdb=tethys_start_db" >> "${ACTIVATE_SCRIPT}" + # echo "alias tethys_stop_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" stop'" >> "${ACTIVATE_SCRIPT}" + # echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" + # echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" + # echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" + + # echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" + # echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" + # echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" + # echo "unset CONDA_HOME" >> "${DEACTIVATE_SCRIPT}" + # echo "unset CONDA_ENV_NAME" >> "${DEACTIVATE_SCRIPT}" + # echo "unalias tethys_start_db" >> "${DEACTIVATE_SCRIPT}" + # echo "unalias tstartdb" >> "${DEACTIVATE_SCRIPT}" + # echo "unalias tethys_stop_db" >> "${DEACTIVATE_SCRIPT}" + # echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" + # echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" + # echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" + + # echo "# Tethys Platform" >> ~/${BASH_PROFILE} + # echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} +fi + +# Install Docker (if flag is set) +set +e # don't exit on error anymore + +# Rename some variables for reference after deactivating tethys environment. +TETHYS_CONDA_HOME=${CONDA_HOME} +TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} + +# Install Production configuration if flag is set + +ubuntu_debian_production_install() { + sudo apt update + sudo apt install -y nginx + sudo rm /etc/nginx/sites-enabled/default + NGINX_SITES_DIR='sites-enabled' +} + +enterprise_linux_production_install() { + sudo yum install nginx -y + sudo systemctl enable nginx + sudo systemctl start nginx + sudo firewall-cmd --permanent --zone=public --add-service=http +# sudo firewall-cmd --permanent --zone=public --add-service=https + sudo firewall-cmd --reload + + NGINX_SITES_DIR='conf.d' +} + +redhat_production_install() { + VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") + sudo bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/rhel/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" + enterprise_linux_production_install +} + +centos_production_install() { + PLATFORM=${LINUX_DISTRIBUTION} + VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") + sudo bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/${PLATFORM}/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" + sudo yum install epel-release -y + enterprise_linux_production_install +} + +configure_selinux() { + sudo yum install setroubleshoot -y + sudo semanage fcontext -a -t httpd_config_t ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + sudo restorecon -v ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}(/.*)?" + sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}/static(/.*)?" + sudo semanage fcontext -a -t httpd_sys_rw_content_t "${TETHYS_HOME}/workspaces(/.*)?" + sudo restorecon -R -v ${TETHYS_HOME} > /dev/null + echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te + + checkmodule -M -m -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te + semodule_package -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp -m ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod + sudo semodule -i ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp +} + +if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] +then + # prompt for sudo + echo "Production installation requires some commands to be run with sudo. Please enter password:" + sudo echo "Installing Tethys Production Server..." + + case ${LINUX_DISTRIBUTION} in + debian) + ubuntu_debian_production_install + ;; + ubuntu) + ubuntu_debian_production_install + ;; + centos) + centos_production_install + ;; + redhat) + redhat_production_install + ;; + fedora) + enterprise_linux_production_install + ;; + *) + echo "Automated production installation on ${LINUX_DISTRIBUTION} is not supported." + ;; + esac + + + . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} + pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" + echo "Waiting for databases to startup..."; sleep 5 + conda install -c conda-forge uwsgi -y + tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite + tethys gen nginx --overwrite + tethys gen uwsgi_settings --overwrite + tethys gen uwsgi_service --overwrite + NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') + NGINX_GROUP=${NGINX_USER} + NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') + mkdir -p ${TETHYS_HOME}/static ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps + sudo chown -R ${USER} ${TETHYS_HOME} + tethys manage collectall --noinput + sudo chmod 705 ~ + sudo mkdir /var/log/uwsgi + sudo touch /var/log/uwsgi/tethys.log + sudo ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ + + if [ -n "${SELINUX}" ] + then + configure_selinux + fi + + sudo chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log + sudo systemctl enable ${TETHYS_HOME}/src/tethys_portal/tethys.uwsgi.service + sudo systemctl start tethys.uwsgi.service + sudo systemctl restart nginx + set +x + . deactivate + + echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" + echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" + echo "alias tuo=tethys_user_own" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_server_own='sudo chown -R \${NGINX_USER}:\${NGINX_USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" + echo "alias tso=tethys_server_own" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_server_restart='tso; sudo systemctl restart tethys.uwsgi.service; sudo systemctl restart nginx'" >> "${ACTIVATE_SCRIPT}" + echo "alias tsr=tethys_server_restart" >> "${ACTIVATE_SCRIPT}" + + echo "unset NGINX_USER" >> "${DEACTIVATE_SCRIPT}" + echo "unset NGINX_HOME" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tethys_user_own" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tuo" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tethys_server_own" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tso" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tethys_server_restart" >> "${DEACTIVATE_SCRIPT}" + echo "unalias trs" >> "${DEACTIVATE_SCRIPT}" +fi + + +# Install Docker (if flag is set + +installation_warning(){ + echo "WARNING: installing docker on $1 is not officially supported by the Tethys install script. Attempting to install with $2 script." +} + +finalize_docker_install(){ + sudo groupadd docker + sudo gpasswd -a ${USER} docker + . ${TETHYS_CONDA_HOME}/bin/activate ${TETHYS_CONDA_ENV_NAME} + sg docker -c "tethys docker init ${DOCKER_OPTIONS}" + . deactivate + echo "Docker installation finished!" + echo "You must re-login for Docker permissions to be activated." + echo "(Alternatively you can run 'newgrp docker')" +} + +ubuntu_debian_docker_install(){ + if [ "${LINUX_DISTRIBUTION}" != "ubuntu" ] && [ ${LINUX_DISTRIBUTION} != "debian" ] + then + installation_warning ${LINUX_DISTRIBUTION} "Ubuntu" + fi + + sudo apt-get update + sudo apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common + curl -fsSL https://download.docker.com/linux/${LINUX_DISTRIBUTION}/gpg | sudo apt-key add - + sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/${LINUX_DISTRIBUTION} $(lsb_release -cs) stable" + sudo apt-get update + sudo apt-get install -y docker-ce + + finalize_docker_install +} + +centos_docker_install(){ + if [ "${LINUX_DISTRIBUTION}" != "centos" ] + then + installation_warning ${LINUX_DISTRIBUTION} "CentOS" + fi + + sudo yum -y install yum-utils + sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + sudo yum makecache fast + sudo yum -y install docker-ce + sudo systemctl start docker + sudo systemctl enable docker + + finalize_docker_install +} + +fedora_docker_install(){ + if [ "${LINUX_DISTRIBUTION}" != "fedora" ] + then + installation_warning ${LINUX_DISTRIBUTION} "Fedora" + fi + + sudo dnf -y install -y dnf-plugins-core + sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo + sudo dnf makecache fast + sudo dnf -y install docker-ce + sudo systemctl start docker + sudo systemctl enable docker + + finalize_docker_install +} + +if [ -n "${LINUX_DISTRIBUTION}" -a "${INSTALL_DOCKER}" = "true" ] +then + # prompt for sudo + echo "Docker installation requires some commands to be run with sudo. Please enter password:" + sudo echo "Installing Docker..." + + case ${LINUX_DISTRIBUTION} in + debian) + ubuntu_debian_docker_install + ;; + ubuntu) + ubuntu_debian_docker_install + ;; + centos) + centos_docker_install + ;; + redhat) + centos_docker_install + ;; + fedora) + fedora_docker_install + ;; + *) + echo "Automated Docker installation on ${LINUX_DISTRIBUTION} is not supported. Please see https://docs.docker.com/engine/installation/ for more information on installing Docker." + ;; + esac +fi + + +if [ -z "${SKIP_TETHYS_INSTALL}" ] +then + echo "Tethys installation complete!" + echo + echo "NOTE: to enable the new alias 't' which activates the tethys environment you must run '. ~/${BASH_PROFILE}'" +fi + +on_exit(){ + set +e + set +x +} +trap on_exit EXIT diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh new file mode 100644 index 000000000..4452d4200 --- /dev/null +++ b/docker/setup_tethys.sh @@ -0,0 +1,516 @@ +#!/bin/bash + +USAGE="USAGE: . install_tethys.sh [options]\n +\n +OPTIONS:\n + -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n + -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n + -p, --port Port on which to serve tethys. Default is 8000.\n + -b, --branch Branch to checkout from version control. Default is 'release'.\n + -c, --conda-home Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n + -n, --conda-env-name Name for tethys conda environment. Default is 'tethys'. + --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n + --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n + --db-password Password that the tethys database server will use. Default is 'pass'.\n + --db-port Port that the tethys database server will use. Default is 5436.\n + -S, --superuser Tethys super user name. Default is 'admin'.\n + -E, --superuser-email Tethys super user email. Default is ''.\n + -P, --superuser-pass Tethys super user password. Default is 'pass'.\n + --skip-tethys-install Flag to skip the Tethys installation so that the Docker installation or production installation can be added to an existing Tethys installation.\n + --install-docker Flag to include Docker installation as part of the install script (Linux only).\n + --docker-options Command line options to pass to the 'tethys docker init' call if --install-docker is used. Default is \"'-d'\".\n + --production Flag to install Tethys in a production configuration.\n + --configure-selinux Flag to perform configuration of SELinux for production installation. (Linux only).\n + -x Flag to turn on shell command echoing.\n + -h, --help Print this help information.\n +" + +print_usage () +{ + echo -e ${USAGE} + exit +} +set -e # exit on error + +# Set platform specific default options +if [ "$(uname)" = "Linux" ] +then + # LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") + # convert to lower case + # LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} + MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" + BASH_PROFILE=".bashrc" + resolve_relative_path () + { + local __path_var="$1" + eval $__path_var="'$(readlink -f $2)'" + } +elif [ "$(uname)" = "Darwin" ] # i.e. MacOSX +then + MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh" + BASH_PROFILE=".bash_profile" + resolve_relative_path () + { + local __path_var="$1" + eval $__path_var="'$(python -c "import os; print(os.path.abspath('$2'))")'" + } +else + echo $(uname) is not a supported operating system. + exit +fi + +# Set default options +ALLOWED_HOST='127.0.0.1' +TETHYS_HOME=~/tethys +TETHYS_PORT=8000 +TETHYS_DB_USERNAME='tethys_default' +TETHYS_DB_PASSWORD='pass' +TETHYS_DB_HOST='127.0.0.1' +TETHYS_DB_PORT=5436 +CONDA_ENV_NAME='tethys' +PYTHON_VERSION='2' +BRANCH='release' + +TETHYS_SUPER_USER='admin' +TETHYS_SUPER_USER_EMAIL='' +TETHYS_SUPER_USER_PASS='pass' + +DOCKER_OPTIONS='-d' + +# parse command line options +set_option_value () +{ + local __option_key="$1" + value="$2" + if [[ $value == -* ]] + then + print_usage + fi + eval $__option_key="$value" +} +while [[ $# -gt 0 ]] +do +key="$1" + +case $key in + -t|--tethys-home) + set_option_value TETHYS_HOME "$2" + shift # past argument + ;; + -a|--allowed-host) + set_option_value ALLOWED_HOST "$2" + shift # past argument + ;; + -p|--port) + set_option_value TETHYS_PORT "$2" + shift # past argument + ;; + -b|--branch) + set_option_value BRANCH "$2" + shift # past argument + ;; + -c|--conda-home) + set_option_value CONDA_HOME "$2" + shift # past argument + ;; + -n|--conda-env-name) + set_option_value CONDA_ENV_NAME "$2" + shift # past argument + ;; + --python-version) + set_option_value PYTHON_VERSION "$2" + shift # past argument + ;; + --db-username) + set_option_value TETHYS_DB_USERNAME "$2" + shift # past argument + ;; + --db-password) + set_option_value TETHYS_DB_PASS "$2" + shift # past argument + ;; + --db-port) + set_option_value TETHYS_DB_PORT "$2" + shift # past argument + ;; + --db-host) + set_option_value TETHYS_DB_HOST "$2" + shift # past argument + ;; + -S|--superuser) + set_option_value TETHYS_SUPER_USER "$2" + shift # past argument + ;; + -E|--superuser-email) + set_option_value TETHYS_SUPER_USER_EMAIL "$2" + shift # past argument + ;; + -P|--superuser-pass) + set_option_value TETHYS_SUPER_USER_PASS "$2" + shift # past argument + ;; + --skip-tethys-install) + SKIP_TETHYS_INSTALL="true" + ;; + --install-docker) + if [ "$(uname)" = "Linux" ] + then + INSTALL_DOCKER="true" + else + echo Automatic installation of Docker is not supported on $(uname). Ignoring option $key. + fi + ;; + --docker-options) + set_option_value DOCKER_OPTIONS "$2" + shift # past argument + ;; + --production) + if [ "$(uname)" = "Linux" ] + then + PRODUCTION="true" + else + echo Automatic production installation is not supported on $(uname). Ignoring option $key. + fi + ;; + --configure-selinux) + if [ "$(uname)" = "Linux" ] + then + SELINUX="true" + else + echo SELinux confiuration is not supported on $(uname). Ignoring option $key. + fi + ;; + -x) + ECHO_COMMANDS="true" + ;; + -h|--help) + print_usage + ;; + *) # unknown option + echo Ignoring unrecognized option: $key + ;; +esac +shift # past argument or value +done + +# resolve relative paths +resolve_relative_path TETHYS_HOME ${TETHYS_HOME} + +# set CONDA_HOME relative to TETHYS_HOME if not already set +if [ -z ${CONDA_HOME} ] +then + CONDA_HOME="${TETHYS_HOME}/miniconda" +else + resolve_relative_path CONDA_HOME ${CONDA_HOME} +fi + + + +if [ -n "${ECHO_COMMANDS}" ] +then + set -x # echo commands as they are executed +fi + +# Set paths for environment activate/deactivate scripts +ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" +DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" +ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" +DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" + + +if [ -z "${SKIP_TETHYS_INSTALL}" ] +then + echo "Starting Tethys Setup..." + . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} + tethys gen settings ${ALLOWED_HOST_OPT} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} + sed -i -e "s/127.0.0.1/${TETHYS_DB_HOST}/g" /usr/lib/tethys/src/tethys_portal/settings.py + # Setup local database + echo "Setting up the Tethys database..." + # initdb -U postgres -D "${TETHYS_HOME}/psql/data" + # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" + echo "Waiting for databases to startup..."; sleep 30 + if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then + psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" + createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 + fi + + # Initialze Tethys database + cd /usr/lib/tethys/src + tethys manage syncdb + echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell + # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" stop + . deactivate + + + # Create environment activate/deactivate scripts + mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" + + echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" + echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" + echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" + echo "export CONDA_HOME='${CONDA_HOME}'" >> "${ACTIVATE_SCRIPT}" + echo "export CONDA_ENV_NAME='${CONDA_ENV_NAME}'" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_start_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" -l \"\${TETHYS_HOME}/psql/logfile\" start -o \"-p \${TETHYS_DB_PORT}\"'" >> "${ACTIVATE_SCRIPT}" + echo "alias tstartdb=tethys_start_db" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_stop_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" stop'" >> "${ACTIVATE_SCRIPT}" + echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" + echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" + echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" + + echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" + echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" + echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" + echo "unset CONDA_HOME" >> "${DEACTIVATE_SCRIPT}" + echo "unset CONDA_ENV_NAME" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tethys_start_db" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tstartdb" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tethys_stop_db" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" + + echo "# Tethys Platform" >> ~/${BASH_PROFILE} + echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} +fi + +# Install Docker (if flag is set) +set +e # don't exit on error anymore + +# Rename some variables for reference after deactivating tethys environment. +TETHYS_CONDA_HOME=${CONDA_HOME} +TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} + +# Install Production configuration if flag is set + +ubuntu_debian_production_install() { + sudo apt update + sudo apt install -y nginx + sudo rm /etc/nginx/sites-enabled/default + NGINX_SITES_DIR='sites-enabled' +} + +enterprise_linux_production_install() { + sudo yum install nginx -y + sudo systemctl enable nginx + sudo systemctl start nginx + sudo firewall-cmd --permanent --zone=public --add-service=http +# sudo firewall-cmd --permanent --zone=public --add-service=https + sudo firewall-cmd --reload + + NGINX_SITES_DIR='conf.d' +} + +redhat_production_install() { + VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") + sudo bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/rhel/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" + enterprise_linux_production_install +} + +centos_production_install() { + PLATFORM=${LINUX_DISTRIBUTION} + VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") + sudo bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/${PLATFORM}/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" + sudo yum install epel-release -y + enterprise_linux_production_install +} + +configure_selinux() { + sudo yum install setroubleshoot -y + sudo semanage fcontext -a -t httpd_config_t ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + sudo restorecon -v ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}(/.*)?" + sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}/static(/.*)?" + sudo semanage fcontext -a -t httpd_sys_rw_content_t "${TETHYS_HOME}/workspaces(/.*)?" + sudo restorecon -R -v ${TETHYS_HOME} > /dev/null + echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te + + checkmodule -M -m -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te + semodule_package -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp -m ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod + sudo semodule -i ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp +} + +if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] +then + # prompt for sudo + echo "Production installation requires some commands to be run with sudo. Please enter password:" + sudo echo "Installing Tethys Production Server..." + + case ${LINUX_DISTRIBUTION} in + debian) + ubuntu_debian_production_install + ;; + ubuntu) + ubuntu_debian_production_install + ;; + centos) + centos_production_install + ;; + redhat) + redhat_production_install + ;; + fedora) + enterprise_linux_production_install + ;; + *) + echo "Automated production installation on ${LINUX_DISTRIBUTION} is not supported." + ;; + esac + + + . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} + pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" + echo "Waiting for databases to startup..."; sleep 5 + conda install -c conda-forge uwsgi -y + tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite + tethys gen nginx --overwrite + tethys gen uwsgi_settings --overwrite + tethys gen uwsgi_service --overwrite + NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') + NGINX_GROUP=${NGINX_USER} + NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') + mkdir -p ${TETHYS_HOME}/static ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps + sudo chown -R ${USER} ${TETHYS_HOME} + tethys manage collectall --noinput + sudo chmod 705 ~ + sudo mkdir /var/log/uwsgi + sudo touch /var/log/uwsgi/tethys.log + sudo ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ + + if [ -n "${SELINUX}" ] + then + configure_selinux + fi + + sudo chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log + sudo systemctl enable ${TETHYS_HOME}/src/tethys_portal/tethys.uwsgi.service + sudo systemctl start tethys.uwsgi.service + sudo systemctl restart nginx + set +x + . deactivate + + echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" + echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" + echo "alias tuo=tethys_user_own" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_server_own='sudo chown -R \${NGINX_USER}:\${NGINX_USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" + echo "alias tso=tethys_server_own" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_server_restart='tso; sudo systemctl restart tethys.uwsgi.service; sudo systemctl restart nginx'" >> "${ACTIVATE_SCRIPT}" + echo "alias tsr=tethys_server_restart" >> "${ACTIVATE_SCRIPT}" + + echo "unset NGINX_USER" >> "${DEACTIVATE_SCRIPT}" + echo "unset NGINX_HOME" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tethys_user_own" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tuo" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tethys_server_own" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tso" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tethys_server_restart" >> "${DEACTIVATE_SCRIPT}" + echo "unalias trs" >> "${DEACTIVATE_SCRIPT}" +fi + + +# Install Docker (if flag is set + +installation_warning(){ + echo "WARNING: installing docker on $1 is not officially supported by the Tethys install script. Attempting to install with $2 script." +} + +finalize_docker_install(){ + sudo groupadd docker + sudo gpasswd -a ${USER} docker + . ${TETHYS_CONDA_HOME}/bin/activate ${TETHYS_CONDA_ENV_NAME} + sg docker -c "tethys docker init ${DOCKER_OPTIONS}" + . deactivate + echo "Docker installation finished!" + echo "You must re-login for Docker permissions to be activated." + echo "(Alternatively you can run 'newgrp docker')" +} + +ubuntu_debian_docker_install(){ + if [ "${LINUX_DISTRIBUTION}" != "ubuntu" ] && [ ${LINUX_DISTRIBUTION} != "debian" ] + then + installation_warning ${LINUX_DISTRIBUTION} "Ubuntu" + fi + + sudo apt-get update + sudo apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common + curl -fsSL https://download.docker.com/linux/${LINUX_DISTRIBUTION}/gpg | sudo apt-key add - + sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/${LINUX_DISTRIBUTION} $(lsb_release -cs) stable" + sudo apt-get update + sudo apt-get install -y docker-ce + + finalize_docker_install +} + +centos_docker_install(){ + if [ "${LINUX_DISTRIBUTION}" != "centos" ] + then + installation_warning ${LINUX_DISTRIBUTION} "CentOS" + fi + + sudo yum -y install yum-utils + sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + sudo yum makecache fast + sudo yum -y install docker-ce + sudo systemctl start docker + sudo systemctl enable docker + + finalize_docker_install +} + +fedora_docker_install(){ + if [ "${LINUX_DISTRIBUTION}" != "fedora" ] + then + installation_warning ${LINUX_DISTRIBUTION} "Fedora" + fi + + sudo dnf -y install -y dnf-plugins-core + sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo + sudo dnf makecache fast + sudo dnf -y install docker-ce + sudo systemctl start docker + sudo systemctl enable docker + + finalize_docker_install +} + +if [ -n "${LINUX_DISTRIBUTION}" -a "${INSTALL_DOCKER}" = "true" ] +then + # prompt for sudo + echo "Docker installation requires some commands to be run with sudo. Please enter password:" + sudo echo "Installing Docker..." + + case ${LINUX_DISTRIBUTION} in + debian) + ubuntu_debian_docker_install + ;; + ubuntu) + ubuntu_debian_docker_install + ;; + centos) + centos_docker_install + ;; + redhat) + centos_docker_install + ;; + fedora) + fedora_docker_install + ;; + *) + echo "Automated Docker installation on ${LINUX_DISTRIBUTION} is not supported. Please see https://docs.docker.com/engine/installation/ for more information on installing Docker." + ;; + esac +fi + + +if [ -z "${SKIP_TETHYS_INSTALL}" ] +then + echo "Tethys installation complete!" + echo + echo "NOTE: to enable the new alias 't' which activates the tethys environment you must run '. ~/${BASH_PROFILE}'" +fi + +on_exit(){ + set +e + set +x +} +trap on_exit EXIT From 27545e8cfedab82808820e90614499bf2a3fdb64 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 18 Sep 2017 09:04:13 -0600 Subject: [PATCH 012/215] Fix check for user in database --- docker/setup_tethys.sh | 135 ++--------------------------------------- 1 file changed, 5 insertions(+), 130 deletions(-) diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index 4452d4200..c4e91f359 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -16,12 +16,7 @@ OPTIONS:\n -S, --superuser Tethys super user name. Default is 'admin'.\n -E, --superuser-email Tethys super user email. Default is ''.\n -P, --superuser-pass Tethys super user password. Default is 'pass'.\n - --skip-tethys-install Flag to skip the Tethys installation so that the Docker installation or production installation can be added to an existing Tethys installation.\n - --install-docker Flag to include Docker installation as part of the install script (Linux only).\n - --docker-options Command line options to pass to the 'tethys docker init' call if --install-docker is used. Default is \"'-d'\".\n --production Flag to install Tethys in a production configuration.\n - --configure-selinux Flag to perform configuration of SELinux for production installation. (Linux only).\n - -x Flag to turn on shell command echoing.\n -h, --help Print this help information.\n " @@ -149,37 +144,10 @@ case $key in set_option_value TETHYS_SUPER_USER_PASS "$2" shift # past argument ;; - --skip-tethys-install) - SKIP_TETHYS_INSTALL="true" - ;; - --install-docker) - if [ "$(uname)" = "Linux" ] - then - INSTALL_DOCKER="true" - else - echo Automatic installation of Docker is not supported on $(uname). Ignoring option $key. - fi - ;; --docker-options) set_option_value DOCKER_OPTIONS "$2" shift # past argument ;; - --production) - if [ "$(uname)" = "Linux" ] - then - PRODUCTION="true" - else - echo Automatic production installation is not supported on $(uname). Ignoring option $key. - fi - ;; - --configure-selinux) - if [ "$(uname)" = "Linux" ] - then - SELINUX="true" - else - echo SELinux confiuration is not supported on $(uname). Ignoring option $key. - fi - ;; -x) ECHO_COMMANDS="true" ;; @@ -226,10 +194,11 @@ then sed -i -e "s/127.0.0.1/${TETHYS_DB_HOST}/g" /usr/lib/tethys/src/tethys_portal/settings.py # Setup local database echo "Setting up the Tethys database..." - # initdb -U postgres -D "${TETHYS_HOME}/psql/data" - # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" - echo "Waiting for databases to startup..."; sleep 30 - if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then + echo "Waiting for databases to startup... (Waiting 60 seconds)"; sleep 60 + if psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -t -c '\du' | cut -d \| -f 1 | grep -qw '${TETHYS_DB_USERNAME}'; then + echo "Username '${TETHYS_DB_USERNAME}' already exists in the database" + else + echo "Creating username and database" psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 fi @@ -408,100 +377,6 @@ then fi -# Install Docker (if flag is set - -installation_warning(){ - echo "WARNING: installing docker on $1 is not officially supported by the Tethys install script. Attempting to install with $2 script." -} - -finalize_docker_install(){ - sudo groupadd docker - sudo gpasswd -a ${USER} docker - . ${TETHYS_CONDA_HOME}/bin/activate ${TETHYS_CONDA_ENV_NAME} - sg docker -c "tethys docker init ${DOCKER_OPTIONS}" - . deactivate - echo "Docker installation finished!" - echo "You must re-login for Docker permissions to be activated." - echo "(Alternatively you can run 'newgrp docker')" -} - -ubuntu_debian_docker_install(){ - if [ "${LINUX_DISTRIBUTION}" != "ubuntu" ] && [ ${LINUX_DISTRIBUTION} != "debian" ] - then - installation_warning ${LINUX_DISTRIBUTION} "Ubuntu" - fi - - sudo apt-get update - sudo apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common - curl -fsSL https://download.docker.com/linux/${LINUX_DISTRIBUTION}/gpg | sudo apt-key add - - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/${LINUX_DISTRIBUTION} $(lsb_release -cs) stable" - sudo apt-get update - sudo apt-get install -y docker-ce - - finalize_docker_install -} - -centos_docker_install(){ - if [ "${LINUX_DISTRIBUTION}" != "centos" ] - then - installation_warning ${LINUX_DISTRIBUTION} "CentOS" - fi - - sudo yum -y install yum-utils - sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo - sudo yum makecache fast - sudo yum -y install docker-ce - sudo systemctl start docker - sudo systemctl enable docker - - finalize_docker_install -} - -fedora_docker_install(){ - if [ "${LINUX_DISTRIBUTION}" != "fedora" ] - then - installation_warning ${LINUX_DISTRIBUTION} "Fedora" - fi - - sudo dnf -y install -y dnf-plugins-core - sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo - sudo dnf makecache fast - sudo dnf -y install docker-ce - sudo systemctl start docker - sudo systemctl enable docker - - finalize_docker_install -} - -if [ -n "${LINUX_DISTRIBUTION}" -a "${INSTALL_DOCKER}" = "true" ] -then - # prompt for sudo - echo "Docker installation requires some commands to be run with sudo. Please enter password:" - sudo echo "Installing Docker..." - - case ${LINUX_DISTRIBUTION} in - debian) - ubuntu_debian_docker_install - ;; - ubuntu) - ubuntu_debian_docker_install - ;; - centos) - centos_docker_install - ;; - redhat) - centos_docker_install - ;; - fedora) - fedora_docker_install - ;; - *) - echo "Automated Docker installation on ${LINUX_DISTRIBUTION} is not supported. Please see https://docs.docker.com/engine/installation/ for more information on installing Docker." - ;; - esac -fi - - if [ -z "${SKIP_TETHYS_INSTALL}" ] then echo "Tethys installation complete!" From dbc8fe465caae2ecdd58594970295adcdcb5b334 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 18 Sep 2017 11:53:52 -0600 Subject: [PATCH 013/215] update if statement for users --- docker/setup_tethys.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index c4e91f359..7a60bd781 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -13,6 +13,7 @@ OPTIONS:\n --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n --db-password Password that the tethys database server will use. Default is 'pass'.\n --db-port Port that the tethys database server will use. Default is 5436.\n + --db-create Create DB and User -S, --superuser Tethys super user name. Default is 'admin'.\n -E, --superuser-email Tethys super user email. Default is ''.\n -P, --superuser-pass Tethys super user password. Default is 'pass'.\n @@ -132,6 +133,10 @@ case $key in set_option_value TETHYS_DB_HOST "$2" shift # past argument ;; + --db-create) + set_option_value TETHYS_DB_CREATE "true" + shift # past argument + ;; -S|--superuser) set_option_value TETHYS_SUPER_USER "$2" shift # past argument @@ -195,7 +200,9 @@ then # Setup local database echo "Setting up the Tethys database..." echo "Waiting for databases to startup... (Waiting 60 seconds)"; sleep 60 - if psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -t -c '\du' | cut -d \| -f 1 | grep -qw '${TETHYS_DB_USERNAME}'; then + # if psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -t -c '\du' | cut -d \| -f 1 | grep -qw '${TETHYS_DB_USERNAME}'; then + if [ ${TETHYS_DB_CREATE} == "true" ] + then echo "Username '${TETHYS_DB_USERNAME}' already exists in the database" else echo "Creating username and database" From 438e1ecfb428970322c5088e09d627ea4e88bb2a Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 18 Sep 2017 12:12:59 -0600 Subject: [PATCH 014/215] update if statement for users --- docker/setup_tethys.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index 7a60bd781..96bc6e859 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -63,6 +63,7 @@ TETHYS_DB_USERNAME='tethys_default' TETHYS_DB_PASSWORD='pass' TETHYS_DB_HOST='127.0.0.1' TETHYS_DB_PORT=5436 +TETHYS_DB_CREATE="false" CONDA_ENV_NAME='tethys' PYTHON_VERSION='2' BRANCH='release' From e40c9106784bfdd407ee7636df9943fbca6f5862 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 18 Sep 2017 12:31:37 -0600 Subject: [PATCH 015/215] update if statement for users (again) --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index cf6705d5e..a4a631603 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,6 +44,7 @@ CMD echo Stating Tethys Setup \ --db-password ${TETHYSBUILD_DB_PASSWORD:-pass} \ --db-host ${TETHYSBUILD_DB_HOST:-127.0.0.1} \ --db-port ${TETHYSBUILD_DB_PORT:-5432} \ + --db-create "true" \ --superuser ${TETHYSBUILD_SUPERUSER:-tethys_super} \ --superuser-pass ${TETHYSBUILD_SUPERUSER_PASS:-admin} \ --tethys-home ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys} \ From c81d579c09e2155b5da2c9160d8d5333438641ad Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 18 Sep 2017 12:53:31 -0600 Subject: [PATCH 016/215] update if statement for users (again) --- docker/setup_tethys.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index 96bc6e859..9dc6b7cf6 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -214,7 +214,10 @@ then # Initialze Tethys database cd /usr/lib/tethys/src tethys manage syncdb - echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell + if [ ${TETHYS_DB_CREATE} == "true" ] + then + echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell + fi # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" stop . deactivate From c053b89adb39f0a4f8713f1d72be594ed40f08c7 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 18 Sep 2017 13:42:00 -0600 Subject: [PATCH 017/215] revert back to old --- Dockerfile | 3 - docker/setup_tethys.sh | 300 +++++++++++++++++++++-------------------- 2 files changed, 154 insertions(+), 149 deletions(-) diff --git a/Dockerfile b/Dockerfile index a4a631603..484449aaa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,6 @@ RUN pwd \ --conda-home $TETHYSBUILD_CONDA_HOME \ --conda-env-name $TETHYSBUILD_CONDA_ENV_NAME \ - # Make port 8000 available to the outside world EXPOSE 8000 @@ -34,7 +33,6 @@ EXPOSE 8000 ENV PATH ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda}/envs/tethys/bin:$PATH # Install Tethys - CMD echo Stating Tethys Setup \ && bash setup_tethys.sh \ -b ${TETHYSBUILD_BRANCH:-release} \ @@ -44,7 +42,6 @@ CMD echo Stating Tethys Setup \ --db-password ${TETHYSBUILD_DB_PASSWORD:-pass} \ --db-host ${TETHYSBUILD_DB_HOST:-127.0.0.1} \ --db-port ${TETHYSBUILD_DB_PORT:-5432} \ - --db-create "true" \ --superuser ${TETHYSBUILD_SUPERUSER:-tethys_super} \ --superuser-pass ${TETHYSBUILD_SUPERUSER_PASS:-admin} \ --tethys-home ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys} \ diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index 9dc6b7cf6..9741e2160 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -1,39 +1,32 @@ #!/bin/bash - -USAGE="USAGE: . install_tethys.sh [options]\n -\n -OPTIONS:\n - -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n - -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n - -p, --port Port on which to serve tethys. Default is 8000.\n - -b, --branch Branch to checkout from version control. Default is 'release'.\n - -c, --conda-home Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n - -n, --conda-env-name Name for tethys conda environment. Default is 'tethys'. - --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n - --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n - --db-password Password that the tethys database server will use. Default is 'pass'.\n - --db-port Port that the tethys database server will use. Default is 5436.\n - --db-create Create DB and User - -S, --superuser Tethys super user name. Default is 'admin'.\n - -E, --superuser-email Tethys super user email. Default is ''.\n - -P, --superuser-pass Tethys super user password. Default is 'pass'.\n - --production Flag to install Tethys in a production configuration.\n - -h, --help Print this help information.\n -" - -print_usage () -{ +USAGE="USAGE: . install_tethys.sh [options]\n \n OPTIONS:\n + -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n + -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n + -p, --port Port on which to serve tethys. Default is 8000.\n + -b, --branch Branch to checkout from version control. Default is 'release'.\n + -c, --conda-home Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n + -n, --conda-env-name Name for tethys conda environment. Default is 'tethys'. + --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n + --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n + --db-password Password that the tethys database server will use. Default is 'pass'.\n + --db-port Port that the tethys database server will use. Default is 5436.\n + -S, --superuser Tethys super user name. Default is 'admin'.\n + -E, --superuser-email Tethys super user email. Default is ''.\n + -P, --superuser-pass Tethys super user password. Default is 'pass'.\n + --skip-tethys-install Flag to skip the Tethys installation so that the Docker installation or production installation can be added to an existing Tethys installation.\n + --install-docker Flag to include Docker installation as part of the install script (Linux only).\n + --docker-options Command line options to pass to the 'tethys docker init' call if --install-docker is used. Default is \"'-d'\".\n + --production Flag to install Tethys in a production configuration.\n + --configure-selinux Flag to perform configuration of SELinux for production installation. (Linux only).\n + -x Flag to turn on shell command echoing.\n + -h, --help Print this help information.\n " print_usage () { echo -e ${USAGE} exit } -set -e # exit on error - +set -e # exit on error # Set platform specific default options -if [ "$(uname)" = "Linux" ] -then - # LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") - # convert to lower case - # LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} +if [ "$(uname)" = "Linux" ] then + # LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") convert to lower case LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" BASH_PROFILE=".bashrc" resolve_relative_path () @@ -41,8 +34,7 @@ then local __path_var="$1" eval $__path_var="'$(readlink -f $2)'" } -elif [ "$(uname)" = "Darwin" ] # i.e. MacOSX -then +elif [ "$(uname)" = "Darwin" ] # i.e. MacOSX then MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh" BASH_PROFILE=".bash_profile" resolve_relative_path () @@ -52,31 +44,12 @@ then } else echo $(uname) is not a supported operating system. - exit -fi - + exit fi # Set default options -ALLOWED_HOST='127.0.0.1' -TETHYS_HOME=~/tethys -TETHYS_PORT=8000 -TETHYS_DB_USERNAME='tethys_default' -TETHYS_DB_PASSWORD='pass' -TETHYS_DB_HOST='127.0.0.1' -TETHYS_DB_PORT=5436 -TETHYS_DB_CREATE="false" -CONDA_ENV_NAME='tethys' -PYTHON_VERSION='2' -BRANCH='release' - -TETHYS_SUPER_USER='admin' -TETHYS_SUPER_USER_EMAIL='' -TETHYS_SUPER_USER_PASS='pass' - -DOCKER_OPTIONS='-d' - +ALLOWED_HOST='127.0.0.1' TETHYS_HOME=~/tethys TETHYS_PORT=8000 TETHYS_DB_USERNAME='tethys_default' TETHYS_DB_PASSWORD='pass' TETHYS_DB_HOST='127.0.0.1' TETHYS_DB_PORT=5436 CONDA_ENV_NAME='tethys' PYTHON_VERSION='2' +BRANCH='release' TETHYS_SUPER_USER='admin' TETHYS_SUPER_USER_EMAIL='' TETHYS_SUPER_USER_PASS='pass' DOCKER_OPTIONS='-d' # parse command line options -set_option_value () -{ +set_option_value () { local __option_key="$1" value="$2" if [[ $value == -* ]] @@ -85,11 +58,7 @@ set_option_value () fi eval $__option_key="$value" } -while [[ $# -gt 0 ]] -do -key="$1" - -case $key in +while [[ $# -gt 0 ]] do key="$1" case $key in -t|--tethys-home) set_option_value TETHYS_HOME "$2" shift # past argument @@ -134,10 +103,6 @@ case $key in set_option_value TETHYS_DB_HOST "$2" shift # past argument ;; - --db-create) - set_option_value TETHYS_DB_CREATE "true" - shift # past argument - ;; -S|--superuser) set_option_value TETHYS_SUPER_USER "$2" shift # past argument @@ -150,10 +115,37 @@ case $key in set_option_value TETHYS_SUPER_USER_PASS "$2" shift # past argument ;; + --skip-tethys-install) + SKIP_TETHYS_INSTALL="true" + ;; + --install-docker) + if [ "$(uname)" = "Linux" ] + then + INSTALL_DOCKER="true" + else + echo Automatic installation of Docker is not supported on $(uname). Ignoring option $key. + fi + ;; --docker-options) set_option_value DOCKER_OPTIONS "$2" shift # past argument ;; + --production) + if [ "$(uname)" = "Linux" ] + then + PRODUCTION="true" + else + echo Automatic production installation is not supported on $(uname). Ignoring option $key. + fi + ;; + --configure-selinux) + if [ "$(uname)" = "Linux" ] + then + SELINUX="true" + else + echo SELinux confiuration is not supported on $(uname). Ignoring option $key. + fi + ;; -x) ECHO_COMMANDS="true" ;; @@ -162,69 +154,38 @@ case $key in ;; *) # unknown option echo Ignoring unrecognized option: $key - ;; -esac -shift # past argument or value -done - + ;; esac shift # past argument or value done # resolve relative paths resolve_relative_path TETHYS_HOME ${TETHYS_HOME} - # set CONDA_HOME relative to TETHYS_HOME if not already set -if [ -z ${CONDA_HOME} ] -then - CONDA_HOME="${TETHYS_HOME}/miniconda" -else - resolve_relative_path CONDA_HOME ${CONDA_HOME} -fi - - - -if [ -n "${ECHO_COMMANDS}" ] -then - set -x # echo commands as they are executed -fi - +if [ -z ${CONDA_HOME} ] then + CONDA_HOME="${TETHYS_HOME}/miniconda" else + resolve_relative_path CONDA_HOME ${CONDA_HOME} fi if [ -n "${ECHO_COMMANDS}" ] then + set -x # echo commands as they are executed fi # Set paths for environment activate/deactivate scripts -ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" -DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" -ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" -DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" - - -if [ -z "${SKIP_TETHYS_INSTALL}" ] -then +ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" +DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" if [ -z "${SKIP_TETHYS_INSTALL}" ] then echo "Starting Tethys Setup..." . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} tethys gen settings ${ALLOWED_HOST_OPT} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} sed -i -e "s/127.0.0.1/${TETHYS_DB_HOST}/g" /usr/lib/tethys/src/tethys_portal/settings.py # Setup local database echo "Setting up the Tethys database..." - echo "Waiting for databases to startup... (Waiting 60 seconds)"; sleep 60 - # if psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -t -c '\du' | cut -d \| -f 1 | grep -qw '${TETHYS_DB_USERNAME}'; then - if [ ${TETHYS_DB_CREATE} == "true" ] - then - echo "Username '${TETHYS_DB_USERNAME}' already exists in the database" - else - echo "Creating username and database" + # initdb -U postgres -D "${TETHYS_HOME}/psql/data" pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" + echo "Waiting for databases to startup..."; sleep 30 + echo if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then + if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 fi - # Initialze Tethys database cd /usr/lib/tethys/src tethys manage syncdb - if [ ${TETHYS_DB_CREATE} == "true" ] - then - echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell - fi + echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" stop . deactivate - - # Create environment activate/deactivate scripts mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" - echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" @@ -236,7 +197,6 @@ then echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" - echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" @@ -248,27 +208,19 @@ then echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" - echo "# Tethys Platform" >> ~/${BASH_PROFILE} - echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} -fi - + echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} fi # Install Docker (if flag is set) -set +e # don't exit on error anymore - +set +e # don't exit on error anymore # Rename some variables for reference after deactivating tethys environment. -TETHYS_CONDA_HOME=${CONDA_HOME} -TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} - +TETHYS_CONDA_HOME=${CONDA_HOME} TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} # Install Production configuration if flag is set - ubuntu_debian_production_install() { sudo apt update sudo apt install -y nginx sudo rm /etc/nginx/sites-enabled/default NGINX_SITES_DIR='sites-enabled' } - enterprise_linux_production_install() { sudo yum install nginx -y sudo systemctl enable nginx @@ -276,16 +228,13 @@ enterprise_linux_production_install() { sudo firewall-cmd --permanent --zone=public --add-service=http # sudo firewall-cmd --permanent --zone=public --add-service=https sudo firewall-cmd --reload - NGINX_SITES_DIR='conf.d' } - redhat_production_install() { VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") sudo bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/rhel/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" enterprise_linux_production_install } - centos_production_install() { PLATFORM=${LINUX_DISTRIBUTION} VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") @@ -293,7 +242,6 @@ centos_production_install() { sudo yum install epel-release -y enterprise_linux_production_install } - configure_selinux() { sudo yum install setroubleshoot -y sudo semanage fcontext -a -t httpd_config_t ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf @@ -302,19 +250,16 @@ configure_selinux() { sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}/static(/.*)?" sudo semanage fcontext -a -t httpd_sys_rw_content_t "${TETHYS_HOME}/workspaces(/.*)?" sudo restorecon -R -v ${TETHYS_HOME} > /dev/null - echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te - + echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > +${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te checkmodule -M -m -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te semodule_package -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp -m ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod sudo semodule -i ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp } - -if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] -then +if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] then # prompt for sudo echo "Production installation requires some commands to be run with sudo. Please enter password:" sudo echo "Installing Tethys Production Server..." - case ${LINUX_DISTRIBUTION} in debian) ubuntu_debian_production_install @@ -335,8 +280,6 @@ then echo "Automated production installation on ${LINUX_DISTRIBUTION} is not supported." ;; esac - - . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" echo "Waiting for databases to startup..."; sleep 5 @@ -355,19 +298,16 @@ then sudo mkdir /var/log/uwsgi sudo touch /var/log/uwsgi/tethys.log sudo ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ - if [ -n "${SELINUX}" ] then configure_selinux fi - sudo chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log sudo systemctl enable ${TETHYS_HOME}/src/tethys_portal/tethys.uwsgi.service sudo systemctl start tethys.uwsgi.service sudo systemctl restart nginx set +x . deactivate - echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" @@ -376,7 +316,6 @@ then echo "alias tso=tethys_server_own" >> "${ACTIVATE_SCRIPT}" echo "alias tethys_server_restart='tso; sudo systemctl restart tethys.uwsgi.service; sudo systemctl restart nginx'" >> "${ACTIVATE_SCRIPT}" echo "alias tsr=tethys_server_restart" >> "${ACTIVATE_SCRIPT}" - echo "unset NGINX_USER" >> "${DEACTIVATE_SCRIPT}" echo "unset NGINX_HOME" >> "${DEACTIVATE_SCRIPT}" echo "unalias tethys_user_own" >> "${DEACTIVATE_SCRIPT}" @@ -384,18 +323,87 @@ then echo "unalias tethys_server_own" >> "${DEACTIVATE_SCRIPT}" echo "unalias tso" >> "${DEACTIVATE_SCRIPT}" echo "unalias tethys_server_restart" >> "${DEACTIVATE_SCRIPT}" - echo "unalias trs" >> "${DEACTIVATE_SCRIPT}" -fi - - -if [ -z "${SKIP_TETHYS_INSTALL}" ] -then + echo "unalias trs" >> "${DEACTIVATE_SCRIPT}" fi +# Install Docker (if flag is set +installation_warning(){ + echo "WARNING: installing docker on $1 is not officially supported by the Tethys install script. Attempting to install with $2 script." +} +finalize_docker_install(){ + sudo groupadd docker + sudo gpasswd -a ${USER} docker + . ${TETHYS_CONDA_HOME}/bin/activate ${TETHYS_CONDA_ENV_NAME} + sg docker -c "tethys docker init ${DOCKER_OPTIONS}" + . deactivate + echo "Docker installation finished!" + echo "You must re-login for Docker permissions to be activated." + echo "(Alternatively you can run 'newgrp docker')" +} +ubuntu_debian_docker_install(){ + if [ "${LINUX_DISTRIBUTION}" != "ubuntu" ] && [ ${LINUX_DISTRIBUTION} != "debian" ] + then + installation_warning ${LINUX_DISTRIBUTION} "Ubuntu" + fi + sudo apt-get update + sudo apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common + curl -fsSL https://download.docker.com/linux/${LINUX_DISTRIBUTION}/gpg | sudo apt-key add - + sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/${LINUX_DISTRIBUTION} $(lsb_release -cs) stable" + sudo apt-get update + sudo apt-get install -y docker-ce + finalize_docker_install +} +centos_docker_install(){ + if [ "${LINUX_DISTRIBUTION}" != "centos" ] + then + installation_warning ${LINUX_DISTRIBUTION} "CentOS" + fi + sudo yum -y install yum-utils + sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + sudo yum makecache fast + sudo yum -y install docker-ce + sudo systemctl start docker + sudo systemctl enable docker + finalize_docker_install +} +fedora_docker_install(){ + if [ "${LINUX_DISTRIBUTION}" != "fedora" ] + then + installation_warning ${LINUX_DISTRIBUTION} "Fedora" + fi + sudo dnf -y install -y dnf-plugins-core + sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo + sudo dnf makecache fast + sudo dnf -y install docker-ce + sudo systemctl start docker + sudo systemctl enable docker + finalize_docker_install +} +if [ -n "${LINUX_DISTRIBUTION}" -a "${INSTALL_DOCKER}" = "true" ] then + # prompt for sudo + echo "Docker installation requires some commands to be run with sudo. Please enter password:" + sudo echo "Installing Docker..." + case ${LINUX_DISTRIBUTION} in + debian) + ubuntu_debian_docker_install + ;; + ubuntu) + ubuntu_debian_docker_install + ;; + centos) + centos_docker_install + ;; + redhat) + centos_docker_install + ;; + fedora) + fedora_docker_install + ;; + *) + echo "Automated Docker installation on ${LINUX_DISTRIBUTION} is not supported. Please see https://docs.docker.com/engine/installation/ for more information on installing Docker." + ;; + esac fi if [ -z "${SKIP_TETHYS_INSTALL}" ] then echo "Tethys installation complete!" echo - echo "NOTE: to enable the new alias 't' which activates the tethys environment you must run '. ~/${BASH_PROFILE}'" -fi - -on_exit(){ + echo "NOTE: to enable the new alias 't' which activates the tethys environment you must run '. ~/${BASH_PROFILE}'" fi on_exit(){ set +e set +x } From d739c98b44b62d956fcbcd706c6c337cc4b6b878 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 18 Sep 2017 14:19:46 -0600 Subject: [PATCH 018/215] Try another pass at username stuff --- docker/setup_tethys.sh | 212 +++++++++++++++++++++++++++++++---------- 1 file changed, 161 insertions(+), 51 deletions(-) diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index 9741e2160..be9554d7b 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -1,32 +1,43 @@ #!/bin/bash -USAGE="USAGE: . install_tethys.sh [options]\n \n OPTIONS:\n - -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n - -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n - -p, --port Port on which to serve tethys. Default is 8000.\n - -b, --branch Branch to checkout from version control. Default is 'release'.\n - -c, --conda-home Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n - -n, --conda-env-name Name for tethys conda environment. Default is 'tethys'. - --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n - --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n - --db-password Password that the tethys database server will use. Default is 'pass'.\n - --db-port Port that the tethys database server will use. Default is 5436.\n - -S, --superuser Tethys super user name. Default is 'admin'.\n - -E, --superuser-email Tethys super user email. Default is ''.\n - -P, --superuser-pass Tethys super user password. Default is 'pass'.\n - --skip-tethys-install Flag to skip the Tethys installation so that the Docker installation or production installation can be added to an existing Tethys installation.\n - --install-docker Flag to include Docker installation as part of the install script (Linux only).\n - --docker-options Command line options to pass to the 'tethys docker init' call if --install-docker is used. Default is \"'-d'\".\n - --production Flag to install Tethys in a production configuration.\n - --configure-selinux Flag to perform configuration of SELinux for production installation. (Linux only).\n - -x Flag to turn on shell command echoing.\n - -h, --help Print this help information.\n " print_usage () { + +USAGE="USAGE: . install_tethys.sh [options]\n +\n +OPTIONS:\n + -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n + -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n + -p, --port Port on which to serve tethys. Default is 8000.\n + -b, --branch Branch to checkout from version control. Default is 'release'.\n + -c, --conda-home Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n + -n, --conda-env-name Name for tethys conda environment. Default is 'tethys'. + --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n + --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n + --db-password Password that the tethys database server will use. Default is 'pass'.\n + --db-port Port that the tethys database server will use. Default is 5436.\n + -S, --superuser Tethys super user name. Default is 'admin'.\n + -E, --superuser-email Tethys super user email. Default is ''.\n + -P, --superuser-pass Tethys super user password. Default is 'pass'.\n + --skip-tethys-install Flag to skip the Tethys installation so that the Docker installation or production installation can be added to an existing Tethys installation.\n + --install-docker Flag to include Docker installation as part of the install script (Linux only).\n + --docker-options Command line options to pass to the 'tethys docker init' call if --install-docker is used. Default is \"'-d'\".\n + --production Flag to install Tethys in a production configuration.\n + --configure-selinux Flag to perform configuration of SELinux for production installation. (Linux only).\n + -x Flag to turn on shell command echoing.\n + -h, --help Print this help information.\n +" + +print_usage () +{ echo -e ${USAGE} exit } -set -e # exit on error +set -e # exit on error + # Set platform specific default options -if [ "$(uname)" = "Linux" ] then - # LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") convert to lower case LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} +if [ "$(uname)" = "Linux" ] +then + # LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") + # convert to lower case + # LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" BASH_PROFILE=".bashrc" resolve_relative_path () @@ -34,7 +45,8 @@ if [ "$(uname)" = "Linux" ] then local __path_var="$1" eval $__path_var="'$(readlink -f $2)'" } -elif [ "$(uname)" = "Darwin" ] # i.e. MacOSX then +elif [ "$(uname)" = "Darwin" ] # i.e. MacOSX +then MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh" BASH_PROFILE=".bash_profile" resolve_relative_path () @@ -44,12 +56,30 @@ elif [ "$(uname)" = "Darwin" ] # i.e. MacOSX then } else echo $(uname) is not a supported operating system. - exit fi + exit +fi + # Set default options -ALLOWED_HOST='127.0.0.1' TETHYS_HOME=~/tethys TETHYS_PORT=8000 TETHYS_DB_USERNAME='tethys_default' TETHYS_DB_PASSWORD='pass' TETHYS_DB_HOST='127.0.0.1' TETHYS_DB_PORT=5436 CONDA_ENV_NAME='tethys' PYTHON_VERSION='2' -BRANCH='release' TETHYS_SUPER_USER='admin' TETHYS_SUPER_USER_EMAIL='' TETHYS_SUPER_USER_PASS='pass' DOCKER_OPTIONS='-d' +ALLOWED_HOST='127.0.0.1' +TETHYS_HOME=~/tethys +TETHYS_PORT=8000 +TETHYS_DB_USERNAME='tethys_default' +TETHYS_DB_PASSWORD='pass' +TETHYS_DB_HOST='127.0.0.1' +TETHYS_DB_PORT=5436 +CONDA_ENV_NAME='tethys' +PYTHON_VERSION='2' +BRANCH='release' + +TETHYS_SUPER_USER='admin' +TETHYS_SUPER_USER_EMAIL='' +TETHYS_SUPER_USER_PASS='pass' + +DOCKER_OPTIONS='-d' + # parse command line options -set_option_value () { +set_option_value () +{ local __option_key="$1" value="$2" if [[ $value == -* ]] @@ -58,7 +88,11 @@ set_option_value () { fi eval $__option_key="$value" } -while [[ $# -gt 0 ]] do key="$1" case $key in +while [[ $# -gt 0 ]] +do +key="$1" + +case $key in -t|--tethys-home) set_option_value TETHYS_HOME "$2" shift # past argument @@ -154,38 +188,66 @@ while [[ $# -gt 0 ]] do key="$1" case $key in ;; *) # unknown option echo Ignoring unrecognized option: $key - ;; esac shift # past argument or value done + ;; +esac +shift # past argument or value +done + # resolve relative paths resolve_relative_path TETHYS_HOME ${TETHYS_HOME} + # set CONDA_HOME relative to TETHYS_HOME if not already set -if [ -z ${CONDA_HOME} ] then - CONDA_HOME="${TETHYS_HOME}/miniconda" else - resolve_relative_path CONDA_HOME ${CONDA_HOME} fi if [ -n "${ECHO_COMMANDS}" ] then - set -x # echo commands as they are executed fi +if [ -z ${CONDA_HOME} ] +then + CONDA_HOME="${TETHYS_HOME}/miniconda" +else + resolve_relative_path CONDA_HOME ${CONDA_HOME} +fi + + + +if [ -n "${ECHO_COMMANDS}" ] +then + set -x # echo commands as they are executed +fi + # Set paths for environment activate/deactivate scripts -ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" -DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" if [ -z "${SKIP_TETHYS_INSTALL}" ] then +ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" +DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" +ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" +DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" + + +if [ -z "${SKIP_TETHYS_INSTALL}" ] +then echo "Starting Tethys Setup..." . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} tethys gen settings ${ALLOWED_HOST_OPT} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} sed -i -e "s/127.0.0.1/${TETHYS_DB_HOST}/g" /usr/lib/tethys/src/tethys_portal/settings.py # Setup local database echo "Setting up the Tethys database..." - # initdb -U postgres -D "${TETHYS_HOME}/psql/data" pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" + # initdb -U postgres -D "${TETHYS_HOME}/psql/data" + # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" echo "Waiting for databases to startup..."; sleep 30 - echo if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then - if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then + echo if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then + if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 fi + # Initialze Tethys database cd /usr/lib/tethys/src tethys manage syncdb - echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell + if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then + echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell + fi # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" stop . deactivate + + # Create environment activate/deactivate scripts mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" + echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" @@ -197,6 +259,7 @@ DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" if [ -z "${SKIP_TETHY echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" + echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" @@ -208,19 +271,27 @@ DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" if [ -z "${SKIP_TETHY echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" + echo "# Tethys Platform" >> ~/${BASH_PROFILE} - echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} fi + echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} +fi + # Install Docker (if flag is set) -set +e # don't exit on error anymore +set +e # don't exit on error anymore + # Rename some variables for reference after deactivating tethys environment. -TETHYS_CONDA_HOME=${CONDA_HOME} TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} +TETHYS_CONDA_HOME=${CONDA_HOME} +TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} + # Install Production configuration if flag is set + ubuntu_debian_production_install() { sudo apt update sudo apt install -y nginx sudo rm /etc/nginx/sites-enabled/default NGINX_SITES_DIR='sites-enabled' } + enterprise_linux_production_install() { sudo yum install nginx -y sudo systemctl enable nginx @@ -228,13 +299,16 @@ enterprise_linux_production_install() { sudo firewall-cmd --permanent --zone=public --add-service=http # sudo firewall-cmd --permanent --zone=public --add-service=https sudo firewall-cmd --reload + NGINX_SITES_DIR='conf.d' } + redhat_production_install() { VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") sudo bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/rhel/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" enterprise_linux_production_install } + centos_production_install() { PLATFORM=${LINUX_DISTRIBUTION} VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") @@ -242,6 +316,7 @@ centos_production_install() { sudo yum install epel-release -y enterprise_linux_production_install } + configure_selinux() { sudo yum install setroubleshoot -y sudo semanage fcontext -a -t httpd_config_t ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf @@ -250,16 +325,19 @@ configure_selinux() { sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}/static(/.*)?" sudo semanage fcontext -a -t httpd_sys_rw_content_t "${TETHYS_HOME}/workspaces(/.*)?" sudo restorecon -R -v ${TETHYS_HOME} > /dev/null - echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > -${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te + echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te + checkmodule -M -m -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te semodule_package -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp -m ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod sudo semodule -i ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp } -if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] then + +if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] +then # prompt for sudo echo "Production installation requires some commands to be run with sudo. Please enter password:" sudo echo "Installing Tethys Production Server..." + case ${LINUX_DISTRIBUTION} in debian) ubuntu_debian_production_install @@ -280,6 +358,8 @@ if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] then echo "Automated production installation on ${LINUX_DISTRIBUTION} is not supported." ;; esac + + . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" echo "Waiting for databases to startup..."; sleep 5 @@ -298,16 +378,19 @@ if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] then sudo mkdir /var/log/uwsgi sudo touch /var/log/uwsgi/tethys.log sudo ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ + if [ -n "${SELINUX}" ] then configure_selinux fi + sudo chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log sudo systemctl enable ${TETHYS_HOME}/src/tethys_portal/tethys.uwsgi.service sudo systemctl start tethys.uwsgi.service sudo systemctl restart nginx set +x . deactivate + echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" @@ -316,6 +399,7 @@ if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] then echo "alias tso=tethys_server_own" >> "${ACTIVATE_SCRIPT}" echo "alias tethys_server_restart='tso; sudo systemctl restart tethys.uwsgi.service; sudo systemctl restart nginx'" >> "${ACTIVATE_SCRIPT}" echo "alias tsr=tethys_server_restart" >> "${ACTIVATE_SCRIPT}" + echo "unset NGINX_USER" >> "${DEACTIVATE_SCRIPT}" echo "unset NGINX_HOME" >> "${DEACTIVATE_SCRIPT}" echo "unalias tethys_user_own" >> "${DEACTIVATE_SCRIPT}" @@ -323,11 +407,16 @@ if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] then echo "unalias tethys_server_own" >> "${DEACTIVATE_SCRIPT}" echo "unalias tso" >> "${DEACTIVATE_SCRIPT}" echo "unalias tethys_server_restart" >> "${DEACTIVATE_SCRIPT}" - echo "unalias trs" >> "${DEACTIVATE_SCRIPT}" fi + echo "unalias trs" >> "${DEACTIVATE_SCRIPT}" +fi + + # Install Docker (if flag is set + installation_warning(){ echo "WARNING: installing docker on $1 is not officially supported by the Tethys install script. Attempting to install with $2 script." } + finalize_docker_install(){ sudo groupadd docker sudo gpasswd -a ${USER} docker @@ -338,49 +427,61 @@ finalize_docker_install(){ echo "You must re-login for Docker permissions to be activated." echo "(Alternatively you can run 'newgrp docker')" } + ubuntu_debian_docker_install(){ if [ "${LINUX_DISTRIBUTION}" != "ubuntu" ] && [ ${LINUX_DISTRIBUTION} != "debian" ] then installation_warning ${LINUX_DISTRIBUTION} "Ubuntu" fi + sudo apt-get update sudo apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common curl -fsSL https://download.docker.com/linux/${LINUX_DISTRIBUTION}/gpg | sudo apt-key add - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/${LINUX_DISTRIBUTION} $(lsb_release -cs) stable" sudo apt-get update sudo apt-get install -y docker-ce + finalize_docker_install } + centos_docker_install(){ if [ "${LINUX_DISTRIBUTION}" != "centos" ] then installation_warning ${LINUX_DISTRIBUTION} "CentOS" fi + sudo yum -y install yum-utils sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo sudo yum makecache fast sudo yum -y install docker-ce sudo systemctl start docker sudo systemctl enable docker + finalize_docker_install } + fedora_docker_install(){ if [ "${LINUX_DISTRIBUTION}" != "fedora" ] then installation_warning ${LINUX_DISTRIBUTION} "Fedora" fi + sudo dnf -y install -y dnf-plugins-core sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo sudo dnf makecache fast sudo dnf -y install docker-ce sudo systemctl start docker sudo systemctl enable docker + finalize_docker_install } -if [ -n "${LINUX_DISTRIBUTION}" -a "${INSTALL_DOCKER}" = "true" ] then + +if [ -n "${LINUX_DISTRIBUTION}" -a "${INSTALL_DOCKER}" = "true" ] +then # prompt for sudo echo "Docker installation requires some commands to be run with sudo. Please enter password:" sudo echo "Installing Docker..." + case ${LINUX_DISTRIBUTION} in debian) ubuntu_debian_docker_install @@ -400,11 +501,20 @@ if [ -n "${LINUX_DISTRIBUTION}" -a "${INSTALL_DOCKER}" = "true" ] then *) echo "Automated Docker installation on ${LINUX_DISTRIBUTION} is not supported. Please see https://docs.docker.com/engine/installation/ for more information on installing Docker." ;; - esac fi if [ -z "${SKIP_TETHYS_INSTALL}" ] then + esac +fi + + +if [ -z "${SKIP_TETHYS_INSTALL}" ] +then echo "Tethys installation complete!" echo - echo "NOTE: to enable the new alias 't' which activates the tethys environment you must run '. ~/${BASH_PROFILE}'" fi on_exit(){ + echo "NOTE: to enable the new alias 't' which activates the tethys environment you must run '. ~/${BASH_PROFILE}'" +fi + +on_exit(){ set +e set +x } trap on_exit EXIT + From 56be64db5409ad1cc2042a72335029d9a812cb67 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 18 Sep 2017 14:46:36 -0600 Subject: [PATCH 019/215] Another pass... --- docker/setup_tethys.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index be9554d7b..ce77f860e 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -229,7 +229,7 @@ then # initdb -U postgres -D "${TETHYS_HOME}/psql/data" # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" echo "Waiting for databases to startup..."; sleep 30 - echo if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then + echo "if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then " if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 From 067686dddfd5d02c9ae7b54b26577778ac4653fd Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 18 Sep 2017 15:14:45 -0600 Subject: [PATCH 020/215] Move adds in docker to help cache. --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 484449aaa..6001f0a7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,6 @@ WORKDIR /usr/lib/tethys # Add files to docker image ADD docker/install_tethys.sh /usr/lib/tethys/install_tethys.sh -ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh ADD . /usr/lib/tethys/src # Arguments @@ -26,6 +25,8 @@ RUN pwd \ --conda-home $TETHYSBUILD_CONDA_HOME \ --conda-env-name $TETHYSBUILD_CONDA_ENV_NAME \ +ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh + # Make port 8000 available to the outside world EXPOSE 8000 From d20b844e057d1f0028ea387723aefc2741cd8062 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 18 Sep 2017 15:44:01 -0600 Subject: [PATCH 021/215] another pass --- docker/setup_tethys.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index ce77f860e..d46d63f0c 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -230,7 +230,7 @@ then # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" echo "Waiting for databases to startup..."; sleep 30 echo "if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then " - if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then + if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1 && echo $?) -ne 0 ]]; then psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 fi From 1a47e9b20e61531afe5a2fb619c4717a3195db6e Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Tue, 19 Sep 2017 13:52:03 -0600 Subject: [PATCH 022/215] Update base Docker --- Dockerfile | 5 ++++- docker/install_tethys.sh | 2 +- docker/setup_tethys.sh | 12 +++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6001f0a7d..677beb612 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,10 +23,12 @@ RUN pwd \ --python-version $TETHYSBUILD_PY_VERSION \ --tethys-home $TETHYSBUILD_TETHYS_HOME \ --conda-home $TETHYSBUILD_CONDA_HOME \ - --conda-env-name $TETHYSBUILD_CONDA_ENV_NAME \ + --conda-env-name $TETHYSBUILD_CONDA_ENV_NAME + ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh + # Make port 8000 available to the outside world EXPOSE 8000 @@ -43,6 +45,7 @@ CMD echo Stating Tethys Setup \ --db-password ${TETHYSBUILD_DB_PASSWORD:-pass} \ --db-host ${TETHYSBUILD_DB_HOST:-127.0.0.1} \ --db-port ${TETHYSBUILD_DB_PORT:-5432} \ + --db-create ${TETHYSBUILD_DB_CREATE:-0} \ --superuser ${TETHYSBUILD_SUPERUSER:-tethys_super} \ --superuser-pass ${TETHYSBUILD_SUPERUSER_PASS:-admin} \ --tethys-home ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys} \ diff --git a/docker/install_tethys.sh b/docker/install_tethys.sh index e8b271c2f..0ca8a3f66 100644 --- a/docker/install_tethys.sh +++ b/docker/install_tethys.sh @@ -244,7 +244,7 @@ then conda install --yes git # git clone ${TETHYS_REPO} "${TETHYS_HOME}/src" cd "${TETHYS_HOME}/src" - git checkout ${BRANCH} + # git checkout ${BRANCH} # create conda env and install Tethys echo "Setting up the ${CONDA_ENV_NAME} environment..." diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index d46d63f0c..415baa6e2 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -67,6 +67,7 @@ TETHYS_DB_USERNAME='tethys_default' TETHYS_DB_PASSWORD='pass' TETHYS_DB_HOST='127.0.0.1' TETHYS_DB_PORT=5436 +TETHYS_DB_CREATE=0 CONDA_ENV_NAME='tethys' PYTHON_VERSION='2' BRANCH='release' @@ -137,6 +138,10 @@ case $key in set_option_value TETHYS_DB_HOST "$2" shift # past argument ;; + --db-create) + set_option_value TETHYS_DB_CREATE "$2" + shift # past argument + ;; -S|--superuser) set_option_value TETHYS_SUPER_USER "$2" shift # past argument @@ -229,8 +234,9 @@ then # initdb -U postgres -D "${TETHYS_HOME}/psql/data" # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" echo "Waiting for databases to startup..."; sleep 30 - echo "if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then " - if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1 && echo $?) -ne 0 ]]; then + # if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1 && echo $?) -ne 0 ]]; then + if [[ "${TETHYS_DB_CREATE}" -ne '0' ]]; then + echo "Creating DB User and Password" psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 fi @@ -238,7 +244,7 @@ then # Initialze Tethys database cd /usr/lib/tethys/src tethys manage syncdb - if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1) -ne 0 ]]; then + if [[ "${TETHYS_DB_CREATE}" -ne '0' ]]; then echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell fi # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" stop From 8b28cf51a47051a8cafac3c0e27edb5628b9ebb0 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Tue, 19 Sep 2017 13:53:56 -0600 Subject: [PATCH 023/215] Update readme for create stuff --- docker/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/README.md b/docker/README.md index aec27ee34..2f08cd06f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -52,6 +52,7 @@ args in the build arg table can be used here as well) |TETHYSBUILD_DB_PASSWORD | Password for Database | pass | |TETHYSBUILD_DB_HOST | IPAddress for Database host| 127.0.0.1 | |TETHYSBUILD_DB_PORT | Port on Database host | 5432 | +|TETHYSBUILD_DB_CREATE | Create Database Users (0/1)| 0 | |TETHYSBUILD_SUPERUSER | Tethys Superuser | tethys_super | |TETHYSBUILD_SUPERUSER_PASS| Tethys Superuser Password | admin | @@ -62,4 +63,4 @@ Example of Command: -e TETHYSBUILD_DB_USERNAME='tethys_super' -e TETHYSBUILD_DB_PASSWORD='3x@m9139@$$' \ -e TETHYSBUILD_DB_PORT='5432' TETHYSBUILD_SUPERUSER='admin' \ -e TETHYSBUILD_SUPERUSER_PASS='admin' tethyscore -``` \ No newline at end of file +``` From 55f8e032129f469e143a1eec6bcbb6406a8d28e7 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 19 Sep 2017 16:12:33 -0600 Subject: [PATCH 024/215] fully implemented - needs review/approval --- tethys_apps/cli/__init__.py | 104 ++++++++++++ tethys_apps/cli/app_settings_commands.py | 75 +++++++++ tethys_apps/cli/cli_colors.py | 101 +++++++++++ tethys_apps/cli/cli_helpers.py | 45 +++++ tethys_apps/cli/link_commands.py | 114 +++++++++++++ tethys_apps/cli/services_commands.py | 206 +++++++++++++++++++++++ 6 files changed, 645 insertions(+) create mode 100644 tethys_apps/cli/app_settings_commands.py create mode 100644 tethys_apps/cli/cli_colors.py create mode 100644 tethys_apps/cli/cli_helpers.py create mode 100644 tethys_apps/cli/link_commands.py create mode 100644 tethys_apps/cli/services_commands.py diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index ae5a9bbe6..fec4d3223 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -23,6 +23,12 @@ MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_COLLECT, MANAGE_CREATESUPERUSER, TETHYS_SRC_DIRECTORY) +from .services_commands import (SERVICES_CREATE, SERVICES_CREATE_PERSISTENT, SERVICES_CREATE_SPATIAL, SERVICES_LINK, + services_create_persistent_command, services_create_spatial_command, + services_list_command, services_remove_persistent_command, + services_remove_spatial_command) +from .link_commands import link_command +from .app_settings_commands import app_settings_list_command from .gen_commands import VALID_GEN_OBJECTS, generate_command from tethys_apps.helpers import get_installed_tethys_apps @@ -229,6 +235,104 @@ def tethys_command(): manage_parser.add_argument('--noinput', action='store_true', help='Pass the --noinput argument to the manage.py command.') manage_parser.set_defaults(func=manage_command) + # Setup services command + services_parser = subparsers.add_parser('services', help='Services commands for Tethys Platform.') + services_subparsers = services_parser.add_subparsers(title='Commands') + + # SERVICES REMOVE COMMANDS + services_remove_parser = services_subparsers.add_parser('remove', help='Remove a Tethys Service.') + services_remove_subparsers = services_remove_parser.add_subparsers(title='Service Type') + + # REMOVE PERSISTENT SERVICE COMMAND + services_remove_persistent = services_remove_subparsers.add_parser('persistent', + help='Remove a Persistent Store Service.') + services_remove_persistent.add_argument('service_id', help='The ID of the service that you are removing.') + services_remove_persistent.add_argument('-a', '--authenticate', required=False, type=str, + help='The superuser credentials needed to perform this command in the form ' + '"" or ":". ' + 'You will be prompted for unprovided parts.') + services_remove_persistent.set_defaults(func=services_remove_persistent_command) + + # REMOVE SPATIAL SERVICE COMMAND + services_remove_spatial = services_remove_subparsers.add_parser('spatial', + help='Remove a Spatial Dataset Service.') + services_remove_spatial.add_argument('service_id', help='The ID of the service that you are removing.') + services_remove_spatial.add_argument('-a', '--authenticate', required=False, type=str, + help='The superuser credentials needed to perform this command in the form ' + '"" or ":". ' + 'You will be prompted for unprovided parts.') + + services_remove_spatial.set_defaults(func=services_remove_spatial_command) + + # SERVICES CREATE COMMANDS + services_create_parser = services_subparsers.add_parser('create', help='Create a Tethys Service.') + services_create_subparsers = services_create_parser.add_subparsers(title='Service Type') + + # CREATE PERSISTENT STORE SERVICE COMMAND + services_create_ps = services_create_subparsers.add_parser('persistent', + help='Create a Persistent Store Service.') + services_create_ps.add_argument('-n', '--name', required=True, help='The name of the Service', type=str) + services_create_ps.add_argument('-c', '--connection', required=True, type=str, + help='The connection of the Service in the form ' + '":@:"') + services_create_ps.add_argument('-a', '--authenticate', required=False, type=str, + help='The superuser credentials needed to perform this command in the form ' + '"" or ":". ' + 'You will be prompted for unprovided parts.') + services_create_ps.set_defaults(func=services_create_persistent_command) + + # CREATE SPATIAL DATASET SERVICE COMMAND + services_create_sd = services_create_subparsers.add_parser('spatial', + help='Create a Spatial Dataset Service.') + services_create_sd.add_argument('-n', '--name', required=True, help='The name of the Service', type=str) + services_create_sd.add_argument('-c', '--connection', required=True, type=str, + help='The connection of the Service in the form ' + '":@//:"') + services_create_sd.add_argument('-p', '--public-endpoint', required=False, type=str, + help='The public-facing endpoint, if different than what was provided with the ' + '--connection argument, of the form ":"') + services_create_sd.add_argument('-k', '--apikey', required=False, type=str, + help='The API key, if any, required to establish a connection.') + services_create_sd.add_argument('-a', '--authenticate', required=False, type=str, + help='The superuser credentials needed to perform this command in the form ' + '"" or ":". ' + 'You will be prompted for unprovided parts.') + services_create_sd.set_defaults(func=services_create_spatial_command) + + # LIST SERVICES COMMAND + services_list_parser = services_subparsers.add_parser('list', help='List all existing Tethys Services.') + group = services_list_parser.add_mutually_exclusive_group() + group.add_argument('-p', '--persistent', action='store_true', help='Only list Persistent Store Services.') + group.add_argument('-s', '--spatial', action='store_true', help='Only list Spatial Dataset Services.') + services_list_parser.add_argument('-a', '--authenticate', required=False, type=str, + help='The superuser credentials needed to perform this command in the form ' + '"" or ":". ' + 'You will be prompted for unprovided parts.') + services_list_parser.set_defaults(func=services_list_command) + + # Setup app_settings command + app_settings_parser = subparsers.add_parser('app_settings', help='Interact with Tethys App Settings.') + app_settings_subparsers = app_settings_parser.add_subparsers(title='Options') + app_settings_list_parser = app_settings_subparsers.add_parser('list', help='List all settings for a specified app') + app_settings_list_parser.add_argument('app', help='The app ("") to list the Settings for.') + app_settings_list_parser.add_argument('-a', '--authenticate', required=False, type=str, + help='The superuser credentials needed to perform this command in the form ' + '"" or ":". ' + 'You will be prompted for unprovided parts.') + app_settings_list_parser.set_defaults(func=app_settings_list_command) + + # Setup link command + + # LINK SERVICE WITH APP COMMAND + link_parser = subparsers.add_parser('link', help='Link a Service to a Tethys app Setting.') + link_parser.add_argument('service', help='Service to link to a target app. Of the form ' + '":" ' + '(i.e. "persistent_connection:super_conn")') + link_parser.add_argument('setting', help='Setting of an app with which to link the specified service.' + 'Of the form ":' + '" (i.e. "epanet:database:epanet_2")') + link_parser.set_defaults(func=link_command) + # Setup test command test_parser = subparsers.add_parser('test', help='Testing commands for Tethys Platform.') test_parser.add_argument('-c', '--coverage', help='Run coverage with tests and output report to console.', diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py new file mode 100644 index 000000000..d00117473 --- /dev/null +++ b/tethys_apps/cli/app_settings_commands.py @@ -0,0 +1,75 @@ +from cli_helpers import console_superuser_required +from django.core.exceptions import ObjectDoesNotExist +from tethys_apps.models import (TethysApp, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, + SpatialDatasetServiceSetting) + +from .cli_colors import * + +setting_type_dict = { + PersistentStoreConnectionSetting: 'ps_connection', + PersistentStoreDatabaseSetting: 'ps_database', + SpatialDatasetServiceSetting: 'ds_spatial' +} + + +@console_superuser_required +def app_settings_list_command(args): + app_package = args.app + try: + app = TethysApp.objects.get(package=app_package) + + app_settings = [] + for setting in PersistentStoreConnectionSetting.objects.filter(tethys_app=app): + app_settings.append(setting) + for setting in PersistentStoreDatabaseSetting.objects.filter(tethys_app=app): + app_settings.append(setting) + for setting in SpatialDatasetServiceSetting.objects.filter(tethys_app=app): + app_settings.append(setting) + + unlinked_settings = [] + linked_settings = [] + + for setting in app_settings: + if hasattr(setting, 'spatial_dataset_service') and setting.dataset_service \ + or hasattr(setting, 'persistent_store_service') and setting.persistent_store_service: + linked_settings.append(setting) + else: + unlinked_settings.append(setting) + + with pretty_output(BOLD) as p: + p.write("\nUnlinked Settings:") + + if len(unlinked_settings) == 0: + print 'None' + else: + is_first_row = True + for setting in unlinked_settings: + if is_first_row: + with pretty_output(BOLD) as p: + p.write('{0: <10}{1: <40}{2: <15}'.format('ID', 'Name', 'Type')) + is_first_row = False + print '{0: <10}{1: <40}{2: <15}'.format(setting.pk, setting.name, setting_type_dict[type(setting)]) + + with pretty_output(BOLD) as p: + p.write("\nLinked Settings:") + + if len(linked_settings) == 0: + print 'None' + else: + is_first_row = True + for setting in linked_settings: + if is_first_row: + with pretty_output(BOLD) as p: + p.write('{0: <10}{1: <40}{2: <15}{3: <20}'.format('ID', 'Name', 'Type', 'Linked With')) + is_first_row = False + service_name = setting.spatial_dataset_service.name if hasattr(setting, 'spatial_dataset_service') \ + else setting.persistent_store_service.name + print '{0: <10}{1: <40}{2: <15}{3: <20}'.format(setting.pk, setting.name, + setting_type_dict[type(setting)], service_name) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('The app you specified ("{0}") does not exist. Command aborted.'.format(app_package)) + except Exception as e: + print e + with pretty_output(FG_RED) as p: + p.write('Something went wrong. Please try again.') diff --git a/tethys_apps/cli/cli_colors.py b/tethys_apps/cli/cli_colors.py new file mode 100644 index 000000000..0ee82bcdb --- /dev/null +++ b/tethys_apps/cli/cli_colors.py @@ -0,0 +1,101 @@ +# encoding: utf-8 + +# Copyright 2013 Diego Navarro Mellén. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, are +# permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this list of +# conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, this list +# of conditions and the following disclaimer in the documentation and/or other materials +# provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY DIEGO NAVARRO MELLÉN ''AS IS'' AND ANY EXPRESS OR IMPLIED +# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +# FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL DIEGO NAVARRO MELLÉN OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# The views and conclusions contained in the software and documentation are those of the +# authors and should not be interpreted as representing official policies, either expressed +# or implied, of Diego Navarro Mellén. + +from __future__ import print_function + + +# Special END separator +END = '0e8ed89a-47ba-4cdb-938e-b8af8e084d5c' + +# Text attributes +ALL_OFF = '\033[0m' +BOLD = '\033[1m' +UNDERSCORE = '\033[4m' +BLINK = '\033[5m' +REVERSE = '\033[7m' +CONCEALED = '\033[7m' + +# Foreground colors +FG_BLACK = '\033[30m' +FG_RED = '\033[31m' +FG_GREEN = '\033[32m' +FG_YELLOW = '\033[33m' +FG_BLUE = '\033[34m' +FG_MAGENTA = '\033[35m' +FG_CYAN = '\033[36m' +FG_WHITE = '\033[37m' + +# Background colors +BG_BLACK = '\033[40m' +BG_RED = '\033[41m' +BG_GREEN = '\033[42m' +BG_YELLOW = '\033[43m' +BG_BLUE = '\033[44m' +BG_MAGENTA = '\033[45m' +BG_CYAN = '\033[46m' +BG_WHITE = '\033[47m' + + +class pretty_output: + ''' + Context manager for pretty terminal prints + ''' + + def __init__(self, *attr): + self.attributes = attr + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + pass + + def write(self, msg): + style = ''.join(self.attributes) + print('{}{}{}'.format(style, msg.replace(END, ALL_OFF + style), ALL_OFF)) + + +if __name__ == '__main__': + + with pretty_output(FG_RED) as out: + out.write('This is a test in RED') + + with pretty_output(FG_BLUE) as out: + out.write('This is a test in BLUE') + + with pretty_output(BOLD, FG_GREEN) as out: + out.write('This is a bold text in green') + + with pretty_output(BOLD, BG_GREEN) as out: + out.write('This is a text with green background') + + with pretty_output(FG_GREEN) as out: + out.write('This is a green text with ' + BOLD + 'bold' + END + ' text included') + + with pretty_output() as out: + out.write(BOLD + 'Use this' + END + ' even with ' + BOLD + FG_RED + 'no parameters' + END + ' in the with statement') \ No newline at end of file diff --git a/tethys_apps/cli/cli_helpers.py b/tethys_apps/cli/cli_helpers.py new file mode 100644 index 000000000..d91eed05e --- /dev/null +++ b/tethys_apps/cli/cli_helpers.py @@ -0,0 +1,45 @@ +from getpass import getpass +from django.contrib.auth import authenticate +from .cli_colors import * + + +def console_superuser_required(func): + def _wrapped(args): + credentials = args.authenticate + username = None + password = None + + if credentials: + cred_parts = credentials.split(':') + if len(cred_parts) > 0: + username = cred_parts[0] + if len(cred_parts) > 1: + password = cred_parts[1] + if not username: + username = raw_input('username: ') + if not password: + password = getpass('password: ') + user = authenticate(username=username, password=password) + if not user: + with pretty_output(FG_RED) as p: + p.write('The username or password provided was incorrect. Command aborted.') + exit(1) + if not user.is_superuser: + with pretty_output(FG_RED) as p: + p.write('You are not authorized to perform this action.') + exit(1) + + return func(args) + + return _wrapped + + +def add_geoserver_rest_to_endpoint(endpoint): + parts = endpoint.split('//') + protocol = parts[0] + parts2 = parts[1].split(':') + host = parts2[0] + port_and_path = parts2[1] + port = port_and_path.split('/')[0] + + return '{0}//{1}:{2}/geoserver/rest/'.format(protocol, host, port) \ No newline at end of file diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py new file mode 100644 index 000000000..239013b3c --- /dev/null +++ b/tethys_apps/cli/link_commands.py @@ -0,0 +1,114 @@ +from django.core.exceptions import ObjectDoesNotExist +from tethys_apps.models import TethysApp +from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, + PersistentStoreDatabaseSetting) +from tethys_services.models import (SpatialDatasetService, PersistentStoreService) + +from .cli_colors import * +from .cli_helpers import console_superuser_required + +service_type_to_model_dict = { + 'spatial': SpatialDatasetService, + 'persistent': PersistentStoreService +} + +setting_type_to_link_model_dict = { + 'ps_database': { + 'setting_model': PersistentStoreDatabaseSetting, + 'service_field': 'persistent_store_service' + }, + 'ps_connection': { + 'setting_model': PersistentStoreConnectionSetting, + 'service_field': 'persistent_store_service' + }, + 'ds_spatial': { + 'setting_model': SpatialDatasetServiceSetting, + 'service_field': 'spatial_dataset_service' + } +} + + +@console_superuser_required +def link_command(args): + """ + Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps + """ + try: + service = args.service + setting = args.setting + + service_parts = service.split(':') + setting_parts = setting.split(':') + service_type = None + service_uid = None + setting_app_package = None + setting_type = None + setting_uid = None + + try: + service_type = service_parts[0] + service_uid = service_parts[1] + + setting_app_package = setting_parts[0] + setting_type = setting_parts[1] + setting_uid = setting_parts[2] + except IndexError: + with pretty_output(FG_RED) as p: + p.write( + 'Incorrect argument format. \nUsage: "tethys link : ' + ':"' + '\nCommand aborted.') + exit(1) + + service_model = service_type_to_model_dict[service_type] + + try: + try: + service_uid = int(service_uid) + service = service_model.objects.get(pk=service_uid) + except ValueError: + service = service_model.objects.get(name=service_uid) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(service_model), service_uid)) + exit(1) + + app = None + try: + app = TethysApp.objects.get(package=setting_app_package) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('The app you specified ("{0}") does not exist.'.format(setting_app_package)) + exit(1) + + linked_setting_model_dict = None + try: + linked_setting_model_dict = setting_type_to_link_model_dict[setting_type] + except KeyError: + with pretty_output(FG_RED) as p: + p.write('The setting_type you specified ("{0}") does not exist.' + '\nChoose from: "ps_database|ps_connection|ds_spatial"'.format(setting_type)) + exit(1) + + linked_setting_model = linked_setting_model_dict['setting_model'] + linked_service_field = linked_setting_model_dict['service_field'] + + try: + try: + setting_uid = int(setting_uid) + setting = linked_setting_model.objects.get(tethys_app=app, pk=setting_uid) + except ValueError: + setting = linked_setting_model.objects.get(tethys_app=app, name=setting_uid) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(linked_setting_model), setting_uid)) + exit(1) + + setattr(setting, linked_service_field, service) + + setting.save() + + except Exception as e: + print e + with pretty_output(FG_RED) as p: + p.write('An unexpected error occurred. Please try again.') diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py new file mode 100644 index 000000000..81bc42a2f --- /dev/null +++ b/tethys_apps/cli/services_commands.py @@ -0,0 +1,206 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.db.utils import IntegrityError +from django.forms.models import model_to_dict +from tethys_services.models import SpatialDatasetService, PersistentStoreService + +from .cli_colors import * +from .cli_helpers import console_superuser_required, add_geoserver_rest_to_endpoint + +SERVICES_CREATE = 'create' +SERVICES_CREATE_PERSISTENT = 'persistent' +SERVICES_CREATE_SPATIAL = 'spatial' +SERVICES_LINK = 'link' +SERVICES_LIST = 'list' + + +class FormatError(Exception): + def __init__(self): + Exception.__init__(self) + + +@console_superuser_required +def services_create_persistent_command(args): + """ + Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps + """ + name = None + + try: + name = args.name + connection = args.connection + parts = connection.split('@') + cred_parts = parts[0].split(':') + store_username = cred_parts[0] + store_password = cred_parts[1] + url_parts = parts[1].split(':') + host = url_parts[0] + port = url_parts[1] + + new_persistent_service = PersistentStoreService(name=name, host=host, port=port, + username=store_username, password=store_password) + new_persistent_service.save() + + with pretty_output(FG_GREEN) as p: + p.write('Successfully created new Persistent Store Service!') + except IndexError: + with pretty_output(FG_RED) as p: + p.write('The connection argument (-c) must be of the form ":@:".') + except IntegrityError: + with pretty_output(FG_RED) as p: + p.write('Persistent Store Service with name "{0}" already exists. Command aborted.'.format(name)) + + +@console_superuser_required +def services_remove_persistent_command(args): + persistent_service_id = None + + try: + persistent_service_id = args.service_id + + try: + persistent_service_id = int(persistent_service_id) + service = PersistentStoreService.objects.get(pk=persistent_service_id) + except ValueError: + service = PersistentStoreService.objects.get(name=persistent_service_id) + + proceed = raw_input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + service.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed Persistent Store Service {0}!'.format(persistent_service_id)) + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. Persistent Store Service not removed.') + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(persistent_service_id)) + + +@console_superuser_required +def services_create_spatial_command(args): + """ + Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps + """ + name = None + + try: + name = args.name + connection = args.connection + parts = connection.split('@') + cred_parts = parts[0].split(':') + service_username = cred_parts[0] + service_password = cred_parts[1] + endpoint = parts[1] + public_endpoint = args.public_endpoint or '' + apikey = args.apikey or '' + + if 'http' not in endpoint or '://' not in endpoint: + raise IndexError() + if public_endpoint and 'http' not in public_endpoint or '://' not in public_endpoint: + raise FormatError() + + endpoint = add_geoserver_rest_to_endpoint(endpoint) + if public_endpoint: + public_endpoint = add_geoserver_rest_to_endpoint(public_endpoint) + + new_persistent_service = SpatialDatasetService(name=name, endpoint=endpoint, public_endpoint=public_endpoint, + apikey=apikey, username=service_username, + password=service_password) + new_persistent_service.save() + + with pretty_output(FG_GREEN) as p: + p.write('Successfully created new Persistent Store Service!') + except IndexError: + with pretty_output(FG_RED) as p: + p.write('The connection argument (-c) must be of the form ' + '":@//:".') + except FormatError: + with pretty_output(FG_RED) as p: + p.write('The public_endpoint argument (-p) must be of the form ' + '"//:".') + except IntegrityError: + with pretty_output(FG_RED) as p: + p.write('Spatial Dataset Service with name "{0}" already exists. Command aborted.'.format(name)) + + +@console_superuser_required +def services_remove_spatial_command(args): + spatial_service_id = None + + try: + spatial_service_id = args.service_id + + try: + spatial_service_id = int(spatial_service_id) + service = SpatialDatasetService.objects.get(pk=spatial_service_id) + except ValueError: + service = SpatialDatasetService.objects.get(name=spatial_service_id) + + proceed = raw_input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + service.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed Persistent Store Service {0}!'.format(spatial_service_id)) + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. Persistent Store Service not removed.') + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(spatial_service_id)) + + +@console_superuser_required +def services_list_command(args): + """ + Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps + """ + list_persistent = False + list_spatial = False + + if not args.spatial and not args.persistent: + list_persistent = True + list_spatial = True + elif args.spatial: + list_spatial = True + elif args.persistent: + list_persistent = True + + if list_persistent: + persistent_entries = PersistentStoreService.objects.order_by('id').all() + if len(persistent_entries) > 0: + with pretty_output(BOLD) as p: + p.write('\nPersistent Store Services:') + is_first_entry = True + for entry in persistent_entries: + model_dict = model_to_dict(entry) + if is_first_entry: + with pretty_output(BOLD) as p: + p.write('{0: <3}{1: <50}{2: <25}{3: <6}'.format('ID', 'Name', 'Host', 'Port')) + is_first_entry = False + print '{0: <3}{1: <50}{2: <25}{3: <6}'.format(model_dict['id'], model_dict['name'], + model_dict['host'], model_dict['port']) + + if list_spatial: + spatial_entries = SpatialDatasetService.objects.order_by('id').all() + if len(spatial_entries) > 0: + with pretty_output(BOLD) as p: + p.write('\nSpatial Dataset Services:') + is_first_entry = True + for entry in spatial_entries: + model_dict = model_to_dict(entry) + if is_first_entry: + with pretty_output(BOLD) as p: + p.write('{0: <3}{1: <50}{2: <50}{3: <50}{4: <30}'.format('ID', 'Name', 'Endpoint', + 'Public Endpoint', 'API Key')) + is_first_entry = False + print '{0: <3}{1: <50}{2: <50}{3: <50}{4: <30}'.format(model_dict['id'], model_dict['name'], + model_dict['endpoint'], + model_dict['public_endpoint'], + model_dict['apikey'] if model_dict['apikey'] + else "None") From effda29112a16a0afc6381781c87a78d4790abc1 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Tue, 19 Sep 2017 16:42:45 -0600 Subject: [PATCH 025/215] Add static stuff --- aquaveo_static/images/aquaveo_favicon.ico | Bin 0 -> 33786 bytes aquaveo_static/images/aquaveo_logo.png | Bin 0 -> 136078 bytes aquaveo_static/tethys_main.css | 2445 +++++++++++++++++++++ 3 files changed, 2445 insertions(+) create mode 100644 aquaveo_static/images/aquaveo_favicon.ico create mode 100644 aquaveo_static/images/aquaveo_logo.png create mode 100644 aquaveo_static/tethys_main.css diff --git a/aquaveo_static/images/aquaveo_favicon.ico b/aquaveo_static/images/aquaveo_favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..fcf1f37ed4aaa3fc1fb7e12f88b649ee5d7d3569 GIT binary patch literal 33786 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 L!-$8|;I#h-@wRQK literal 0 HcmV?d00001 diff --git a/aquaveo_static/images/aquaveo_logo.png b/aquaveo_static/images/aquaveo_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..08b7fa2d3f55aa4a89e486360bfa545015fd55bb GIT binary patch literal 136078 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 G=l=sen) .active > a, +.nav-list > .active > a:hover, +.nav-list > .active > a:focus { + background-color: #0A62A9; +} +@media (max-width: 1250px) { + .container { + width: 90%; + } + .container .wrapper { + background-image: none; + } + .container .wrapper .primary { + width: 70%; + border-left: #d8d8d8 1px solid; + margin-left: -1px; + } + .container .wrapper .secondary { + width: 30%; + } + .container .wrapper .secondary .module-heading { + border-right: #d8d8d8 1px solid; + } + .editor textarea { + margin-bottom: 0; + } + .select2-container { + width: 100% !important; + } + .control-full .select2-container { + width: 100% !important; + } + .control-medium input, + .control-medium select, + .control-medium textarea { + width: 95%; + } + .control-custom .control-label { + margin-bottom: 5px; + } + .control-custom .editor .input-prepend { + position: relative; + } + .control-custom .editor .input-prepend .add-on { + display: block; + float: left; + margin-bottom: 10px; + } + .control-custom .editor .input-prepend input { + display: block; + margin-bottom: 10px; + } + .control-custom .editor .input-prepend .icon-remove input[type=checkbox] { + visibility: hidden; + } +} +@media (max-width: 950px) { + .actions { + position: fixed; + top: 100%; + left: 0; + width: 100%; + margin-top: -60px; + height: 60px; + background-color: white; + background-color: rgba(255, 255, 255, 0.95); + -webkit-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); + -moz-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); + -ms-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); + -o-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); + box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); + z-index: 500; + padding: 10px; + } + .resource-item .btn-group { + position: static; + margin-top: 10px; + } + .module-resource .actions { + position: fixed; + top: 100%; + left: 0; + width: 100%; + margin-top: -60px; + height: 60px; + background-color: white; + background-color: rgba(255, 255, 255, 0.95); + -webkit-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); + -moz-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); + -ms-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); + -o-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); + box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); + z-index: 500; + padding: 10px; + } +} +@media (max-width: 880px) { + .control-order-by { + display: none; + } + .results strong:before { + display: none; + } + .form-actions { + position: relative; + padding-top: 75px; + margin-left: auto; + margin-right: auto; + } + .form-actions .action-info.small { + position: absolute; + width: 90%; + top: 12px; + } + .form-actions .btn { + margin-bottom: 5px; + } +} +@media (max-width: 700px) { + .container .wrapper .primary { + border-left: none; + width: 100%; + } + .container .wrapper .primary .context-info { + background: whitesmoke; + margin-bottom: -20px; + } + .container .wrapper .primary .context-info .arrow { + display: none; + } + .container .wrapper .primary .context-info h1 { + font-size: 30px; + margin-right: 80px; + } + .container .wrapper .primary .context-info h4 { + font-size: 30px; + margin-right: 80px; + } + .container .wrapper .primary .context-info p { + margin-right: 80px; + } + .container .wrapper .primary .context-info .nums { + position: absolute; + right: 0; + top: 0; + padding-right: 15px; + border: none; + margin-top: 0; + } + .container .wrapper .primary .context-info .nums dl { + float: none; + } + .container .wrapper .secondary { + display: none; + } +} +@media (max-width: 575px) { + .modal { + width: 90%; + left: 0; + margin-left: 5%; + margin-right: 5%; + } + .stages { + /*li:nth-of-type(1):before { + &:before { + content: "1"; + } + } + + li:nth-of-type(2){ + &:before { + content: "2"; + } + }*/ + + } + .stages li { + /*display: none;*/ + width: 100%; + /*&.active { + display:block; + }*/ + } + .stages li:after { + content: none; + } + .stages li:nth-of-type(3).active { + /*&:before { + content: "3"; + }*/ + left: -1px; + } +} +@media (max-width: 550px) { + .media-item { + width: 50%; + } + .media-item.first { + clear: none; + } + .media-item:nth-child(odd) { + clear: left; + } + .toolbar .breadcrumb { + font-size: 18px; + } + .nav-tabs > li { + font-size: 12px; + } + .dataset-form-resource-types .controls { + position: relative; + margin-bottom: 10px; + height: 180px; + } + .dataset-form-resource-types .controls i { + display: block; + float: left; + clear: left; + margin-top: 30px; + } + .dataset-form-resource-types .controls i:first-of-type { + margin-top: 0; + } + .dataset-form-resource-types .controls label.radio { + display: block; + float: left; + margin-top: 25px; + padding-left: 5px; + font-size: 18px; + line-height: 25px; + } + .dataset-form-resource-types .controls label.radio:first-of-type { + margin-top: -5px; + } + .dataset-form-resource-types .controls span { + display: block; + float: left; + clear: left; + margin-top: 30px; + } + .dataset-form-resource-types .controls input[type=radio]:checked + label:after { + background-position: -320px -35px; + top: 10px; + right: 0; + left: auto; + bottom: auto; + } +} +section.module { + padding: 0; +} +section.module h2 { + font-weight: bold; +} +li.dataset-item .dataset-content h3.dataset-heading a { + font-weight: bold; +} +li.dataset-item .dataset-content h3.dataset-heading a:hover { + color: #333; + text-decoration: underline; +} +li.dataset-item .dataset-content div { + font-weight: 400; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +li.dataset-item .dataset-content p { + font-weight: 400; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.toolbar { + margin-top: 5px; + margin-bottom: 15px; +} +.toolbar .add_action { + top: -2px; +} +.media-grid .media-item .media-content .media-heading a { + font-weight: 500; + color: #2ca6ed; +} +.media-grid .media-item .media-content .media-heading a:hover { + text-decoration: underline; + color: #0a84cb; +} +.media-grid .media-item .media-content p { + font-weight: 400; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.module-content.page-header { + height: 40px; +} +.module-content.page-header .nav.nav-tabs li a { + color: #0A62A9; +} +.module-content.page-header .nav.nav-tabs li.active a { + color: #555; +} +.resources h3 { + font-weight: normal; +} +.resources .resource-list .resource-item .dropdown { + top: 10px; +} +.tags .tag-list li a.tag:hover { + background-color: #0A62A9; +} +.additional-info h3 { + font-weight: normal; +} +.notes.embedded-content p { + font-weight: 400; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.module-content h3 { + font-weight: normal; +} +.module-content p a.resource-url-analytics { + color: #505050; +} +.nav-simple .nav-item.active a { + background-color: #0A62A9; +} +.nav-simple .nav-item.active a:before { + background-image: none; + height: 0; + width: 0; + border-top: 17px solid transparent; + border-bottom: 17px solid transparent; + border-left: 6px solid #0A62A9; +} +.nav-simple .nav-item a:hover { + color: #333; +} +.banner { + background-color: #0A62A9; +} +.popover-followee .popover-content .nav li a { + color: #0A62A9; +} +.popover-followee .popover-content .nav li a i { + color: white; + background-color: #0A62A9; + -webkit-font-smooting: antialiased; +} +.popover-followee .popover-content .nav li.active a { + color: white; + background-color: #0A62A9; +} +.popover-followee .popover-content .nav li.active a i { + background-color: white; + color: #0A62A9; + -webkit-font-smooting: antialiased; +} +/***************************************************************************** + * FILE: footer + * DATE: 2014 + * AUTHOR: Nathan Swain + * COPYRIGHT: (c) Brigham Young University 2014 + * LICENSE: BSD 2-Clause + *****************************************************************************/ +/* ==================================== + The footer at the bottom of the site + ==================================== */ +footer.site-footer { + background-color: #0A62A9; + position: relative; + z-index: 100; + box-shadow: 0px -1px 10px rgba(0, 0, 0, 0.25); + padding: 0 50px; + overflow: auto; +} +footer.site-footer .tethys-attribution { + font-size: 16px; + float: right; + margin-top: 37px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: #ffffff; +} +footer.site-footer .tethys-attribution a { + font-size: 20px; + color: #eeeeff; +} +footer.site-footer .tethys-attribution a img { + padding-left: 5px; +} +footer.site-footer .tethys-attribution a:hover { + color: #ffffff; +} +.footer-col { + float: left; + margin-top: 80px; + margin-bottom: 80px; + margin-right: 5%; + min-height: 50px; + width: 30%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-align: center; +} +.footer-col:nth-child(3) { + margin-right: 0; +} +.footer-col .centered { + width: 200px; +} +.footer-col ul { + list-style: none; + margin: 0; + padding: 0; +} +.footer-col ul li { + font-size: 18px; + font-weight: 300; + line-height: 2; + color: #efefef; + list-style: none; +} +.footer-col ul li a { + color: #CCDEE3; + text-decoration: none; +} +.footer-col ul li a:hover { + color: #ffffff; +} +.footer-col h5 { + line-height: 2; + margin-top: 8%; + color: #fff; + font-size: 18px; + font-weight: bold; + text-transform: none; + letter-spacing: 0; +} +.copyright-wrapper { + margin-top: 40px; + float: left; +} +.copyright { + display: block; + font-weight: 300; + font-size: 16px; + color: #efefef; + margin-left: 0; +} +.copyright a { + text-decoration: none; + color: #efefef; +} +.copyright a:hover { + color: #ffffff; +} +.copyright-bottom { + display: none; +} +.documents-wrapper { + margin-top: 40px; + margin-left: 10px; + float: left; + font-weight: 300; +} +.documents-wrapper .document-link { + margin: 0 5px; + display: inline-block; + font-size: 16px; + color: #eeeeff; +} +.documents-wrapper .document-link:hover { + color: #ffffff; +} +/*@media (max-width: 1180px) { + .footer-col > .png-cp-logo { + width: 200px; + margin-left: -11px; + } + + .footer-col { + float: none; + font-size: 16px; + width: 100%; + margin: 0; + text-align: center; + + &.one { + display: none; + } + + &.two, &.three { + margin-top: 40px; + } + + &.three { + width: 100%; + } + + &:last-child { + width: 100%; + margin-bottom: 40px; + } + + h5 { + margin-top: 20px; + } + } + + .copyright-bottom { + display: block; + position: relative; + margin-top: 40px; + + img { + width: 100px; + } + + .copyright { + margin-top: 40px; + font-size: 18px; + text-align: center; + } + } +}*/ +@media (max-width: 1100px) { + footer.site-footer .tethys-attribution { + float: none; + margin-top: 15px; + text-align: center; + } + .copyright-wrapper { + float: none; + margin-top: 22px; + text-align: center; + } + .documents-wrapper { + display: block; + float: none; + margin-top: 15px; + text-align: center; + } +} +/***************************************************************************** + * FILE: account + * DATE: 2014 + * AUTHOR: Nathan Swain + * COPYRIGHT: (c) Brigham Young University 2014 + * LICENSE: BSD 2-Clause + *****************************************************************************/ +.account-form-wrapper { + position: relative; + padding: 50px; + margin: 50px 0; + background: #ffffff; + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.25); + box-sizing: border-box; +} +.account-form-wrapper .account-form-header { + position: absolute; + left: 0; + top: 0; + width: 100%; + padding: 0 50px; + background: #eeeeee; +} +.account-form-wrapper .account-form-header h1 { + color: #555555; + font-weight: 400; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.account-form-wrapper.disconnect p { + padding: 20px 0; +} +.account-form-wrapper .account-form-body { + margin-top: 50px; +} +.account-form-wrapper .help-block { + margin-top: 10px; +} +.btn-hydroshare { + color: #555555; + background-color: #ffffff; + border-color: rgba(0, 0, 0, 0.2); +} +.btn-hydroshare:hover { + color: #ffffff; + background-color: #71a43a; + border-color: rgba(0, 0, 0, 0.2); +} +.fa-hydroshare { + background-image: url(/static/tethys_portal/images/hs-icon-sm.png); + background-repeat: no-repeat; + background-size: 75%; + background-position: 4px 5px; +} +.btn-google { + background-color: #ffffff; + color: #555555; +} +.fa-google { + color: #dd4b39; +} +.btn-google:hover { + background-color: #dd4b39; +} +.btn-google:hover .fa-google { + color: #ffffff; +} +.btn-google:focus { + background-color: #dd4b39; +} +.btn-google:focus .fa-google { + color: #ffffff; +} +.btn-linkedin { + background-color: #ffffff; + color: #555555; +} +.fa-linkedin { + color: #007bb6; +} +.btn-linkedin:hover { + background-color: #007bb6; +} +.btn-linkedin:hover .fa-linkedin { + color: #ffffff; +} +.btn-linkedin:focus { + background-color: #007bb6; +} +.btn-linkedin:focus .fa-linkedin { + color: #ffffff; +} +.btn-facebook { + background-color: #ffffff; + color: #555555; +} +.fa-facebook { + color: #3b5998; +} +.btn-facebook:hover { + background-color: #3b5998; +} +.btn-facebook:hover .fa-facebook { + color: #ffffff; +} +.btn-facebook:focus { + background-color: #3b5998; +} +.btn-facebook:focus .fa-facebook { + color: #ffffff; +} +.btn-social { + font-size: 15px; + font-weight: 500; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.btn-block + .btn-block { + margin-top: 10px; +} +.social-divide-or { + position: relative; + margin: 30px 0; + width: 100%; + text-align: center; +} +.social-divide-or .line { + width: 100%; + height: 1px; + background-color: #dddddd; +} +.social-divide-or .or { + position: absolute; + left: 50%; + top: -15px; + margin-left: -20px; + background-color: #ffffff; + padding: 0 10px; + font-size: 20px; + color: #555555; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +@media (max-width: 992px) { + .account-form-wrapper { + margin: 0; + } +} +/***************************************************************************** + * FILE: user + * DATE: 2014 + * AUTHOR: Nathan Swain + * COPYRIGHT: (c) Brigham Young University 2014 + * LICENSE: BSD 2-Clause + *****************************************************************************/ +.profile-header { + position: relative; + height: 250px; + background: #1b95dc; +} +.profile-header .profile-image-wrapper { + position: absolute; + top: 50px; +} +.profile-header .profile-image-wrapper img { + border-radius: 50%; +} +.profile-header .profile-name { + display: block; + color: white; + font-size: 60px; + font-weight: 300; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + padding-top: 130px; + padding-left: 30px; +} +.profile-section { + padding: 25px 0; + color: #555555; + width: 80%; + margin: 0 auto; + border-bottom: 1px solid #555555; +} +.profile-section.first { + margin-top: 75px; +} +.profile-section.last { + border-bottom: none; +} +.profile-section .profile-section-header h3 { + margin: 0; + font-weight: 500; +} +.profile-section .profile-parameters { + margin-top: 8px; +} +.profile-section .profile-parameters ul { + padding: 0; + margin: 0; +} +.profile-section .profile-parameters ul li { + display: block; + height: 28px; + margin-bottom: 10px; + list-style: none; + font-size: 18px; + font-weight: 300; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.profile-section .profile-parameters ul li .parameter { + float: left; + width: 30%; + text-align: right; + padding-right: 20px; + font-weight: 400; +} +.profile-section .profile-parameters ul li .value { + float: left; +} +.profile-section .profile-parameters ul li .value.button { + -webkit-font-smoothing: subpixel-antialiased; + -moz-font-smoothing: subpixel-antialiased; + -ms-font-smoothing: subpixel-antialiased; + -o-font-smoothing: subpixel-antialiased; + font-smoothing: subpixel-antialiased; +} +.profile-section .profile-parameters ul li .value.button .btn { + font-size: 13.5px; +} +.profile-section .profile-parameters ul > li:last-of-type { + margin-bottom: 0; +} +.profile-section .profile-parameters label { + width: 30%; + font-size: 18px; + font-weight: 400; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.profile-section .profile-parameters label.error-label { + text-align: left; + margin-left: 30%; + padding-left: 15px; + width: 50%; +} +.profile-section .profile-parameters .form-control-static { + font-size: 18px; + font-weight: 300; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.profile-section .profile-parameters .form-control-static .btn { + margin-top: -4px; +} +.profile-section .profile-parameters .form-control { + margin-top: 4px; +} +.profile-section.social .profile-parameters ul li { + height: auto; +} +.profile-section.social .profile-parameters ul li .social-label-container { + margin-left: 30%; +} +.profile-section.social .profile-parameters ul li .social-label-container span { + line-height: 32px; +} +#settings-buttons { + margin: 20px 0; +} +#settings-buttons .btn { + width: 150px; + margin-left: 10px; +} +.profile-edit-button { + float: right; + padding-top: 20px; + padding-right: 10%; + margin-bottom: -65px; +} +@media (max-width: 992px) { + .profile-header { + height: 125px; + } + .profile-header .profile-image-wrapper { + top: 25px; + } + .profile-header .profile-image-wrapper img { + height: 75px; + width: 75px; + } + .profile-header .profile-name { + font-size: 35px; + padding-top: 38px; + padding-left: 90px; + } + .profile-section.first { + margin-top: 0; + } + .profile-section .profile-parameters ul li .parameter { + float: none; + } + .profile-section .profile-parameters ul li .value { + float: none; + } + .profile-section .profile-parameters label { + width: 100%; + padding-left: 0; + } + .profile-section .profile-parameters label.error-label { + margin-left: 0; + padding-left: 15px; + width: 100%; + } + .profile-section.social .profile-parameters ul li .social-label-container { + margin-left: 15px; + } + .profile-section.social .profile-parameters ul li .social-label-container span { + line-height: 45px; + font-size: 18px; + } + #settings-buttons .btn-toolbar { + float: none!important; + } + #settings-buttons .btn-toolbar .btn-group { + float: left; + width: 100%; + margin-bottom: 10px; + } + #settings-buttons .btn-toolbar .btn-group .btn { + width: 100%; + margin-left: 0; + } + .profile-edit-button { + float: none; + padding-right: 0; + margin-bottom: 0; + } + .profile-edit-button .btn { + width: 80%; + margin: 0 10%; + } +} +.label-google { + color: #dd4b39; + border: #aaaaaa solid 1px; +} +.label-google:hover { + color: #ffffff; + background-color: #dd4b39; +} +.label-facebook { + color: #3b5998; + border: #aaaaaa solid 1px; +} +.label-facebook:hover { + color: #ffffff; + background-color: #3b5998; +} +.label-hydroshare { + color: #71a43a; + border: #aaaaaa solid 1px; +} +.label-hydroshare:hover { + color: #ffffff; + background-color: #71a43a; +} +.label-linkedin { + color: #007bb6; + border: #aaaaaa solid 1px; +} +.label-linkedin:hover { + color: #ffffff; + background-color: #007bb6; +} +/***************************************************************************** + * FILE: secondary_header + * DATE: 2014 + * AUTHOR: Nathan Swain + * COPYRIGHT: (c) Brigham Young University 2014 + * LICENSE: BSD 2-Clause + *****************************************************************************/ +.tethys-secondary-header { + position: relative; + height: 100px; + background: #1b95dc; + margin-bottom: 70px; +} +.tethys-secondary-header .image-wrapper { + position: absolute; + top: 0; + left: 0; + padding: 20px; + z-index: 100; +} +.tethys-secondary-header .image-wrapper img { + border-radius: 50%; + width: 120px; + background: #1b95dc; + box-shadow: 0 5px 10px 0px rgba(0, 0, 0, 0.1); +} +.tethys-secondary-header .secondary-title-wrapper { + float: left; +} +.tethys-secondary-header .secondary-title-wrapper .secondary-title { + display: block; + color: white; + font-size: 40px; + font-weight: 300; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + padding: 21px 0; + padding-left: 130px; +} +@media (max-width: 700px) { + .tethys-secondary-header { + height: 100px; + margin-bottom: 0; + } + .tethys-secondary-header .image-wrapper { + position: inline-block; + padding: 10px 20px; + } + .tethys-secondary-header .image-wrapper img { + width: 80px; + box-shadow: none; + } + .tethys-secondary-header .secondary-title-wrapper .secondary-title { + font-size: 40px; + padding: 21px 0; + padding-left: 50px; + } +} + +/* + * Override App Library Header Styles + */ + +.tethys-secondary-header { + height: 60px!important; +} + +.tethys-secondary-header .secondary-title-wrapper .secondary-title { + font-size: 30px!important; + font-weight: 400!important; + padding: 0!important; + padding-left: 50px!important; + margin-top: 9px!important; +} + +@media(max-width: 900px) { + .tethys-secondary-header .secondary-title-wrapper .secondary-title { + padding-left: 20px!important; + } +} + +@media(max-width: 750px) { + .tethys-secondary-header { + height: 40px!important; + } + + .tethys-secondary-header .secondary-title-wrapper .secondary-title { + font-size: 20px!important; + margin-top: 6px!important; + } +} From 32bba003b09426f5fad87a899c4039aec6ff4bd9 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Tue, 19 Sep 2017 16:43:03 -0600 Subject: [PATCH 026/215] Fix other commands --- Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 677beb612..3d4e57737 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,9 @@ RUN pwd \ ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh - +ADD aquaveo_static/images/aquaveo_favicon.ico /usr/lib/tethys/src/static/tethys_portal/images/default_favicon.png +ADD aquaveo_static/images/aquaveo_logo.png /usr/lib/tethys/src/static/tethys_portal/images/tethys-logo-75.png +ADD aqauveo_static/tethys_main.css /usr/lib/tethys/src/static/tethys_portal/css/tethys_main.css # Make port 8000 available to the outside world EXPOSE 8000 @@ -50,6 +52,8 @@ CMD echo Stating Tethys Setup \ --superuser-pass ${TETHYSBUILD_SUPERUSER_PASS:-admin} \ --tethys-home ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys} \ --conda-home ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda} \ + --production \ + && sed 's/BYPASS_TETHYS_HOME = False/BYPASS_TETHYS_HOME = True/' /usr/lib/tethys/src/tethys_portal/settings.py > /usr/lib/tethys/src/tethys_portal/settings.py \ && echo Setup Complete \ && cd ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys}/src \ && echo Source Directory: $PWD \ From afb1acd8cbf86324095c9889bdca774be1727754 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Thu, 21 Sep 2017 12:17:47 -0600 Subject: [PATCH 027/215] updates --- Dockerfile | 25 +++---- docker/setup_tethys.sh | 164 ++++++++++------------------------------- 2 files changed, 52 insertions(+), 137 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3d4e57737..61dc0e3b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use an official Python runtime as a parent image -FROM python:2.7-slim +FROM python:2 WORKDIR /usr/lib/tethys @@ -8,31 +8,30 @@ ADD docker/install_tethys.sh /usr/lib/tethys/install_tethys.sh ADD . /usr/lib/tethys/src # Arguments -ARG TETHYSBUILD_BRANCH=release -ARG TETHYSBUILD_PY_VERSION=2 -ARG TETHYSBUILD_TETHYS_HOME=/usr/lib/tethys -ARG TETHYSBUILD_CONDA_HOME=/usr/lib/tethys/miniconda -ARG TETHYSBUILD_CONDA_ENV_NAME=tethys +# ARG TETHYSBUILD_BRANCH=release +# ARG TETHYSBUILD_PY_VERSION=2 +# ARG TETHYSBUILD_TETHYS_HOME=/usr/lib/tethys +# ARG TETHYSBUILD_CONDA_HOME=/usr/lib/tethys/miniconda +# ARG TETHYSBUILD_CONDA_ENV_NAME=tethys # Run Scripts to Get Files RUN pwd \ && apt-get update \ && apt-get install -y wget bzip2 git \ && bash install_tethys.sh \ - -b $TETHYSBUILD_BRANCH \ - --python-version $TETHYSBUILD_PY_VERSION \ - --tethys-home $TETHYSBUILD_TETHYS_HOME \ - --conda-home $TETHYSBUILD_CONDA_HOME \ - --conda-env-name $TETHYSBUILD_CONDA_ENV_NAME + --python-version 2 \ + --tethys-home /usr/lib/tethys \ + --conda-home /usr/lib/tethys/miniconda \ + --conda-env-name tethys ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh ADD aquaveo_static/images/aquaveo_favicon.ico /usr/lib/tethys/src/static/tethys_portal/images/default_favicon.png ADD aquaveo_static/images/aquaveo_logo.png /usr/lib/tethys/src/static/tethys_portal/images/tethys-logo-75.png -ADD aqauveo_static/tethys_main.css /usr/lib/tethys/src/static/tethys_portal/css/tethys_main.css +ADD aquaveo_static/tethys_main.css /usr/lib/tethys/src/static/tethys_portal/css/tethys_main.css # Make port 8000 available to the outside world -EXPOSE 8000 +EXPOSE 80 # Configure Tethys ENV PATH ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda}/envs/tethys/bin:$PATH diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index 415baa6e2..9be111c16 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -35,9 +35,10 @@ set -e # exit on error # Set platform specific default options if [ "$(uname)" = "Linux" ] then - # LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") + LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") # convert to lower case - # LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} + echo "Linux Distribution: ${LINUX_DISTRIBUTION}" + LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" BASH_PROFILE=".bashrc" resolve_relative_path () @@ -228,14 +229,16 @@ then echo "Starting Tethys Setup..." . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} tethys gen settings ${ALLOWED_HOST_OPT} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} - sed -i -e "s/127.0.0.1/${TETHYS_DB_HOST}/g" /usr/lib/tethys/src/tethys_portal/settings.py + sed -i -e "s/'HOST': '127.0.0.1',/'HOST': '${TETHYSBUILD_DB_HOST}',/g" /usr/lib/tethys/src/tethys_portal/settings.py + sed -i -e 's/BYPASS_TETHYS_HOME = False/BYPASS_TETHYS_HOME = True/g' /usr/lib/tethys/src/tethys_portal/settings.py + # sed -i -e "s/127.0.0.1/${TETHYS_DB_HOST}/g" /usr/lib/tethys/src/tethys_portal/settings.py # Setup local database echo "Setting up the Tethys database..." # initdb -U postgres -D "${TETHYS_HOME}/psql/data" # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" echo "Waiting for databases to startup..."; sleep 30 - # if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1 && echo $?) -ne 0 ]]; then - if [[ "${TETHYS_DB_CREATE}" -ne '0' ]]; then + if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1 && echo $?) -ne 0 ]]; then + # if [[ "${TETHYS_DB_CREATE}" -ne '0' ]]; then echo "Creating DB User and Password" psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 @@ -244,7 +247,8 @@ then # Initialze Tethys database cd /usr/lib/tethys/src tethys manage syncdb - if [[ "${TETHYS_DB_CREATE}" -ne '0' ]]; then + if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1 && echo $?) -ne 0 ]]; then + # if [[ "${TETHYS_DB_CREATE}" -ne '0' ]]; then echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell fi # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" stop @@ -292,57 +296,56 @@ TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} # Install Production configuration if flag is set ubuntu_debian_production_install() { - sudo apt update - sudo apt install -y nginx - sudo rm /etc/nginx/sites-enabled/default + apt update + apt install -y nginx + rm /etc/nginx/sites-enabled/default NGINX_SITES_DIR='sites-enabled' } enterprise_linux_production_install() { - sudo yum install nginx -y - sudo systemctl enable nginx - sudo systemctl start nginx - sudo firewall-cmd --permanent --zone=public --add-service=http -# sudo firewall-cmd --permanent --zone=public --add-service=https - sudo firewall-cmd --reload + yum install nginx -y + systemctl enable nginx + systemctl start nginx + firewall-cmd --permanent --zone=public --add-service=http +# firewall-cmd --permanent --zone=public --add-service=https + firewall-cmd --reload NGINX_SITES_DIR='conf.d' } redhat_production_install() { VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") - sudo bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/rhel/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" + bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/rhel/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" enterprise_linux_production_install } centos_production_install() { PLATFORM=${LINUX_DISTRIBUTION} VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") - sudo bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/${PLATFORM}/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" - sudo yum install epel-release -y + bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/${PLATFORM}/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" + yum install epel-release -y enterprise_linux_production_install } configure_selinux() { - sudo yum install setroubleshoot -y - sudo semanage fcontext -a -t httpd_config_t ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf - sudo restorecon -v ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf - sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}(/.*)?" - sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}/static(/.*)?" - sudo semanage fcontext -a -t httpd_sys_rw_content_t "${TETHYS_HOME}/workspaces(/.*)?" - sudo restorecon -R -v ${TETHYS_HOME} > /dev/null + yum install setroubleshoot -y + semanage fcontext -a -t httpd_config_t ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + restorecon -v ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}(/.*)?" + semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}/static(/.*)?" + semanage fcontext -a -t httpd_sys_rw_content_t "${TETHYS_HOME}/workspaces(/.*)?" + restorecon -R -v ${TETHYS_HOME} > /dev/null echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te checkmodule -M -m -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te semodule_package -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp -m ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod - sudo semodule -i ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp + semodule -i ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp } if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] then # prompt for sudo - echo "Production installation requires some commands to be run with sudo. Please enter password:" - sudo echo "Installing Tethys Production Server..." + echo "Installing Tethys Production Server..." case ${LINUX_DISTRIBUTION} in debian) @@ -378,22 +381,22 @@ then NGINX_GROUP=${NGINX_USER} NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') mkdir -p ${TETHYS_HOME}/static ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps - sudo chown -R ${USER} ${TETHYS_HOME} + chown -R ${USER} ${TETHYS_HOME} tethys manage collectall --noinput - sudo chmod 705 ~ - sudo mkdir /var/log/uwsgi - sudo touch /var/log/uwsgi/tethys.log - sudo ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ + chmod 705 ~ + mkdir /var/log/uwsgi + touch /var/log/uwsgi/tethys.log + ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ if [ -n "${SELINUX}" ] then configure_selinux fi - sudo chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log - sudo systemctl enable ${TETHYS_HOME}/src/tethys_portal/tethys.uwsgi.service - sudo systemctl start tethys.uwsgi.service - sudo systemctl restart nginx + chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log + systemctl enable ${TETHYS_HOME}/src/tethys_portal/tethys.uwsgi.service + systemctl start tethys.uwsgi.service + systemctl restart nginx set +x . deactivate @@ -423,93 +426,6 @@ installation_warning(){ echo "WARNING: installing docker on $1 is not officially supported by the Tethys install script. Attempting to install with $2 script." } -finalize_docker_install(){ - sudo groupadd docker - sudo gpasswd -a ${USER} docker - . ${TETHYS_CONDA_HOME}/bin/activate ${TETHYS_CONDA_ENV_NAME} - sg docker -c "tethys docker init ${DOCKER_OPTIONS}" - . deactivate - echo "Docker installation finished!" - echo "You must re-login for Docker permissions to be activated." - echo "(Alternatively you can run 'newgrp docker')" -} - -ubuntu_debian_docker_install(){ - if [ "${LINUX_DISTRIBUTION}" != "ubuntu" ] && [ ${LINUX_DISTRIBUTION} != "debian" ] - then - installation_warning ${LINUX_DISTRIBUTION} "Ubuntu" - fi - - sudo apt-get update - sudo apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common - curl -fsSL https://download.docker.com/linux/${LINUX_DISTRIBUTION}/gpg | sudo apt-key add - - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/${LINUX_DISTRIBUTION} $(lsb_release -cs) stable" - sudo apt-get update - sudo apt-get install -y docker-ce - - finalize_docker_install -} - -centos_docker_install(){ - if [ "${LINUX_DISTRIBUTION}" != "centos" ] - then - installation_warning ${LINUX_DISTRIBUTION} "CentOS" - fi - - sudo yum -y install yum-utils - sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo - sudo yum makecache fast - sudo yum -y install docker-ce - sudo systemctl start docker - sudo systemctl enable docker - - finalize_docker_install -} - -fedora_docker_install(){ - if [ "${LINUX_DISTRIBUTION}" != "fedora" ] - then - installation_warning ${LINUX_DISTRIBUTION} "Fedora" - fi - - sudo dnf -y install -y dnf-plugins-core - sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo - sudo dnf makecache fast - sudo dnf -y install docker-ce - sudo systemctl start docker - sudo systemctl enable docker - - finalize_docker_install -} - -if [ -n "${LINUX_DISTRIBUTION}" -a "${INSTALL_DOCKER}" = "true" ] -then - # prompt for sudo - echo "Docker installation requires some commands to be run with sudo. Please enter password:" - sudo echo "Installing Docker..." - - case ${LINUX_DISTRIBUTION} in - debian) - ubuntu_debian_docker_install - ;; - ubuntu) - ubuntu_debian_docker_install - ;; - centos) - centos_docker_install - ;; - redhat) - centos_docker_install - ;; - fedora) - fedora_docker_install - ;; - *) - echo "Automated Docker installation on ${LINUX_DISTRIBUTION} is not supported. Please see https://docs.docker.com/engine/installation/ for more information on installing Docker." - ;; - esac -fi - if [ -z "${SKIP_TETHYS_INSTALL}" ] then From b5aa0348c4d8715dba290987c945f27050f705cf Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 21 Sep 2017 13:23:03 -0600 Subject: [PATCH 028/215] fully implemented --- tethys_apps/cli/__init__.py | 6 +- tethys_apps/cli/manage_commands.py | 5 +- .../management/commands/collectworkspaces.py | 70 ++++++++++++------- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index fec4d3223..918d9ef08 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -229,10 +229,14 @@ def tethys_command(): # Setup start server command manage_parser = subparsers.add_parser('manage', help='Management commands for Tethys Platform.') manage_parser.add_argument('command', help='Management command to run.', - choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_COLLECT, MANAGE_CREATESUPERUSER]) + choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, + MANAGE_COLLECT, MANAGE_CREATESUPERUSER]) manage_parser.add_argument('-m', '--manage', help='Absolute path to manage.py for Tethys Platform installation.') manage_parser.add_argument('-p', '--port', type=str, help='Host and/or port on which to bind the development server.') manage_parser.add_argument('--noinput', action='store_true', help='Pass the --noinput argument to the manage.py command.') + manage_parser.add_argument('-f', '--force', required=False, action='store_true', + help='Used only with {} to force the overwrite the app directory into its collect-to ' + 'location.') manage_parser.set_defaults(func=manage_command) # Setup services command diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index ee747db4c..fde3cd513 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -78,7 +78,10 @@ def manage_command(args): elif args.command == MANAGE_COLLECTWORKSPACES: # Run collectworkspaces command - primary_process = ['python', manage_path, 'collectworkspaces'] + if args.force: + primary_process = ['python', manage_path, 'collectworkspaces', '--force'] + else: + primary_process = ['python', manage_path, 'collectworkspaces'] elif args.command == MANAGE_COLLECT: # Convenience command to run collectstatic and collectworkspaces diff --git a/tethys_apps/management/commands/collectworkspaces.py b/tethys_apps/management/commands/collectworkspaces.py index 77b100a28..fc3da9949 100644 --- a/tethys_apps/management/commands/collectworkspaces.py +++ b/tethys_apps/management/commands/collectworkspaces.py @@ -18,18 +18,26 @@ class Command(BaseCommand): """ - Command class that handles the syncstores command. Provides persistent store management functionality. + Command class that handles the collectworkspaces command. """ + def add_arguments(self, parser): + parser.add_argument('-f', '--force', action='store_true', default=False, + help='Force the overwrite the app directory into its collected-to location.') + def handle(self, *args, **options): """ - Symbolically link the static directories of each app into the static/public directory specified by the STATIC_ROOT - parameter of the settings.py. Do this prior to running Django's collectstatic method. + Symbolically link the static directories of each app into the static/public directory specified by the + STATIC_ROOT parameter of the settings.py. Do this prior to running Django's collectstatic method. """ - if not hasattr(settings, 'TETHYS_WORKSPACES_ROOT') or (hasattr(settings, 'TETHYS_WORKSPACES_ROOT') and not settings.TETHYS_WORKSPACES_ROOT): + if not hasattr(settings, 'TETHYS_WORKSPACES_ROOT') or (hasattr(settings, 'TETHYS_WORKSPACES_ROOT') + and not settings.TETHYS_WORKSPACES_ROOT): print('WARNING: Cannot find the TETHYS_WORKSPACES_ROOT setting in the settings.py file. ' - 'Please provide the path to the static directory using the TETHYS_WORKSPACES_ROOT setting and try again.') + 'Please provide the path to the static directory using the TETHYS_WORKSPACES_ROOT ' + 'setting and try again.') exit(1) + # Get optional force arg + force = options['force'] # Read settings workspaces_root = settings.TETHYS_WORKSPACES_ROOT @@ -42,28 +50,40 @@ def handle(self, *args, **options): for app, path in installed_apps.items(): # Check for both variants of the static directory (public and static) - workspaces_path = os.path.join(path, 'workspaces') - workspaces_root_path = os.path.join(workspaces_root, app) + app_ws_path = os.path.join(path, 'workspaces') + tethys_ws_root_path = os.path.join(workspaces_root, app) # Only perform if workspaces_path is a directory - if os.path.isdir(workspaces_path) and not os.path.islink(workspaces_path): - # Clear out old symbolic links/directories in workspace root if necessary - try: - # Remove link - os.remove(workspaces_root_path) - except OSError: - try: - # Remove directory - shutil.rmtree(workspaces_root_path) - except OSError: - # No file - pass + if not os.path.isdir(app_ws_path): + print 'WARNING: The workspace_path for app "{}" is not a directory. Skipping...'.format(app) + continue - # Move the directory to workspace root path - shutil.move(workspaces_path, workspaces_root_path) + if not os.path.islink(app_ws_path): + if not os.path.exists(tethys_ws_root_path): + # Move the directory to workspace root path + shutil.move(app_ws_path, tethys_ws_root_path) + else: + if force: + # Clear out old symbolic links/directories in workspace root if necessary + try: + # Remove link + os.remove(tethys_ws_root_path) + except OSError: + shutil.rmtree(tethys_ws_root_path, ignore_errors=True) - # Create appropriate symbolic link - if os.path.isdir(workspaces_root_path): - os.symlink(workspaces_root_path, workspaces_path) - print('INFO: Successfully linked "workspaces" directory to TETHYS_WORKSPACES_ROOT for app "{0}".'.format(app)) + # Move the directory to workspace root path + shutil.move(app_ws_path, tethys_ws_root_path) + else: + print('WARNING: Workspace directory for app "{}" already exists in the TETHYS_WORKSPACES_ROOT ' + 'directory. A symbolic link is being created to the existing directory. To force overwrite ' + 'the existing directory, re-run the command with the "-f" argument.'.format(app)) + shutil.rmtree(app_ws_path, ignore_errors=True) + # Create appropriate symbolic link + if os.path.isdir(tethys_ws_root_path): + os.symlink(tethys_ws_root_path, app_ws_path) + print('INFO: Successfully linked "workspaces" directory to TETHYS_WORKSPACES_ROOT for app ' + '"{0}".'.format(app)) + else: + print('WARNING: Workspace directory for app "{}" is already symbolically linked to another directory ' + 'within the TETHYS_WORKSPACES_ROOT directory. Skipping... '.format(app)) From b47cf901a239f628b8834a3fa7b6be1f50c775a0 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 21 Sep 2017 13:57:30 -0600 Subject: [PATCH 029/215] fix bug with global model imports --- tethys_apps/cli/app_settings_commands.py | 17 ++++---- tethys_apps/cli/link_commands.py | 49 ++++++++++++------------ tethys_apps/cli/services_commands.py | 6 ++- 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index d00117473..e606f7cd4 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -1,19 +1,20 @@ from cli_helpers import console_superuser_required from django.core.exceptions import ObjectDoesNotExist -from tethys_apps.models import (TethysApp, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, - SpatialDatasetServiceSetting) from .cli_colors import * -setting_type_dict = { - PersistentStoreConnectionSetting: 'ps_connection', - PersistentStoreDatabaseSetting: 'ps_database', - SpatialDatasetServiceSetting: 'ds_spatial' -} - @console_superuser_required def app_settings_list_command(args): + from tethys_apps.models import (TethysApp, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, + SpatialDatasetServiceSetting) + + setting_type_dict = { + PersistentStoreConnectionSetting: 'ps_connection', + PersistentStoreDatabaseSetting: 'ps_database', + SpatialDatasetServiceSetting: 'ds_spatial' + } + app_package = args.app try: app = TethysApp.objects.get(package=app_package) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index 239013b3c..f46ea8a38 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -1,38 +1,39 @@ from django.core.exceptions import ObjectDoesNotExist -from tethys_apps.models import TethysApp -from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, - PersistentStoreDatabaseSetting) -from tethys_services.models import (SpatialDatasetService, PersistentStoreService) from .cli_colors import * from .cli_helpers import console_superuser_required -service_type_to_model_dict = { - 'spatial': SpatialDatasetService, - 'persistent': PersistentStoreService -} - -setting_type_to_link_model_dict = { - 'ps_database': { - 'setting_model': PersistentStoreDatabaseSetting, - 'service_field': 'persistent_store_service' - }, - 'ps_connection': { - 'setting_model': PersistentStoreConnectionSetting, - 'service_field': 'persistent_store_service' - }, - 'ds_spatial': { - 'setting_model': SpatialDatasetServiceSetting, - 'service_field': 'spatial_dataset_service' - } -} - @console_superuser_required def link_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_apps.models import TethysApp + from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, + PersistentStoreDatabaseSetting) + from tethys_services.models import (SpatialDatasetService, PersistentStoreService) + + service_type_to_model_dict = { + 'spatial': SpatialDatasetService, + 'persistent': PersistentStoreService + } + + setting_type_to_link_model_dict = { + 'ps_database': { + 'setting_model': PersistentStoreDatabaseSetting, + 'service_field': 'persistent_store_service' + }, + 'ps_connection': { + 'setting_model': PersistentStoreConnectionSetting, + 'service_field': 'persistent_store_service' + }, + 'ds_spatial': { + 'setting_model': SpatialDatasetServiceSetting, + 'service_field': 'spatial_dataset_service' + } + } + try: service = args.service setting = args.setting diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 81bc42a2f..4d33c05b1 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -1,7 +1,6 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.utils import IntegrityError from django.forms.models import model_to_dict -from tethys_services.models import SpatialDatasetService, PersistentStoreService from .cli_colors import * from .cli_helpers import console_superuser_required, add_geoserver_rest_to_endpoint @@ -23,6 +22,7 @@ def services_create_persistent_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_services.models import PersistentStoreService name = None try: @@ -52,6 +52,7 @@ def services_create_persistent_command(args): @console_superuser_required def services_remove_persistent_command(args): + from tethys_services.models import PersistentStoreService persistent_service_id = None try: @@ -84,6 +85,7 @@ def services_create_spatial_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_services.models import SpatialDatasetService name = None try: @@ -128,6 +130,7 @@ def services_create_spatial_command(args): @console_superuser_required def services_remove_spatial_command(args): + from tethys_services.models import SpatialDatasetService spatial_service_id = None try: @@ -160,6 +163,7 @@ def services_list_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_services.models import SpatialDatasetService, PersistentStoreService list_persistent = False list_spatial = False From e208409d4d65b609a5e307d9ce3efebc9237bde1 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Fri, 22 Sep 2017 04:58:20 +0000 Subject: [PATCH 030/215] Update README.md --- docker/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/README.md b/docker/README.md index 2f08cd06f..b1a4daf70 100644 --- a/docker/README.md +++ b/docker/README.md @@ -52,7 +52,6 @@ args in the build arg table can be used here as well) |TETHYSBUILD_DB_PASSWORD | Password for Database | pass | |TETHYSBUILD_DB_HOST | IPAddress for Database host| 127.0.0.1 | |TETHYSBUILD_DB_PORT | Port on Database host | 5432 | -|TETHYSBUILD_DB_CREATE | Create Database Users (0/1)| 0 | |TETHYSBUILD_SUPERUSER | Tethys Superuser | tethys_super | |TETHYSBUILD_SUPERUSER_PASS| Tethys Superuser Password | admin | From 6c15fad8ccbec765f4c001eaae6ed57576af3d5c Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 25 Sep 2017 10:49:06 -0600 Subject: [PATCH 031/215] remove unnecessary authentication from tethys commands --- tethys_apps/cli/__init__.py | 25 ---------------- tethys_apps/cli/app_settings_commands.py | 2 -- tethys_apps/cli/cli_helpers.py | 36 +----------------------- tethys_apps/cli/link_commands.py | 2 -- tethys_apps/cli/services_commands.py | 7 +---- 5 files changed, 2 insertions(+), 70 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index 918d9ef08..b095d764a 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -251,21 +251,12 @@ def tethys_command(): services_remove_persistent = services_remove_subparsers.add_parser('persistent', help='Remove a Persistent Store Service.') services_remove_persistent.add_argument('service_id', help='The ID of the service that you are removing.') - services_remove_persistent.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_remove_persistent.set_defaults(func=services_remove_persistent_command) # REMOVE SPATIAL SERVICE COMMAND services_remove_spatial = services_remove_subparsers.add_parser('spatial', help='Remove a Spatial Dataset Service.') services_remove_spatial.add_argument('service_id', help='The ID of the service that you are removing.') - services_remove_spatial.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') - services_remove_spatial.set_defaults(func=services_remove_spatial_command) # SERVICES CREATE COMMANDS @@ -279,10 +270,6 @@ def tethys_command(): services_create_ps.add_argument('-c', '--connection', required=True, type=str, help='The connection of the Service in the form ' '":@:"') - services_create_ps.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_create_ps.set_defaults(func=services_create_persistent_command) # CREATE SPATIAL DATASET SERVICE COMMAND @@ -297,10 +284,6 @@ def tethys_command(): '--connection argument, of the form ":"') services_create_sd.add_argument('-k', '--apikey', required=False, type=str, help='The API key, if any, required to establish a connection.') - services_create_sd.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_create_sd.set_defaults(func=services_create_spatial_command) # LIST SERVICES COMMAND @@ -308,10 +291,6 @@ def tethys_command(): group = services_list_parser.add_mutually_exclusive_group() group.add_argument('-p', '--persistent', action='store_true', help='Only list Persistent Store Services.') group.add_argument('-s', '--spatial', action='store_true', help='Only list Spatial Dataset Services.') - services_list_parser.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_list_parser.set_defaults(func=services_list_command) # Setup app_settings command @@ -319,10 +298,6 @@ def tethys_command(): app_settings_subparsers = app_settings_parser.add_subparsers(title='Options') app_settings_list_parser = app_settings_subparsers.add_parser('list', help='List all settings for a specified app') app_settings_list_parser.add_argument('app', help='The app ("") to list the Settings for.') - app_settings_list_parser.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') app_settings_list_parser.set_defaults(func=app_settings_list_command) # Setup link command diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index e606f7cd4..e572b9094 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -1,10 +1,8 @@ -from cli_helpers import console_superuser_required from django.core.exceptions import ObjectDoesNotExist from .cli_colors import * -@console_superuser_required def app_settings_list_command(args): from tethys_apps.models import (TethysApp, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, SpatialDatasetServiceSetting) diff --git a/tethys_apps/cli/cli_helpers.py b/tethys_apps/cli/cli_helpers.py index d91eed05e..693e68778 100644 --- a/tethys_apps/cli/cli_helpers.py +++ b/tethys_apps/cli/cli_helpers.py @@ -1,37 +1,3 @@ -from getpass import getpass -from django.contrib.auth import authenticate -from .cli_colors import * - - -def console_superuser_required(func): - def _wrapped(args): - credentials = args.authenticate - username = None - password = None - - if credentials: - cred_parts = credentials.split(':') - if len(cred_parts) > 0: - username = cred_parts[0] - if len(cred_parts) > 1: - password = cred_parts[1] - if not username: - username = raw_input('username: ') - if not password: - password = getpass('password: ') - user = authenticate(username=username, password=password) - if not user: - with pretty_output(FG_RED) as p: - p.write('The username or password provided was incorrect. Command aborted.') - exit(1) - if not user.is_superuser: - with pretty_output(FG_RED) as p: - p.write('You are not authorized to perform this action.') - exit(1) - - return func(args) - - return _wrapped def add_geoserver_rest_to_endpoint(endpoint): @@ -42,4 +8,4 @@ def add_geoserver_rest_to_endpoint(endpoint): port_and_path = parts2[1] port = port_and_path.split('/')[0] - return '{0}//{1}:{2}/geoserver/rest/'.format(protocol, host, port) \ No newline at end of file + return '{0}//{1}:{2}/geoserver/rest/'.format(protocol, host, port) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index f46ea8a38..b4d176a44 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -1,10 +1,8 @@ from django.core.exceptions import ObjectDoesNotExist from .cli_colors import * -from .cli_helpers import console_superuser_required -@console_superuser_required def link_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 4d33c05b1..2967136e1 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -3,7 +3,7 @@ from django.forms.models import model_to_dict from .cli_colors import * -from .cli_helpers import console_superuser_required, add_geoserver_rest_to_endpoint +from .cli_helpers import add_geoserver_rest_to_endpoint SERVICES_CREATE = 'create' SERVICES_CREATE_PERSISTENT = 'persistent' @@ -17,7 +17,6 @@ def __init__(self): Exception.__init__(self) -@console_superuser_required def services_create_persistent_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps @@ -50,7 +49,6 @@ def services_create_persistent_command(args): p.write('Persistent Store Service with name "{0}" already exists. Command aborted.'.format(name)) -@console_superuser_required def services_remove_persistent_command(args): from tethys_services.models import PersistentStoreService persistent_service_id = None @@ -80,7 +78,6 @@ def services_remove_persistent_command(args): p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(persistent_service_id)) -@console_superuser_required def services_create_spatial_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps @@ -128,7 +125,6 @@ def services_create_spatial_command(args): p.write('Spatial Dataset Service with name "{0}" already exists. Command aborted.'.format(name)) -@console_superuser_required def services_remove_spatial_command(args): from tethys_services.models import SpatialDatasetService spatial_service_id = None @@ -158,7 +154,6 @@ def services_remove_spatial_command(args): p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(spatial_service_id)) -@console_superuser_required def services_list_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps From f765625423d3aff7edd083408c6e57d1cd587f1b Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 25 Sep 2017 11:28:35 -0600 Subject: [PATCH 032/215] Add changes --- Dockerfile | 6 ++++-- docker/setup_tethys.sh | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 61dc0e3b7..f5793ce86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,10 @@ RUN pwd \ --python-version 2 \ --tethys-home /usr/lib/tethys \ --conda-home /usr/lib/tethys/miniconda \ - --conda-env-name tethys + --conda-env-name tethys \ + && mkdir /usr/lib/tethys/workspaces + +VOLUME ["/usr/lib/tethys/workspaces"] ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh @@ -52,7 +55,6 @@ CMD echo Stating Tethys Setup \ --tethys-home ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys} \ --conda-home ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda} \ --production \ - && sed 's/BYPASS_TETHYS_HOME = False/BYPASS_TETHYS_HOME = True/' /usr/lib/tethys/src/tethys_portal/settings.py > /usr/lib/tethys/src/tethys_portal/settings.py \ && echo Setup Complete \ && cd ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys}/src \ && echo Source Directory: $PWD \ diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index 9be111c16..660c8e473 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -229,8 +229,9 @@ then echo "Starting Tethys Setup..." . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} tethys gen settings ${ALLOWED_HOST_OPT} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} - sed -i -e "s/'HOST': '127.0.0.1',/'HOST': '${TETHYSBUILD_DB_HOST}',/g" /usr/lib/tethys/src/tethys_portal/settings.py + sed -i -e "s/'HOST': '127.0.0.1',/'HOST': '${TETHYSBUILD_DB_HOST}',/g" /usr/lib/tethys/src/tethys_portal/settings.py sed -i -e 's/BYPASS_TETHYS_HOME = False/BYPASS_TETHYS_HOME = True/g' /usr/lib/tethys/src/tethys_portal/settings.py + sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" /usr/lib/tethys/src/tethys_portal/settings.py # sed -i -e "s/127.0.0.1/${TETHYS_DB_HOST}/g" /usr/lib/tethys/src/tethys_portal/settings.py # Setup local database echo "Setting up the Tethys database..." From 1c452d83dfd2f4ceba85075df92910c7a29edc4b Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 26 Sep 2017 13:56:12 -0600 Subject: [PATCH 033/215] command to sync apps fully implemented --- tethys_apps/cli/__init__.py | 6 +++--- tethys_apps/cli/manage_commands.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index b095d764a..73776e14f 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -21,7 +21,7 @@ from .gen_commands import GEN_SETTINGS_OPTION, GEN_APACHE_OPTION, generate_command from .manage_commands import (manage_command, get_manage_path, run_process, MANAGE_START, MANAGE_SYNCDB, - MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, + MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_SYNCAPPS, MANAGE_COLLECT, MANAGE_CREATESUPERUSER, TETHYS_SRC_DIRECTORY) from .services_commands import (SERVICES_CREATE, SERVICES_CREATE_PERSISTENT, SERVICES_CREATE_SPATIAL, SERVICES_LINK, services_create_persistent_command, services_create_spatial_command, @@ -230,7 +230,7 @@ def tethys_command(): manage_parser = subparsers.add_parser('manage', help='Management commands for Tethys Platform.') manage_parser.add_argument('command', help='Management command to run.', choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, - MANAGE_COLLECT, MANAGE_CREATESUPERUSER]) + MANAGE_COLLECT, MANAGE_CREATESUPERUSER, MANAGE_SYNCAPPS]) manage_parser.add_argument('-m', '--manage', help='Absolute path to manage.py for Tethys Platform installation.') manage_parser.add_argument('-p', '--port', type=str, help='Host and/or port on which to bind the development server.') manage_parser.add_argument('--noinput', action='store_true', help='Pass the --noinput argument to the manage.py command.') @@ -308,7 +308,7 @@ def tethys_command(): '":" ' '(i.e. "persistent_connection:super_conn")') link_parser.add_argument('setting', help='Setting of an app with which to link the specified service.' - 'Of the form ":' + 'Of the form "::' '" (i.e. "epanet:database:epanet_2")') link_parser.set_defaults(func=link_command) diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index fde3cd513..e228b6b37 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -23,6 +23,7 @@ MANAGE_COLLECTWORKSPACES = 'collectworkspaces' MANAGE_COLLECT = 'collectall' MANAGE_CREATESUPERUSER = 'createsuperuser' +MANAGE_SYNCAPPS = 'syncapps' def get_manage_path(args): @@ -102,7 +103,9 @@ def manage_command(args): elif args.command == MANAGE_CREATESUPERUSER: primary_process = ['python', manage_path, 'createsuperuser'] - + elif args.command == MANAGE_SYNCAPPS: + from tethys_apps.utilities import sync_tethys_app_db + sync_tethys_app_db() if primary_process: run_process(primary_process) From 42d5ac3c4c072c84ee14ee3b4851fc8c2001f209 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 26 Sep 2017 16:06:50 -0600 Subject: [PATCH 034/215] added scheduler commands; added documentation for all recent cli cmds --- docs/tethys_sdk/tethys_cli.rst | 156 +++++++++++++++++++++++++- tethys_apps/cli/__init__.py | 42 ++++++- tethys_apps/cli/scheduler_commands.py | 73 ++++++++++++ 3 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 tethys_apps/cli/scheduler_commands.py diff --git a/docs/tethys_sdk/tethys_cli.rst b/docs/tethys_sdk/tethys_cli.rst index 6d6b10b92..a15035fa4 100644 --- a/docs/tethys_sdk/tethys_cli.rst +++ b/docs/tethys_sdk/tethys_cli.rst @@ -87,8 +87,9 @@ This command contains several subcommands that are used to help manage Tethys Pl * **subcommand**: The management command to run. Either "start", "syncdb", or "collectstatic". - * *start*: Starts the Django development server. Wrapper for ``manage.py runserver``. + * *start*: Start the Django development server. Wrapper for ``manage.py runserver``. * *syncdb*: Initialize the database during installation. Wrapper for ``manage.py syncdb``. + * *syncapps*: Sync installed apps with the TethysApp database. * *collectstatic*: Link app static/public directories to STATIC_ROOT directory and then run Django's collectstatic command. Preprocessor and wrapper for ``manage.py collectstatic``. * *collectworkspaces*: Link app workspace directories to TETHYS_WORKSPACES_ROOT directory. * *collectall*: Convenience command for running both *collectstatic* and *collectworkspaces*. @@ -110,6 +111,9 @@ This command contains several subcommands that are used to help manage Tethys Pl # Sync the database $ tethys manage syncdb + # Sync installed apps with the TethysApp database. + $ tethys manage syncapps + # Collect static files $ tethys manage collectstatic @@ -285,3 +289,153 @@ Management commands for running tests for Tethys Platform and Tethys Apps. See : # Run tests for a single app tethys test -f tethys_apps.tethysapp.my_first_app + + +.. _tethys_cli_app_settings: + +app_settings +--------------- + +This command is used to list the Persistent Store and Spatial Dataset Settings that an app has requested. + +**Arguments:** + +* **app_name**: Name of app for which Settings will be listed + +**Optional Arguments:** + +* **-p --persistent**: A flag indicating that only Persistent Store Settings should be listed +* **-s --spatial**: A flag indicating that only Spatial Dataset Settings should be listed + +**Examples:** + +:: + + $ tethys app_settings my_first_app + +.. _tethys_cli_services: + +services [ | options] +--------------- + +This command is used to interact with Tethys Services from the command line, rather than the App Admin interface. + +**Arguments:** + +* **subcommand**: The services command to run. One of the following: + + * *list*: List all existing Tethys Services (Persistent Store and Spatial Dataset Services) + * *create*: Create a new Tethys Service + * **subcommand**: The service type to create + * *persistent*: Create a new Persistent Store Service + **Arguments:** + + * **-n, --name**: A unique name to identify the service being created + * **-c, --connection**: The connection endpoint associated with this service, in the form ":@:" + * *spatial*: Create a new Spatial Dataset Service + **Arguments:** + + * **-n, --name**: A unique name to identify the service being created + * **-c, --connection**: The connection endpoint associated with this service, in the form ":@//:" + + **Optional Arguments:** + + * **-p, --public-endpoint**: The public-facing endpoint of the Service, if different than what was provided with the "--connection" argument, in the form "//:". + * **-k, --apikey**: The API key, if any, required to establish a connection. + * *remove*: Remove a Tethys Service + * **subcommand**: The service type to remove + * *persistent*: Remove a Persistent Store Service + **Arguments:** + * **service_uid**: A unique identifier of the Service to be removed, which can either be the database ID, or the service name + * *spatial*: Remove a Spatial Dataset Service + **Arguments:** + * **service_uid**: A unique identifier of the Service to be removed, which can either be the database ID, or the service name + +**Examples:** + +:: + + # List all Tethys Services + $ tethys services list + + # List only Spatial Dataset Tethys Services + $ tethys services list -s + + # List only Persistent Store Tethys Services + $ tethys services list -p + + # Create a new Spatial Dataset Tethys Service + + $ tethys services create spatial -n my_spatial_service -c my_username:my_password@http://127.0.0.1:8081 -p https://mypublicdomain.com -k mysecretapikey + + # Create a new Persistent Store Tethys Service + $ tethys services create persistent -n my_persistent_service -c my_username:my_password@http://127.0.0.1:8081 + + # Remove a Spatial Dataset Tethys Service + $ tethys services remove my_spatial_service + + # Remove a Persistent Store Tethys Service + $ tethys services remove my_persistent_service + +.. _tethys_cli_link: + +link +--------------- + +This command is used to link a Tethys Service with a TethysApp Setting + +**Arguments:** + +* **service_identifier**: An identifier of the Tethys Service being linked, of the form ":", where can be either "spatial" or "persistent", and must be either the database ID or name of the Tethys Service. +* **app_setting_identifier**: An identifier of the TethysApp Setting being linked, of the form "::", where must be one of "ds_spatial," "ps_connection", or "ps_database" and can be either the database ID or name of the TethysApp Setting. + +**Examples:** + +:: + + # Link a Persistent Store Service to a Persistent Store Connection Setting + $ tethys link persistent:my_persistent_service my_first_app:ps_connection:my_ps_connection + + # Link a Persistent Store Service to a Persistent Store Database Setting + $ tethys link persistent:my_persistent_service my_first_app:ps_database:my_ps_connection + + # Link a Spatial Dataset Service to a Spatial Dataset Service Setting + $ tethys link spatial:my_spatial_service my_first_app:ds_spatial:my_spatial_connection + +.. _tethys_cli_schedulers: + +schedulers +--------------- + +This command is used to interact with Schedulers from the command line, rather than through the App Admin interface + +**Arguments:** + +* **subcommand**: The schedulers command to run. One of the following: + + * *list*: List all existing Schedulers + * *create*: Create a new Scheduler + **Arguments:** + * **-n, --name**: A unique name to identify the Scheduler being created + * **-d, --endpoint**: The endpoint of the remote host the Scheduler will connect with in the form //" + * **-u, --username**: The username that will be used to connect to the remote endpoint" + **Optional Arguments:** + * **-p, --password**: The password associated with the username (required if "-f (--private-key-path)" not specified. + * **-f, --private-key-path**: The path to the private ssh key file (required if "-p (--password)" not specified. + * **-k, --private-key-pass**: The password to the private ssh key file (only meaningful if "-f (--private-key-path)" is specified. + * *remove*: Remove a Scheduler + **Arguments:** + * **scheduler_name**: The unique name of the Scheduler being removed. + +**Examples:** + +:: + + # List all Schedulers + $ tethys schedulers list + + # Create a new scheduler + $ tethys schedulers create -n my_scheduler -e http://127.0.0.1 -u my_username -p my_password + + # Remove a scheduler + $ tethys schedulers remove my_scheduler diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index 73776e14f..eeed30d07 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -29,6 +29,7 @@ services_remove_spatial_command) from .link_commands import link_command from .app_settings_commands import app_settings_list_command +from .scheduler_commands import scheduler_create_command, schedulers_list_command, schedulers_remove_command from .gen_commands import VALID_GEN_OBJECTS, generate_command from tethys_apps.helpers import get_installed_tethys_apps @@ -239,6 +240,37 @@ def tethys_command(): 'location.') manage_parser.set_defaults(func=manage_command) + # Setup scheduler command + scheduler_parser = subparsers.add_parser('schedulers', help='Scheduler commands for Tethys Platform.') + scheduler_subparsers = scheduler_parser.add_subparsers(title='Commands') + + # SCHEDULERS CREATE COMMAND + schedulers_create = scheduler_subparsers.add_parser('create', help='Create a Scheduler that can be ' + 'accessed by Tethys Apps.') + schedulers_create.add_argument('-n', '--name', required=True, help='A unique name for the Scheduler', type=str) + schedulers_create.add_argument('-e', '--endpoint', required=True, type=str, + help='The endpoint of the service in the form //"') + schedulers_create.add_argument('-u', '--username', required=True, help='The username to connect to the host with', + type=str) + group = schedulers_create.add_mutually_exclusive_group(required=True) + group.add_argument('-p', '--password', required=False, type=str, + help='The password associated with the provided username') + group.add_argument('-f', '--private-key-path', required=False, help='The path to the private ssh key file', + type=str) + schedulers_create.add_argument('-k', '--private-key-pass', required=False, type=str, + help='The password to the private ssh key file') + + schedulers_create.set_defaults(func=scheduler_create_command) + + # SCHEDULERS LIST COMMAND + schedulers_list = scheduler_subparsers.add_parser('list', help='List the existing Schedulers.') + schedulers_list.set_defaults(func=schedulers_list_command) + + # SCHEDULERS REMOVE COMMAND + schedulers_remove = scheduler_subparsers.add_parser('remove', help='Remove a Scheduler.') + schedulers_remove.add_argument('scheduler_name', help='The unique name of the Scheduler that you are removing.') + schedulers_remove.set_defaults(func=schedulers_remove_command) + # Setup services command services_parser = subparsers.add_parser('services', help='Services commands for Tethys Platform.') services_subparsers = services_parser.add_subparsers(title='Commands') @@ -250,13 +282,15 @@ def tethys_command(): # REMOVE PERSISTENT SERVICE COMMAND services_remove_persistent = services_remove_subparsers.add_parser('persistent', help='Remove a Persistent Store Service.') - services_remove_persistent.add_argument('service_id', help='The ID of the service that you are removing.') + services_remove_persistent.add_argument('service_uid', help='The ID or name of the Persistent Store Service ' + 'that you are removing.') services_remove_persistent.set_defaults(func=services_remove_persistent_command) # REMOVE SPATIAL SERVICE COMMAND services_remove_spatial = services_remove_subparsers.add_parser('spatial', help='Remove a Spatial Dataset Service.') - services_remove_spatial.add_argument('service_id', help='The ID of the service that you are removing.') + services_remove_spatial.add_argument('service_uid', help='The ID or name of the Spatial Dataset Service ' + 'that you are removing.') services_remove_spatial.set_defaults(func=services_remove_spatial_command) # SERVICES CREATE COMMANDS @@ -266,7 +300,7 @@ def tethys_command(): # CREATE PERSISTENT STORE SERVICE COMMAND services_create_ps = services_create_subparsers.add_parser('persistent', help='Create a Persistent Store Service.') - services_create_ps.add_argument('-n', '--name', required=True, help='The name of the Service', type=str) + services_create_ps.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) services_create_ps.add_argument('-c', '--connection', required=True, type=str, help='The connection of the Service in the form ' '":@:"') @@ -275,7 +309,7 @@ def tethys_command(): # CREATE SPATIAL DATASET SERVICE COMMAND services_create_sd = services_create_subparsers.add_parser('spatial', help='Create a Spatial Dataset Service.') - services_create_sd.add_argument('-n', '--name', required=True, help='The name of the Service', type=str) + services_create_sd.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) services_create_sd.add_argument('-c', '--connection', required=True, type=str, help='The connection of the Service in the form ' '":@//:"') diff --git a/tethys_apps/cli/scheduler_commands.py b/tethys_apps/cli/scheduler_commands.py new file mode 100644 index 000000000..fc44704b4 --- /dev/null +++ b/tethys_apps/cli/scheduler_commands.py @@ -0,0 +1,73 @@ +from .cli_colors import * +from django.core.exceptions import ObjectDoesNotExist + + +def scheduler_create_command(args): + from tethys_compute.models import Scheduler + + name = args.name + host = args.endpoint + username = args.username + password = args.password + private_key_path = args.private_key_path + private_key_pass = args.private_key_pass + + scheduler = Scheduler( + name=name, + host=host, + username=username, + password=password, + private_key_path=private_key_path, + private_key_pass=private_key_pass + ) + + scheduler.save() + + with pretty_output(FG_GREEN) as p: + p.write('Scheduler created successfully!') + exit(0) + + +def schedulers_list_command(args): + from tethys_compute.models import Scheduler + schedulers = Scheduler.objects.all() + + num_schedulers = len(schedulers) + + if num_schedulers > 0: + with pretty_output(BOLD) as p: + p.write('{0: <30}{1: <25}{2: <10}{3: <10}{4: <50}{5: <10}'.format( + 'Name', 'Host', 'Username', 'Password', 'Private Key Path', 'Private Key Pass' + )) + for scheduler in schedulers: + p.write('{0: <30}{1: <25}{2: <10}{3: <10}{4: <50}{5: <10}'.format( + scheduler.name, scheduler.host, scheduler.username, '******' if scheduler.password else 'None', + scheduler.private_key_path, '******' if scheduler.private_key_pass else 'None' + )) + else: + with pretty_output(BOLD) as p: + p.write('There are no Schedulers registered in Tethys.') + + +def schedulers_remove_command(args): + from tethys_compute.models import Scheduler + scheduler = None + name = args.scheduler_name + + try: + scheduler = Scheduler.objects.get(name=name) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('Scheduler with name "{}" does not exist.\nCommand aborted.'.format(name)) + + proceed = raw_input('Are you sure you want to delete this Scheduler? [y/n]: ') + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + scheduler.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed Scheduler "{0}"!'.format(name)) + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. Scheduler not removed.') From 5c424eac0143808764676d535d7f74535ee61cee Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 28 Sep 2017 12:01:27 -0600 Subject: [PATCH 035/215] add app_settings_create/remove commands --- tethys_apps/cli/__init__.py | 79 +++++++--- tethys_apps/cli/app_settings_commands.py | 33 +++++ tethys_apps/cli/link_commands.py | 79 +--------- tethys_apps/utilities.py | 174 ++++++++++++++++++++++- 4 files changed, 268 insertions(+), 97 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index eeed30d07..f106dc4ab 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -28,7 +28,8 @@ services_list_command, services_remove_persistent_command, services_remove_spatial_command) from .link_commands import link_command -from .app_settings_commands import app_settings_list_command +from .app_settings_commands import (app_settings_list_command, app_settings_create_ps_database_command, + app_settings_remove_command) from .scheduler_commands import scheduler_create_command, schedulers_list_command, schedulers_remove_command from .gen_commands import VALID_GEN_OBJECTS, generate_command from tethys_apps.helpers import get_installed_tethys_apps @@ -240,64 +241,63 @@ def tethys_command(): 'location.') manage_parser.set_defaults(func=manage_command) - # Setup scheduler command + # SCHEDULERS COMMANDS scheduler_parser = subparsers.add_parser('schedulers', help='Scheduler commands for Tethys Platform.') scheduler_subparsers = scheduler_parser.add_subparsers(title='Commands') - # SCHEDULERS CREATE COMMAND + # tethys schedulers create schedulers_create = scheduler_subparsers.add_parser('create', help='Create a Scheduler that can be ' - 'accessed by Tethys Apps.') + 'accessed by Tethys Apps.') schedulers_create.add_argument('-n', '--name', required=True, help='A unique name for the Scheduler', type=str) schedulers_create.add_argument('-e', '--endpoint', required=True, type=str, - help='The endpoint of the service in the form //"') + help='The endpoint of the service in the form //"') schedulers_create.add_argument('-u', '--username', required=True, help='The username to connect to the host with', - type=str) + type=str) group = schedulers_create.add_mutually_exclusive_group(required=True) group.add_argument('-p', '--password', required=False, type=str, help='The password associated with the provided username') group.add_argument('-f', '--private-key-path', required=False, help='The path to the private ssh key file', type=str) schedulers_create.add_argument('-k', '--private-key-pass', required=False, type=str, - help='The password to the private ssh key file') - + help='The password to the private ssh key file') schedulers_create.set_defaults(func=scheduler_create_command) - # SCHEDULERS LIST COMMAND + # tethys schedulers list schedulers_list = scheduler_subparsers.add_parser('list', help='List the existing Schedulers.') schedulers_list.set_defaults(func=schedulers_list_command) - # SCHEDULERS REMOVE COMMAND + # tethys schedulers remove schedulers_remove = scheduler_subparsers.add_parser('remove', help='Remove a Scheduler.') schedulers_remove.add_argument('scheduler_name', help='The unique name of the Scheduler that you are removing.') schedulers_remove.set_defaults(func=schedulers_remove_command) - # Setup services command + # SERVICES COMMANDS services_parser = subparsers.add_parser('services', help='Services commands for Tethys Platform.') services_subparsers = services_parser.add_subparsers(title='Commands') - # SERVICES REMOVE COMMANDS + # tethys services remove services_remove_parser = services_subparsers.add_parser('remove', help='Remove a Tethys Service.') services_remove_subparsers = services_remove_parser.add_subparsers(title='Service Type') - # REMOVE PERSISTENT SERVICE COMMAND + # tethys services remove persistent services_remove_persistent = services_remove_subparsers.add_parser('persistent', help='Remove a Persistent Store Service.') services_remove_persistent.add_argument('service_uid', help='The ID or name of the Persistent Store Service ' 'that you are removing.') services_remove_persistent.set_defaults(func=services_remove_persistent_command) - # REMOVE SPATIAL SERVICE COMMAND + # tethys services remove spatial services_remove_spatial = services_remove_subparsers.add_parser('spatial', help='Remove a Spatial Dataset Service.') services_remove_spatial.add_argument('service_uid', help='The ID or name of the Spatial Dataset Service ' 'that you are removing.') services_remove_spatial.set_defaults(func=services_remove_spatial_command) - # SERVICES CREATE COMMANDS + # tethys services create services_create_parser = services_subparsers.add_parser('create', help='Create a Tethys Service.') services_create_subparsers = services_create_parser.add_subparsers(title='Service Type') - # CREATE PERSISTENT STORE SERVICE COMMAND + # tethys services create persistent services_create_ps = services_create_subparsers.add_parser('persistent', help='Create a Persistent Store Service.') services_create_ps.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) @@ -306,7 +306,7 @@ def tethys_command(): '":@:"') services_create_ps.set_defaults(func=services_create_persistent_command) - # CREATE SPATIAL DATASET SERVICE COMMAND + # tethys services create spatial services_create_sd = services_create_subparsers.add_parser('spatial', help='Create a Spatial Dataset Service.') services_create_sd.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) @@ -320,24 +320,59 @@ def tethys_command(): help='The API key, if any, required to establish a connection.') services_create_sd.set_defaults(func=services_create_spatial_command) - # LIST SERVICES COMMAND + # tethys services list services_list_parser = services_subparsers.add_parser('list', help='List all existing Tethys Services.') group = services_list_parser.add_mutually_exclusive_group() group.add_argument('-p', '--persistent', action='store_true', help='Only list Persistent Store Services.') group.add_argument('-s', '--spatial', action='store_true', help='Only list Spatial Dataset Services.') services_list_parser.set_defaults(func=services_list_command) - # Setup app_settings command + # APP_SETTINGS COMMANDS app_settings_parser = subparsers.add_parser('app_settings', help='Interact with Tethys App Settings.') app_settings_subparsers = app_settings_parser.add_subparsers(title='Options') + + # tethys app_settings list app_settings_list_parser = app_settings_subparsers.add_parser('list', help='List all settings for a specified app') app_settings_list_parser.add_argument('app', help='The app ("") to list the Settings for.') app_settings_list_parser.set_defaults(func=app_settings_list_command) - # Setup link command - - # LINK SERVICE WITH APP COMMAND + # tethys app_settings create + app_settings_create_cmd = app_settings_subparsers.add_parser('create', help='Create a Setting for an app.') + + asc_subparsers = app_settings_create_cmd.add_subparsers(title='Create Options') + app_settings_create_cmd.add_argument('-a', '--app', required=True, + help='The app ("") to create the Setting for.') + app_settings_create_cmd.add_argument('-n', '--name', required=True, help='The name of the Setting to create.') + app_settings_create_cmd.add_argument('-d', '--description', required=False, + help='A description for the Setting to create.') + app_settings_create_cmd.add_argument('-r', '--required', required=False, action='store_true', + help='Include this flag if the Setting is required for the app.') + app_settings_create_cmd.add_argument('-i', '--initializer', required=False, + help='The function that initializes the PersistentStoreSetting database.') + app_settings_create_cmd.add_argument('-z', '--initialized', required=False, action='store_true', + help='Include this flag if the database is already initialized.') + + # tethys app_settings create ps_database + app_settings_create_psdb_cmd = asc_subparsers.add_parser('ps_database', + help='Create a PersistentStoreDatabaseSetting') + app_settings_create_psdb_cmd.add_argument('-s', '--spatial', required=False, action='store_true', + help='Include this flag if the database requires spatial capabilities.') + app_settings_create_psdb_cmd.add_argument('-y', '--dynamic', action='store_true', required=False, + help='Include this flag if the database should be considered to be ' + 'dynamically created.') + app_settings_create_psdb_cmd.set_defaults(func=app_settings_create_ps_database_command) + + # tethys app_settings remove + app_settings_remove_cmd = app_settings_subparsers.add_parser('remove', help='Remove a Setting for an app.') + app_settings_remove_cmd.add_argument('app', help='The app ("") to remove the Setting from.') + app_settings_remove_cmd.add_argument('-n', '--name', help='The name of the Setting to remove.', required=True) + app_settings_remove_cmd.add_argument('-f', '--force', action='store_true', help='Force removal without confirming.') + app_settings_remove_cmd.set_defaults(func=app_settings_remove_command) + + # LINK COMMANDS link_parser = subparsers.add_parser('link', help='Link a Service to a Tethys app Setting.') + + # tethys link link_parser.add_argument('service', help='Service to link to a target app. Of the form ' '":" ' '(i.e. "persistent_connection:super_conn")') diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index e572b9094..b86887b82 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -72,3 +72,36 @@ def app_settings_list_command(args): print e with pretty_output(FG_RED) as p: p.write('Something went wrong. Please try again.') + + +def app_settings_create_ps_database_command(args): + from ..utilities import create_ps_database_setting + app_package = args.app + setting_name = args.name + setting_description = args.description + required = args.required + initializer = args.initializer + initialized = args.initialized + spatial = args.spatial + dynamic = args.dynamic + + success = create_ps_database_setting(app_package, setting_name, setting_description or '', required, initializer or '', + initialized, spatial, dynamic) + + if not success: + exit(1) + + exit(0) + + +def app_settings_remove_command(args): + from ..utilities import remove_ps_database_setting + app_package = args.app + setting_name = args.name + force = args.force + success = remove_ps_database_setting(app_package, setting_name, force) + + if not success: + exit(1) + + exit(0) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index b4d176a44..a7dfe09dd 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -1,36 +1,9 @@ -from django.core.exceptions import ObjectDoesNotExist - -from .cli_colors import * - - def link_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ - from tethys_apps.models import TethysApp - from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, - PersistentStoreDatabaseSetting) - from tethys_services.models import (SpatialDatasetService, PersistentStoreService) - - service_type_to_model_dict = { - 'spatial': SpatialDatasetService, - 'persistent': PersistentStoreService - } - - setting_type_to_link_model_dict = { - 'ps_database': { - 'setting_model': PersistentStoreDatabaseSetting, - 'service_field': 'persistent_store_service' - }, - 'ps_connection': { - 'setting_model': PersistentStoreConnectionSetting, - 'service_field': 'persistent_store_service' - }, - 'ds_spatial': { - 'setting_model': SpatialDatasetServiceSetting, - 'service_field': 'spatial_dataset_service' - } - } + from ..utilities import link_service_to_app_setting + from .cli_colors import pretty_output, FG_RED try: service = args.service @@ -59,55 +32,15 @@ def link_command(args): '\nCommand aborted.') exit(1) - service_model = service_type_to_model_dict[service_type] + success = link_service_to_app_setting(service_type, service_uid, setting_app_package, setting_type, setting_uid) - try: - try: - service_uid = int(service_uid) - service = service_model.objects.get(pk=service_uid) - except ValueError: - service = service_model.objects.get(name=service_uid) - except ObjectDoesNotExist: - with pretty_output(FG_RED) as p: - p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(service_model), service_uid)) - exit(1) - - app = None - try: - app = TethysApp.objects.get(package=setting_app_package) - except ObjectDoesNotExist: - with pretty_output(FG_RED) as p: - p.write('The app you specified ("{0}") does not exist.'.format(setting_app_package)) + if not success: exit(1) - linked_setting_model_dict = None - try: - linked_setting_model_dict = setting_type_to_link_model_dict[setting_type] - except KeyError: - with pretty_output(FG_RED) as p: - p.write('The setting_type you specified ("{0}") does not exist.' - '\nChoose from: "ps_database|ps_connection|ds_spatial"'.format(setting_type)) - exit(1) - - linked_setting_model = linked_setting_model_dict['setting_model'] - linked_service_field = linked_setting_model_dict['service_field'] - - try: - try: - setting_uid = int(setting_uid) - setting = linked_setting_model.objects.get(tethys_app=app, pk=setting_uid) - except ValueError: - setting = linked_setting_model.objects.get(tethys_app=app, name=setting_uid) - except ObjectDoesNotExist: - with pretty_output(FG_RED) as p: - p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(linked_setting_model), setting_uid)) - exit(1) - - setattr(setting, linked_service_field, service) - - setting.save() + exit(0) except Exception as e: print e with pretty_output(FG_RED) as p: p.write('An unexpected error occurred. Please try again.') + exit(1) diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 73370aa03..2de4f34e9 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -317,8 +317,8 @@ def sync_tethys_app_db(): for installed_app in installed_apps: # Query to see if installed app is in the database - db_apps = TethysApp.objects.\ - filter(package__exact=installed_app.package).\ + db_apps = TethysApp.objects. \ + filter(package__exact=installed_app.package). \ all() # If the app is not in the database, then add it @@ -417,3 +417,173 @@ def get_active_app(request=None, url=None): except MultipleObjectsReturned: tethys_log.warning('Multiple apps found with root url "{0}".'.format(app_root_url)) return app + + +def create_ps_database_setting(app_package, name, description='', required=False, initializer='', initialized=False, + spatial=False, dynamic=False): + from cli.cli_colors import pretty_output, FG_RED, FG_GREEN + from tethys_apps.models import PersistentStoreDatabaseSetting + + try: + app = TethysApp.objects.get(package=app_package) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Tethys App with the name "{}" does not exist. Aborted.'.format(app_package)) + return False + + try: + setting = PersistentStoreDatabaseSetting.objects.get(name=name) + if setting: + with pretty_output(FG_RED) as p: + p.write('A PersistentStoreDatabaseSetting with name "{}" already exists. Aborted.'.format(name)) + return False + except ObjectDoesNotExist: + pass + + try: + ps_database_setting = PersistentStoreDatabaseSetting( + tethys_app=app, + name=name, + description=description, + required=required, + initializer=initializer, + initialized=initialized, + spatial=spatial, + dynamic=dynamic + ) + ps_database_setting.save() + with pretty_output(FG_GREEN) as p: + p.write('PersistentStoreDatabaseSetting named "{}" for app "{}" created successfully!'.format(name, + app_package)) + return True + except Exception as e: + print e + with pretty_output(FG_RED) as p: + p.write('The above error was encountered. Aborted.'.format(app_package)) + return False + + +def remove_ps_database_setting(app_package, name, force=False): + from cli.cli_colors import pretty_output, FG_RED, FG_GREEN + from tethys_apps.models import PersistentStoreDatabaseSetting + + try: + app = TethysApp.objects.get(package=app_package) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Tethys App with the name "{}" does not exist. Aborted.'.format(app_package)) + return False + + try: + setting = PersistentStoreDatabaseSetting.objects.get(tethys_app=app, name=name) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('An PersistentStoreDatabaseSetting with the name "{}" for app "{}" does not exist. Aborted.' + .format(name, app_package)) + return False + + if not force: + proceed = raw_input('Are you sure you want to delete the PersistentStoreDatabaseSetting named "{}"? [y/n]: ' + .format(name)) + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + setting.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed PersistentStoreDatabaseSetting with name "{0}"!'.format(name)) + return True + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. PersistentStoreDatabaseSetting not removed.') + else: + setting.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed PersistentStoreDatabaseSetting with name "{0}"!'.format(name)) + return True + + +def link_service_to_app_setting(service_type, service_uid, app_package, setting_type, setting_uid): + """ + Links a Tethys Service to a TethysAppSetting. + :param service_type: The type of service being linked to an app. Must be either 'spatial' or 'persistent'. + :param service_uid: The name or id of the service being linked to an app. + :param app_package: The package name of the app whose setting is being linked to a service. + :param setting_type: The type of setting being linked to a service. Must be one of the following: 'ps_database', + 'ps_connection', or 'ds_spatial'. + :param setting_uid: The name or id of the setting being linked to a service. + :return: True if successful, False otherwise. + """ + from cli.cli_colors import pretty_output, FG_GREEN, FG_RED + from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, + PersistentStoreDatabaseSetting) + from tethys_services.models import (SpatialDatasetService, PersistentStoreService) + + service_type_to_model_dict = { + 'spatial': SpatialDatasetService, + 'persistent': PersistentStoreService + } + + setting_type_to_link_model_dict = { + 'ps_database': { + 'setting_model': PersistentStoreDatabaseSetting, + 'service_field': 'persistent_store_service' + }, + 'ps_connection': { + 'setting_model': PersistentStoreConnectionSetting, + 'service_field': 'persistent_store_service' + }, + 'ds_spatial': { + 'setting_model': SpatialDatasetServiceSetting, + 'service_field': 'spatial_dataset_service' + } + } + + service_model = service_type_to_model_dict[service_type] + + try: + try: + service_uid = int(service_uid) + service = service_model.objects.get(pk=service_uid) + except ValueError: + service = service_model.objects.get(name=service_uid) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(service_model), service_uid)) + return False + + try: + app = TethysApp.objects.get(package=app_package) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Tethys App with the name "{}" does not exist. Aborted.'.format(app_package)) + return False + + try: + linked_setting_model_dict = setting_type_to_link_model_dict[setting_type] + except KeyError: + with pretty_output(FG_RED) as p: + p.write('The setting_type you specified ("{0}") does not exist.' + '\nChoose from: "ps_database|ps_connection|ds_spatial"'.format(setting_type)) + return False + + linked_setting_model = linked_setting_model_dict['setting_model'] + linked_service_field = linked_setting_model_dict['service_field'] + + try: + try: + setting_uid = int(setting_uid) + setting = linked_setting_model.objects.get(tethys_app=app, pk=setting_uid) + except ValueError: + setting = linked_setting_model.objects.get(tethys_app=app, name=setting_uid) + + setattr(setting, linked_service_field, service) + setting.save() + with pretty_output(FG_GREEN) as p: + p.write('{} with name "{}" was successfully linked to "{}" with name "{}" of the "{}" Tethys App' + .format(str(service_model), service_uid, linked_setting_model, setting_uid, app_package)) + return True + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(linked_setting_model), setting_uid)) + return False From 8254a087b0a21578f371e5f67b49f7e42d35bc72 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Thu, 28 Sep 2017 12:08:45 -0600 Subject: [PATCH 036/215] recent changes --- Dockerfile | 25 +- docker/install_tethys.sh | 478 +++------------------------------------ docker/setup_tethys.sh | 422 ++++++++++------------------------ 3 files changed, 157 insertions(+), 768 deletions(-) diff --git a/Dockerfile b/Dockerfile index f5793ce86..ef189d2a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,17 +17,15 @@ ADD . /usr/lib/tethys/src # Run Scripts to Get Files RUN pwd \ && apt-get update \ - && apt-get install -y wget bzip2 git \ + && apt-get --assume-yes install wget bzip2 git nginx \ && bash install_tethys.sh \ --python-version 2 \ --tethys-home /usr/lib/tethys \ - --conda-home /usr/lib/tethys/miniconda \ --conda-env-name tethys \ && mkdir /usr/lib/tethys/workspaces VOLUME ["/usr/lib/tethys/workspaces"] - ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh ADD aquaveo_static/images/aquaveo_favicon.ico /usr/lib/tethys/src/static/tethys_portal/images/default_favicon.png ADD aquaveo_static/images/aquaveo_logo.png /usr/lib/tethys/src/static/tethys_portal/images/tethys-logo-75.png @@ -40,23 +38,4 @@ EXPOSE 80 ENV PATH ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda}/envs/tethys/bin:$PATH # Install Tethys -CMD echo Stating Tethys Setup \ - && bash setup_tethys.sh \ - -b ${TETHYSBUILD_BRANCH:-release} \ - --allowed-host ${TETHYSBUILD_ALLOWED_HOST:-127.0.0.1} \ - --python-version ${TETHYSBUILD_PY_VERSION:-2} \ - --db-username ${TETHYSBUILD_DB_USERNAME:-tethys_default} \ - --db-password ${TETHYSBUILD_DB_PASSWORD:-pass} \ - --db-host ${TETHYSBUILD_DB_HOST:-127.0.0.1} \ - --db-port ${TETHYSBUILD_DB_PORT:-5432} \ - --db-create ${TETHYSBUILD_DB_CREATE:-0} \ - --superuser ${TETHYSBUILD_SUPERUSER:-tethys_super} \ - --superuser-pass ${TETHYSBUILD_SUPERUSER_PASS:-admin} \ - --tethys-home ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys} \ - --conda-home ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda} \ - --production \ - && echo Setup Complete \ - && cd ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys}/src \ - && echo Source Directory: $PWD \ - && echo Starting Tethys on ${TETHYSBUILD_ALLOWED_HOST:-0.0.0.0}:${TETHYSBUILD_HOST_PORT:-8000} \ - && tethys manage start -p ${TETHYSBUILD_DOCKER_IP:-0.0.0.0}:${TETHYSBUILD_HOST_PORT:-8000} +CMD echo Error: Not a Standalone Docker diff --git a/docker/install_tethys.sh b/docker/install_tethys.sh index 0ca8a3f66..899d5becc 100644 --- a/docker/install_tethys.sh +++ b/docker/install_tethys.sh @@ -4,23 +4,7 @@ USAGE="USAGE: . install_tethys.sh [options]\n \n OPTIONS:\n -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n - -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n - -p, --port Port on which to serve tethys. Default is 8000.\n - -b, --branch Branch to checkout from version control. Default is 'release'.\n - -c, --conda-home Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n - -n, --conda-env-name Name for tethys conda environment. Default is 'tethys'. --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n - --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n - --db-password Password that the tethys database server will use. Default is 'pass'.\n - --db-port Port that the tethys database server will use. Default is 5436.\n - -S, --superuser Tethys super user name. Default is 'admin'.\n - -E, --superuser-email Tethys super user email. Default is ''.\n - -P, --superuser-pass Tethys super user password. Default is 'pass'.\n - --skip-tethys-install Flag to skip the Tethys installation so that the Docker installation or production installation can be added to an existing Tethys installation.\n - --install-docker Flag to include Docker installation as part of the install script (Linux only).\n - --docker-options Command line options to pass to the 'tethys docker init' call if --install-docker is used. Default is \"'-d'\".\n - --production Flag to install Tethys in a production configuration.\n - --configure-selinux Flag to perform configuration of SELinux for production installation. (Linux only).\n -x Flag to turn on shell command echoing.\n -h, --help Print this help information.\n " @@ -31,51 +15,23 @@ print_usage () exit } set -e # exit on error - -# Set platform specific default options -if [ "$(uname)" = "Linux" ] -then - # LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") - # convert to lower case - # LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} - MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" - BASH_PROFILE=".bashrc" - resolve_relative_path () - { - local __path_var="$1" - eval $__path_var="'$(readlink -f $2)'" - } -elif [ "$(uname)" = "Darwin" ] # i.e. MacOSX -then - MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh" - BASH_PROFILE=".bash_profile" - resolve_relative_path () - { - local __path_var="$1" - eval $__path_var="'$(python -c "import os; print(os.path.abspath('$2'))")'" - } -else - echo $(uname) is not a supported operating system. - exit -fi +LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") +# convert to lower case +LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} +MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" +BASH_PROFILE=".bashrc" +resolve_relative_path () +{ + local __path_var="$1" + eval $__path_var="'$(readlink -f $2)'" +} # Set default options -ALLOWED_HOST='127.0.0.1' -TETHYS_REPO='https://github.com/tethysplatform/tethys.git' TETHYS_HOME=~/tethys -TETHYS_PORT=8000 -TETHYS_DB_USERNAME='tethys_default' -TETHYS_DB_PASSWORD='pass' -TETHYS_DB_PORT=5436 +TETHYS_PORT=80 CONDA_ENV_NAME='tethys' PYTHON_VERSION='2' -BRANCH='release' - -TETHYS_SUPER_USER='admin' -TETHYS_SUPER_USER_EMAIL='' -TETHYS_SUPER_USER_PASS='pass' -DOCKER_OPTIONS='-d' # parse command line options set_option_value () @@ -97,26 +53,6 @@ case $key in set_option_value TETHYS_HOME "$2" shift # past argument ;; - -a|--allowed-host) - set_option_value ALLOWED_HOST "$2" - shift # past argument - ;; - -p|--port) - set_option_value TETHYS_PORT "$2" - shift # past argument - ;; - --tethys-repo) - set_option_value TETHYS_REPO "$2" - shift # past argument - ;; - -b|--branch) - set_option_value BRANCH "$2" - shift # past argument - ;; - -c|--conda-home) - set_option_value CONDA_HOME "$2" - shift # past argument - ;; -n|--conda-env-name) set_option_value CONDA_ENV_NAME "$2" shift # past argument @@ -125,61 +61,6 @@ case $key in set_option_value PYTHON_VERSION "$2" shift # past argument ;; - --db-username) - set_option_value TETHYS_DB_USERNAME "$2" - shift # past argument - ;; - --db-password) - set_option_value TETHYS_DB_PASS "$2" - shift # past argument - ;; - --db-port) - set_option_value TETHYS_DB_PORT "$2" - shift # past argument - ;; - -S|--superuser) - set_option_value TETHYS_SUPER_USER "$2" - shift # past argument - ;; - -E|--superuser-email) - set_option_value TETHYS_SUPER_USER_EMAIL "$2" - shift # past argument - ;; - -P|--superuser-pass) - set_option_value TETHYS_SUPER_USER_PASS "$2" - shift # past argument - ;; - --skip-tethys-install) - SKIP_TETHYS_INSTALL="true" - ;; - --install-docker) - if [ "$(uname)" = "Linux" ] - then - INSTALL_DOCKER="true" - else - echo Automatic installation of Docker is not supported on $(uname). Ignoring option $key. - fi - ;; - --docker-options) - set_option_value DOCKER_OPTIONS "$2" - shift # past argument - ;; - --production) - if [ "$(uname)" = "Linux" ] - then - PRODUCTION="true" - else - echo Automatic production installation is not supported on $(uname). Ignoring option $key. - fi - ;; - --configure-selinux) - if [ "$(uname)" = "Linux" ] - then - SELINUX="true" - else - echo SELinux confiuration is not supported on $(uname). Ignoring option $key. - fi - ;; -x) ECHO_COMMANDS="true" ;; @@ -194,15 +75,9 @@ shift # past argument or value done # resolve relative paths +CONDA_HOME="${TETHYS_HOME}/miniconda" resolve_relative_path TETHYS_HOME ${TETHYS_HOME} - -# set CONDA_HOME relative to TETHYS_HOME if not already set -if [ -z ${CONDA_HOME} ] -then - CONDA_HOME="${TETHYS_HOME}/miniconda" -else - resolve_relative_path CONDA_HOME ${CONDA_HOME} -fi +resolve_relative_path CONDA_HOME ${CONDA_HOME} @@ -211,6 +86,7 @@ then set -x # echo commands as they are executed fi + # Set paths for environment activate/deactivate scripts ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" @@ -218,329 +94,35 @@ ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" -if [ -z "${SKIP_TETHYS_INSTALL}" ] -then - echo "Starting Tethys Installation..." - - mkdir -p "${TETHYS_HOME}" - - # install miniconda - # first see if Miniconda is already installed - if [ -f "${CONDA_HOME}/bin/activate" ] - then - echo "Using existing Miniconda installation..." - else - echo "Installing Miniconda..." - wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" || (echo -using curl instead; curl ${MINICONDA_URL} -o "${TETHYS_HOME}/miniconda.sh") - pushd ./ - cd "${TETHYS_HOME}" - bash miniconda.sh -b -p "${CONDA_HOME}" - popd - fi - export PATH="${CONDA_HOME}/bin:$PATH" - - # clone Tethys repo - echo "Cloning the Tethys Platform repo..." - conda install --yes git - # git clone ${TETHYS_REPO} "${TETHYS_HOME}/src" - cd "${TETHYS_HOME}/src" - # git checkout ${BRANCH} - - # create conda env and install Tethys - echo "Setting up the ${CONDA_ENV_NAME} environment..." - conda env create -n ${CONDA_ENV_NAME} -f "environment_py${PYTHON_VERSION}.yml" - . activate ${CONDA_ENV_NAME} - python setup.py develop - - # only pass --allowed-hosts option to gen settings command if it is not the default - # if [ ${ALLOWED_HOST} != "127.0.0.1" ] - # then - # ALLOWED_HOST_OPT="--allowed-host ${ALLOWED_HOST}" - # fi - # tethys gen settings ${ALLOWED_HOST_OPT} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} +echo "Starting Tethys Installation..." - # Setup local database - # echo "Setting up the Tethys database..." - # initdb -U postgres -D "${TETHYS_HOME}/psql/data" - # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" - # echo "Waiting for databases to startup..."; sleep 10 - # psql -U postgres -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" - # createdb -U postgres -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 +mkdir -p "${TETHYS_HOME}" - # Initialze Tethys database - # tethys manage syncdb - # echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell - # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" stop - # . deactivate +# Install Miniconda +echo "Installing Miniconda..." +wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" || (echo -using curl instead; curl ${MINICONDA_URL} -o "${TETHYS_HOME}/miniconda.sh") +pushd ./ +cd "${TETHYS_HOME}" +bash miniconda.sh -b -p "${CONDA_HOME}" +popd +export PATH="${CONDA_HOME}/bin:$PATH" - # Create environment activate/deactivate scripts - # mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" +cd "${TETHYS_HOME}/src" - # echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" - # echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" - # echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" - # echo "export CONDA_HOME='${CONDA_HOME}'" >> "${ACTIVATE_SCRIPT}" - # echo "export CONDA_ENV_NAME='${CONDA_ENV_NAME}'" >> "${ACTIVATE_SCRIPT}" - # echo "alias tethys_start_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" -l \"\${TETHYS_HOME}/psql/logfile\" start -o \"-p \${TETHYS_DB_PORT}\"'" >> "${ACTIVATE_SCRIPT}" - # echo "alias tstartdb=tethys_start_db" >> "${ACTIVATE_SCRIPT}" - # echo "alias tethys_stop_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" stop'" >> "${ACTIVATE_SCRIPT}" - # echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" - # echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" - # echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" +# create conda env and install Tethys +echo "Setting up the ${CONDA_ENV_NAME} environment..." +conda env create -n ${CONDA_ENV_NAME} -f "environment_py${PYTHON_VERSION}.yml" +. activate ${CONDA_ENV_NAME} - # echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" - # echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" - # echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" - # echo "unset CONDA_HOME" >> "${DEACTIVATE_SCRIPT}" - # echo "unset CONDA_ENV_NAME" >> "${DEACTIVATE_SCRIPT}" - # echo "unalias tethys_start_db" >> "${DEACTIVATE_SCRIPT}" - # echo "unalias tstartdb" >> "${DEACTIVATE_SCRIPT}" - # echo "unalias tethys_stop_db" >> "${DEACTIVATE_SCRIPT}" - # echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" - # echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" - # echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" +python setup.py develop - # echo "# Tethys Platform" >> ~/${BASH_PROFILE} - # echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} -fi - -# Install Docker (if flag is set) set +e # don't exit on error anymore # Rename some variables for reference after deactivating tethys environment. TETHYS_CONDA_HOME=${CONDA_HOME} TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} -# Install Production configuration if flag is set - -ubuntu_debian_production_install() { - sudo apt update - sudo apt install -y nginx - sudo rm /etc/nginx/sites-enabled/default - NGINX_SITES_DIR='sites-enabled' -} - -enterprise_linux_production_install() { - sudo yum install nginx -y - sudo systemctl enable nginx - sudo systemctl start nginx - sudo firewall-cmd --permanent --zone=public --add-service=http -# sudo firewall-cmd --permanent --zone=public --add-service=https - sudo firewall-cmd --reload - - NGINX_SITES_DIR='conf.d' -} - -redhat_production_install() { - VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") - sudo bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/rhel/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" - enterprise_linux_production_install -} - -centos_production_install() { - PLATFORM=${LINUX_DISTRIBUTION} - VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") - sudo bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/${PLATFORM}/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" - sudo yum install epel-release -y - enterprise_linux_production_install -} - -configure_selinux() { - sudo yum install setroubleshoot -y - sudo semanage fcontext -a -t httpd_config_t ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf - sudo restorecon -v ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf - sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}(/.*)?" - sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}/static(/.*)?" - sudo semanage fcontext -a -t httpd_sys_rw_content_t "${TETHYS_HOME}/workspaces(/.*)?" - sudo restorecon -R -v ${TETHYS_HOME} > /dev/null - echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te - - checkmodule -M -m -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te - semodule_package -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp -m ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod - sudo semodule -i ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp -} - -if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] -then - # prompt for sudo - echo "Production installation requires some commands to be run with sudo. Please enter password:" - sudo echo "Installing Tethys Production Server..." - - case ${LINUX_DISTRIBUTION} in - debian) - ubuntu_debian_production_install - ;; - ubuntu) - ubuntu_debian_production_install - ;; - centos) - centos_production_install - ;; - redhat) - redhat_production_install - ;; - fedora) - enterprise_linux_production_install - ;; - *) - echo "Automated production installation on ${LINUX_DISTRIBUTION} is not supported." - ;; - esac - - - . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} - pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" - echo "Waiting for databases to startup..."; sleep 5 - conda install -c conda-forge uwsgi -y - tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite - tethys gen nginx --overwrite - tethys gen uwsgi_settings --overwrite - tethys gen uwsgi_service --overwrite - NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') - NGINX_GROUP=${NGINX_USER} - NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') - mkdir -p ${TETHYS_HOME}/static ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps - sudo chown -R ${USER} ${TETHYS_HOME} - tethys manage collectall --noinput - sudo chmod 705 ~ - sudo mkdir /var/log/uwsgi - sudo touch /var/log/uwsgi/tethys.log - sudo ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ - - if [ -n "${SELINUX}" ] - then - configure_selinux - fi - - sudo chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log - sudo systemctl enable ${TETHYS_HOME}/src/tethys_portal/tethys.uwsgi.service - sudo systemctl start tethys.uwsgi.service - sudo systemctl restart nginx - set +x - . deactivate - - echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" - echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" - echo "alias tuo=tethys_user_own" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_server_own='sudo chown -R \${NGINX_USER}:\${NGINX_USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" - echo "alias tso=tethys_server_own" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_server_restart='tso; sudo systemctl restart tethys.uwsgi.service; sudo systemctl restart nginx'" >> "${ACTIVATE_SCRIPT}" - echo "alias tsr=tethys_server_restart" >> "${ACTIVATE_SCRIPT}" - - echo "unset NGINX_USER" >> "${DEACTIVATE_SCRIPT}" - echo "unset NGINX_HOME" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tethys_user_own" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tuo" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tethys_server_own" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tso" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tethys_server_restart" >> "${DEACTIVATE_SCRIPT}" - echo "unalias trs" >> "${DEACTIVATE_SCRIPT}" -fi - - -# Install Docker (if flag is set - -installation_warning(){ - echo "WARNING: installing docker on $1 is not officially supported by the Tethys install script. Attempting to install with $2 script." -} - -finalize_docker_install(){ - sudo groupadd docker - sudo gpasswd -a ${USER} docker - . ${TETHYS_CONDA_HOME}/bin/activate ${TETHYS_CONDA_ENV_NAME} - sg docker -c "tethys docker init ${DOCKER_OPTIONS}" - . deactivate - echo "Docker installation finished!" - echo "You must re-login for Docker permissions to be activated." - echo "(Alternatively you can run 'newgrp docker')" -} - -ubuntu_debian_docker_install(){ - if [ "${LINUX_DISTRIBUTION}" != "ubuntu" ] && [ ${LINUX_DISTRIBUTION} != "debian" ] - then - installation_warning ${LINUX_DISTRIBUTION} "Ubuntu" - fi - - sudo apt-get update - sudo apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common - curl -fsSL https://download.docker.com/linux/${LINUX_DISTRIBUTION}/gpg | sudo apt-key add - - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/${LINUX_DISTRIBUTION} $(lsb_release -cs) stable" - sudo apt-get update - sudo apt-get install -y docker-ce - - finalize_docker_install -} - -centos_docker_install(){ - if [ "${LINUX_DISTRIBUTION}" != "centos" ] - then - installation_warning ${LINUX_DISTRIBUTION} "CentOS" - fi - - sudo yum -y install yum-utils - sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo - sudo yum makecache fast - sudo yum -y install docker-ce - sudo systemctl start docker - sudo systemctl enable docker - - finalize_docker_install -} - -fedora_docker_install(){ - if [ "${LINUX_DISTRIBUTION}" != "fedora" ] - then - installation_warning ${LINUX_DISTRIBUTION} "Fedora" - fi - - sudo dnf -y install -y dnf-plugins-core - sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo - sudo dnf makecache fast - sudo dnf -y install docker-ce - sudo systemctl start docker - sudo systemctl enable docker - - finalize_docker_install -} - -if [ -n "${LINUX_DISTRIBUTION}" -a "${INSTALL_DOCKER}" = "true" ] -then - # prompt for sudo - echo "Docker installation requires some commands to be run with sudo. Please enter password:" - sudo echo "Installing Docker..." - - case ${LINUX_DISTRIBUTION} in - debian) - ubuntu_debian_docker_install - ;; - ubuntu) - ubuntu_debian_docker_install - ;; - centos) - centos_docker_install - ;; - redhat) - centos_docker_install - ;; - fedora) - fedora_docker_install - ;; - *) - echo "Automated Docker installation on ${LINUX_DISTRIBUTION} is not supported. Please see https://docs.docker.com/engine/installation/ for more information on installing Docker." - ;; - esac -fi - - -if [ -z "${SKIP_TETHYS_INSTALL}" ] -then - echo "Tethys installation complete!" - echo - echo "NOTE: to enable the new alias 't' which activates the tethys environment you must run '. ~/${BASH_PROFILE}'" -fi - on_exit(){ set +e set +x diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index 660c8e473..1ba0d4143 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -6,9 +6,6 @@ OPTIONS:\n -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n -p, --port Port on which to serve tethys. Default is 8000.\n - -b, --branch Branch to checkout from version control. Default is 'release'.\n - -c, --conda-home Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n - -n, --conda-env-name Name for tethys conda environment. Default is 'tethys'. --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n --db-password Password that the tethys database server will use. Default is 'pass'.\n @@ -16,11 +13,6 @@ OPTIONS:\n -S, --superuser Tethys super user name. Default is 'admin'.\n -E, --superuser-email Tethys super user email. Default is ''.\n -P, --superuser-pass Tethys super user password. Default is 'pass'.\n - --skip-tethys-install Flag to skip the Tethys installation so that the Docker installation or production installation can be added to an existing Tethys installation.\n - --install-docker Flag to include Docker installation as part of the install script (Linux only).\n - --docker-options Command line options to pass to the 'tethys docker init' call if --install-docker is used. Default is \"'-d'\".\n - --production Flag to install Tethys in a production configuration.\n - --configure-selinux Flag to perform configuration of SELinux for production installation. (Linux only).\n -x Flag to turn on shell command echoing.\n -h, --help Print this help information.\n " @@ -33,52 +25,34 @@ print_usage () set -e # exit on error # Set platform specific default options -if [ "$(uname)" = "Linux" ] -then - LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") - # convert to lower case - echo "Linux Distribution: ${LINUX_DISTRIBUTION}" - LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} - MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" - BASH_PROFILE=".bashrc" - resolve_relative_path () - { - local __path_var="$1" - eval $__path_var="'$(readlink -f $2)'" - } -elif [ "$(uname)" = "Darwin" ] # i.e. MacOSX -then - MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh" - BASH_PROFILE=".bash_profile" - resolve_relative_path () - { - local __path_var="$1" - eval $__path_var="'$(python -c "import os; print(os.path.abspath('$2'))")'" - } -else - echo $(uname) is not a supported operating system. - exit -fi +LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") +# convert to lower case +echo "Linux Distribution: ${LINUX_DISTRIBUTION}" +LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} +MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" +BASH_PROFILE=".bashrc" +resolve_relative_path () +{ + local __path_var="$1" + eval $__path_var="'$(readlink -f $2)'" +} + # Set default options ALLOWED_HOST='127.0.0.1' TETHYS_HOME=~/tethys -TETHYS_PORT=8000 +TETHYS_PORT=80 TETHYS_DB_USERNAME='tethys_default' TETHYS_DB_PASSWORD='pass' -TETHYS_DB_HOST='127.0.0.1' -TETHYS_DB_PORT=5436 -TETHYS_DB_CREATE=0 +TETHYS_DB_HOST='172.17.0.1' +TETHYS_DB_PORT=5432 CONDA_ENV_NAME='tethys' PYTHON_VERSION='2' -BRANCH='release' TETHYS_SUPER_USER='admin' TETHYS_SUPER_USER_EMAIL='' TETHYS_SUPER_USER_PASS='pass' -DOCKER_OPTIONS='-d' - # parse command line options set_option_value () { @@ -107,18 +81,6 @@ case $key in set_option_value TETHYS_PORT "$2" shift # past argument ;; - -b|--branch) - set_option_value BRANCH "$2" - shift # past argument - ;; - -c|--conda-home) - set_option_value CONDA_HOME "$2" - shift # past argument - ;; - -n|--conda-env-name) - set_option_value CONDA_ENV_NAME "$2" - shift # past argument - ;; --python-version) set_option_value PYTHON_VERSION "$2" shift # past argument @@ -128,7 +90,7 @@ case $key in shift # past argument ;; --db-password) - set_option_value TETHYS_DB_PASS "$2" + set_option_value TETHYS_DB_PASSWORD "$2" shift # past argument ;; --db-port) @@ -139,10 +101,6 @@ case $key in set_option_value TETHYS_DB_HOST "$2" shift # past argument ;; - --db-create) - set_option_value TETHYS_DB_CREATE "$2" - shift # past argument - ;; -S|--superuser) set_option_value TETHYS_SUPER_USER "$2" shift # past argument @@ -155,37 +113,6 @@ case $key in set_option_value TETHYS_SUPER_USER_PASS "$2" shift # past argument ;; - --skip-tethys-install) - SKIP_TETHYS_INSTALL="true" - ;; - --install-docker) - if [ "$(uname)" = "Linux" ] - then - INSTALL_DOCKER="true" - else - echo Automatic installation of Docker is not supported on $(uname). Ignoring option $key. - fi - ;; - --docker-options) - set_option_value DOCKER_OPTIONS "$2" - shift # past argument - ;; - --production) - if [ "$(uname)" = "Linux" ] - then - PRODUCTION="true" - else - echo Automatic production installation is not supported on $(uname). Ignoring option $key. - fi - ;; - --configure-selinux) - if [ "$(uname)" = "Linux" ] - then - SELINUX="true" - else - echo SELinux confiuration is not supported on $(uname). Ignoring option $key. - fi - ;; -x) ECHO_COMMANDS="true" ;; @@ -200,16 +127,9 @@ shift # past argument or value done # resolve relative paths +CONDA_HOME="${TETHYS_HOME}/miniconda" resolve_relative_path TETHYS_HOME ${TETHYS_HOME} - -# set CONDA_HOME relative to TETHYS_HOME if not already set -if [ -z ${CONDA_HOME} ] -then - CONDA_HOME="${TETHYS_HOME}/miniconda" -else - resolve_relative_path CONDA_HOME ${CONDA_HOME} -fi - +resolve_relative_path CONDA_HOME ${CONDA_HOME} if [ -n "${ECHO_COMMANDS}" ] @@ -217,6 +137,7 @@ then set -x # echo commands as they are executed fi + # Set paths for environment activate/deactivate scripts ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" @@ -224,217 +145,124 @@ ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" -if [ -z "${SKIP_TETHYS_INSTALL}" ] -then - echo "Starting Tethys Setup..." - . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} - tethys gen settings ${ALLOWED_HOST_OPT} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} - sed -i -e "s/'HOST': '127.0.0.1',/'HOST': '${TETHYSBUILD_DB_HOST}',/g" /usr/lib/tethys/src/tethys_portal/settings.py - sed -i -e 's/BYPASS_TETHYS_HOME = False/BYPASS_TETHYS_HOME = True/g' /usr/lib/tethys/src/tethys_portal/settings.py - sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" /usr/lib/tethys/src/tethys_portal/settings.py - # sed -i -e "s/127.0.0.1/${TETHYS_DB_HOST}/g" /usr/lib/tethys/src/tethys_portal/settings.py - # Setup local database - echo "Setting up the Tethys database..." - # initdb -U postgres -D "${TETHYS_HOME}/psql/data" - # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" - echo "Waiting for databases to startup..."; sleep 30 - if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1 && echo $?) -ne 0 ]]; then - # if [[ "${TETHYS_DB_CREATE}" -ne '0' ]]; then - echo "Creating DB User and Password" - psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" - createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 - fi - - # Initialze Tethys database - cd /usr/lib/tethys/src - tethys manage syncdb - if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1 && echo $?) -ne 0 ]]; then - # if [[ "${TETHYS_DB_CREATE}" -ne '0' ]]; then - echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell - fi - # pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" stop - . deactivate - - - # Create environment activate/deactivate scripts - mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" - - echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" - echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" - echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" - echo "export CONDA_HOME='${CONDA_HOME}'" >> "${ACTIVATE_SCRIPT}" - echo "export CONDA_ENV_NAME='${CONDA_ENV_NAME}'" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_start_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" -l \"\${TETHYS_HOME}/psql/logfile\" start -o \"-p \${TETHYS_DB_PORT}\"'" >> "${ACTIVATE_SCRIPT}" - echo "alias tstartdb=tethys_start_db" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_stop_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" stop'" >> "${ACTIVATE_SCRIPT}" - echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" - echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" - echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" - - echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" - echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" - echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" - echo "unset CONDA_HOME" >> "${DEACTIVATE_SCRIPT}" - echo "unset CONDA_ENV_NAME" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tethys_start_db" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tstartdb" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tethys_stop_db" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" - - echo "# Tethys Platform" >> ~/${BASH_PROFILE} - echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} -fi - -# Install Docker (if flag is set) -set +e # don't exit on error anymore - # Rename some variables for reference after deactivating tethys environment. TETHYS_CONDA_HOME=${CONDA_HOME} TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} -# Install Production configuration if flag is set - -ubuntu_debian_production_install() { - apt update - apt install -y nginx - rm /etc/nginx/sites-enabled/default - NGINX_SITES_DIR='sites-enabled' -} - -enterprise_linux_production_install() { - yum install nginx -y - systemctl enable nginx - systemctl start nginx - firewall-cmd --permanent --zone=public --add-service=http -# firewall-cmd --permanent --zone=public --add-service=https - firewall-cmd --reload - - NGINX_SITES_DIR='conf.d' -} - -redhat_production_install() { - VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") - bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/rhel/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" - enterprise_linux_production_install -} -centos_production_install() { - PLATFORM=${LINUX_DISTRIBUTION} - VERSION=$(python -c "import platform; print(platform.platform().split('-')[-2][0])") - bash -c "echo $'[nginx]\nname=nginx repo\nbaseurl=http://nginx.org/packages/${PLATFORM}/${VERSION}/\$basearch/\ngpgcheck=0\nenabled=1' > /etc/yum.repos.d/nginx.repo" - yum install epel-release -y - enterprise_linux_production_install -} - -configure_selinux() { - yum install setroubleshoot -y - semanage fcontext -a -t httpd_config_t ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf - restorecon -v ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf - semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}(/.*)?" - semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}/static(/.*)?" - semanage fcontext -a -t httpd_sys_rw_content_t "${TETHYS_HOME}/workspaces(/.*)?" - restorecon -R -v ${TETHYS_HOME} > /dev/null - echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te - - checkmodule -M -m -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te - semodule_package -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp -m ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod - semodule -i ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp -} - -if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] -then - # prompt for sudo - echo "Installing Tethys Production Server..." - - case ${LINUX_DISTRIBUTION} in - debian) - ubuntu_debian_production_install - ;; - ubuntu) - ubuntu_debian_production_install - ;; - centos) - centos_production_install - ;; - redhat) - redhat_production_install - ;; - fedora) - enterprise_linux_production_install - ;; - *) - echo "Automated production installation on ${LINUX_DISTRIBUTION} is not supported." - ;; - esac - - - . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} - pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" - echo "Waiting for databases to startup..."; sleep 5 - conda install -c conda-forge uwsgi -y - tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite - tethys gen nginx --overwrite - tethys gen uwsgi_settings --overwrite - tethys gen uwsgi_service --overwrite - NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') - NGINX_GROUP=${NGINX_USER} - NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') - mkdir -p ${TETHYS_HOME}/static ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps - chown -R ${USER} ${TETHYS_HOME} - tethys manage collectall --noinput - chmod 705 ~ - mkdir /var/log/uwsgi - touch /var/log/uwsgi/tethys.log - ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ - - if [ -n "${SELINUX}" ] - then - configure_selinux - fi - - chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log - systemctl enable ${TETHYS_HOME}/src/tethys_portal/tethys.uwsgi.service - systemctl start tethys.uwsgi.service - systemctl restart nginx - set +x - . deactivate - - echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" - echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" - echo "alias tuo=tethys_user_own" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_server_own='sudo chown -R \${NGINX_USER}:\${NGINX_USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" - echo "alias tso=tethys_server_own" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_server_restart='tso; sudo systemctl restart tethys.uwsgi.service; sudo systemctl restart nginx'" >> "${ACTIVATE_SCRIPT}" - echo "alias tsr=tethys_server_restart" >> "${ACTIVATE_SCRIPT}" - - echo "unset NGINX_USER" >> "${DEACTIVATE_SCRIPT}" - echo "unset NGINX_HOME" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tethys_user_own" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tuo" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tethys_server_own" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tso" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tethys_server_restart" >> "${DEACTIVATE_SCRIPT}" - echo "unalias trs" >> "${DEACTIVATE_SCRIPT}" +# prompt for sudo +echo "Installing Tethys Production Server..." + +# NGINEX +rm /etc/nginx/sites-enabled/default +NGINX_SITES_DIR='sites-enabled' + +# Create environment activate/deactivate scripts +mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" + +echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" +echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" +echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" +echo "export CONDA_HOME='${CONDA_HOME}'" >> "${ACTIVATE_SCRIPT}" +echo "export CONDA_ENV_NAME='${CONDA_ENV_NAME}'" >> "${ACTIVATE_SCRIPT}" +echo "alias tethys_start_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" -l \"\${TETHYS_HOME}/psql/logfile\" start -o \"-p \${TETHYS_DB_PORT}\"'" >> "${ACTIVATE_SCRIPT}" +echo "alias tstartdb=tethys_start_db" >> "${ACTIVATE_SCRIPT}" +echo "alias tethys_stop_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" stop'" >> "${ACTIVATE_SCRIPT}" +echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" +echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" +echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" + +echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" +echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" +echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" +echo "unset CONDA_HOME" >> "${DEACTIVATE_SCRIPT}" +echo "unset CONDA_ENV_NAME" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tethys_start_db" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tstartdb" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tethys_stop_db" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" + +echo "# Tethys Platform" >> ~/${BASH_PROFILE} +echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} + +echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" +echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" +echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" +echo "alias tuo=tethys_user_own" >> "${ACTIVATE_SCRIPT}" +echo "alias tethys_server_own='sudo chown -R \${NGINX_USER}:\${NGINX_USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" +echo "alias tso=tethys_server_own" >> "${ACTIVATE_SCRIPT}" +echo "alias tethys_server_restart='tso; sudo systemctl restart tethys.uwsgi.service; sudo systemctl restart nginx'" >> "${ACTIVATE_SCRIPT}" +echo "alias tsr=tethys_server_restart" >> "${ACTIVATE_SCRIPT}" + +echo "unset NGINX_USER" >> "${DEACTIVATE_SCRIPT}" +echo "unset NGINX_HOME" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tethys_user_own" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tuo" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tethys_server_own" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tso" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tethys_server_restart" >> "${DEACTIVATE_SCRIPT}" +echo "unalias tsr" >> "${DEACTIVATE_SCRIPT}" + + +# ACTIVATE +. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} + +# INSTALL REQUIREMENTS +conda install -c conda-forge uwsgi -y + +# GEN SETTINGS +tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite +sed -i -e "s/'HOST': '127.0.0.1',/'HOST': '${TETHYSBUILD_DB_HOST}',/g" /usr/lib/tethys/src/tethys_portal/settings.py +sed -i -e 's/BYPASS_TETHYS_HOME_PAGE = False/BYPASS_TETHYS_HOME_PAGE = True/g' /usr/lib/tethys/src/tethys_portal/settings.py +sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" /usr/lib/tethys/src/tethys_portal/settings.py + +# DB ROLLS/ETC +if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command ""; echo $?) -ne 0 ]]; then # Check if postgres has a password + echo "default postgres user has a password set, assuming database is setup correctly..." + tethys manage syncdb +else + if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1; echo $?) -ne 0 ]]; then + echo "Creating DB User and Password" + cd /usr/lib/tethys/src + psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" + createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 + tethys manage syncdb + echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" + echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell + cd /usr/lib/tethys + else + tethys manage syncdb + fi fi +# NGINX AND UWSGI +tethys gen nginx --overwrite +tethys gen uwsgi_settings --overwrite +tethys gen uwsgi_service --overwrite +NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') +NGINX_GROUP=${NGINX_USER} +NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') +chmod 705 ~ +mkdir /var/log/uwsgi +touch /var/log/uwsgi/tethys.log +ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ +chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log -# Install Docker (if flag is set +# STATIC FILES AND WORKSPACES +mkdir -p ${TETHYS_HOME}/static ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps +tethys manage collectall --noinput +chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME} -installation_warning(){ - echo "WARNING: installing docker on $1 is not officially supported by the Tethys install script. Attempting to install with $2 script." -} +# ECHOING +set +x +# DEACTIVATE +. deactivate -if [ -z "${SKIP_TETHYS_INSTALL}" ] -then - echo "Tethys installation complete!" - echo - echo "NOTE: to enable the new alias 't' which activates the tethys environment you must run '. ~/${BASH_PROFILE}'" -fi +# EXIT on_exit(){ set +e set +x From 94d901e8b7611baded2816975e0dada89ba2cbe9 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 21 Sep 2017 13:23:03 -0600 Subject: [PATCH 037/215] fully implemented --- tethys_apps/cli/__init__.py | 6 +- tethys_apps/cli/manage_commands.py | 5 +- .../management/commands/collectworkspaces.py | 70 ++++++++++++------- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index fec4d3223..918d9ef08 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -229,10 +229,14 @@ def tethys_command(): # Setup start server command manage_parser = subparsers.add_parser('manage', help='Management commands for Tethys Platform.') manage_parser.add_argument('command', help='Management command to run.', - choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_COLLECT, MANAGE_CREATESUPERUSER]) + choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, + MANAGE_COLLECT, MANAGE_CREATESUPERUSER]) manage_parser.add_argument('-m', '--manage', help='Absolute path to manage.py for Tethys Platform installation.') manage_parser.add_argument('-p', '--port', type=str, help='Host and/or port on which to bind the development server.') manage_parser.add_argument('--noinput', action='store_true', help='Pass the --noinput argument to the manage.py command.') + manage_parser.add_argument('-f', '--force', required=False, action='store_true', + help='Used only with {} to force the overwrite the app directory into its collect-to ' + 'location.') manage_parser.set_defaults(func=manage_command) # Setup services command diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index ee747db4c..fde3cd513 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -78,7 +78,10 @@ def manage_command(args): elif args.command == MANAGE_COLLECTWORKSPACES: # Run collectworkspaces command - primary_process = ['python', manage_path, 'collectworkspaces'] + if args.force: + primary_process = ['python', manage_path, 'collectworkspaces', '--force'] + else: + primary_process = ['python', manage_path, 'collectworkspaces'] elif args.command == MANAGE_COLLECT: # Convenience command to run collectstatic and collectworkspaces diff --git a/tethys_apps/management/commands/collectworkspaces.py b/tethys_apps/management/commands/collectworkspaces.py index 77b100a28..fc3da9949 100644 --- a/tethys_apps/management/commands/collectworkspaces.py +++ b/tethys_apps/management/commands/collectworkspaces.py @@ -18,18 +18,26 @@ class Command(BaseCommand): """ - Command class that handles the syncstores command. Provides persistent store management functionality. + Command class that handles the collectworkspaces command. """ + def add_arguments(self, parser): + parser.add_argument('-f', '--force', action='store_true', default=False, + help='Force the overwrite the app directory into its collected-to location.') + def handle(self, *args, **options): """ - Symbolically link the static directories of each app into the static/public directory specified by the STATIC_ROOT - parameter of the settings.py. Do this prior to running Django's collectstatic method. + Symbolically link the static directories of each app into the static/public directory specified by the + STATIC_ROOT parameter of the settings.py. Do this prior to running Django's collectstatic method. """ - if not hasattr(settings, 'TETHYS_WORKSPACES_ROOT') or (hasattr(settings, 'TETHYS_WORKSPACES_ROOT') and not settings.TETHYS_WORKSPACES_ROOT): + if not hasattr(settings, 'TETHYS_WORKSPACES_ROOT') or (hasattr(settings, 'TETHYS_WORKSPACES_ROOT') + and not settings.TETHYS_WORKSPACES_ROOT): print('WARNING: Cannot find the TETHYS_WORKSPACES_ROOT setting in the settings.py file. ' - 'Please provide the path to the static directory using the TETHYS_WORKSPACES_ROOT setting and try again.') + 'Please provide the path to the static directory using the TETHYS_WORKSPACES_ROOT ' + 'setting and try again.') exit(1) + # Get optional force arg + force = options['force'] # Read settings workspaces_root = settings.TETHYS_WORKSPACES_ROOT @@ -42,28 +50,40 @@ def handle(self, *args, **options): for app, path in installed_apps.items(): # Check for both variants of the static directory (public and static) - workspaces_path = os.path.join(path, 'workspaces') - workspaces_root_path = os.path.join(workspaces_root, app) + app_ws_path = os.path.join(path, 'workspaces') + tethys_ws_root_path = os.path.join(workspaces_root, app) # Only perform if workspaces_path is a directory - if os.path.isdir(workspaces_path) and not os.path.islink(workspaces_path): - # Clear out old symbolic links/directories in workspace root if necessary - try: - # Remove link - os.remove(workspaces_root_path) - except OSError: - try: - # Remove directory - shutil.rmtree(workspaces_root_path) - except OSError: - # No file - pass + if not os.path.isdir(app_ws_path): + print 'WARNING: The workspace_path for app "{}" is not a directory. Skipping...'.format(app) + continue - # Move the directory to workspace root path - shutil.move(workspaces_path, workspaces_root_path) + if not os.path.islink(app_ws_path): + if not os.path.exists(tethys_ws_root_path): + # Move the directory to workspace root path + shutil.move(app_ws_path, tethys_ws_root_path) + else: + if force: + # Clear out old symbolic links/directories in workspace root if necessary + try: + # Remove link + os.remove(tethys_ws_root_path) + except OSError: + shutil.rmtree(tethys_ws_root_path, ignore_errors=True) - # Create appropriate symbolic link - if os.path.isdir(workspaces_root_path): - os.symlink(workspaces_root_path, workspaces_path) - print('INFO: Successfully linked "workspaces" directory to TETHYS_WORKSPACES_ROOT for app "{0}".'.format(app)) + # Move the directory to workspace root path + shutil.move(app_ws_path, tethys_ws_root_path) + else: + print('WARNING: Workspace directory for app "{}" already exists in the TETHYS_WORKSPACES_ROOT ' + 'directory. A symbolic link is being created to the existing directory. To force overwrite ' + 'the existing directory, re-run the command with the "-f" argument.'.format(app)) + shutil.rmtree(app_ws_path, ignore_errors=True) + # Create appropriate symbolic link + if os.path.isdir(tethys_ws_root_path): + os.symlink(tethys_ws_root_path, app_ws_path) + print('INFO: Successfully linked "workspaces" directory to TETHYS_WORKSPACES_ROOT for app ' + '"{0}".'.format(app)) + else: + print('WARNING: Workspace directory for app "{}" is already symbolically linked to another directory ' + 'within the TETHYS_WORKSPACES_ROOT directory. Skipping... '.format(app)) From aaac80e3064b19e05a979e125c394f7693ff822e Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 21 Sep 2017 13:57:30 -0600 Subject: [PATCH 038/215] fix bug with global model imports --- tethys_apps/cli/app_settings_commands.py | 17 ++++---- tethys_apps/cli/link_commands.py | 49 ++++++++++++------------ tethys_apps/cli/services_commands.py | 6 ++- 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index d00117473..e606f7cd4 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -1,19 +1,20 @@ from cli_helpers import console_superuser_required from django.core.exceptions import ObjectDoesNotExist -from tethys_apps.models import (TethysApp, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, - SpatialDatasetServiceSetting) from .cli_colors import * -setting_type_dict = { - PersistentStoreConnectionSetting: 'ps_connection', - PersistentStoreDatabaseSetting: 'ps_database', - SpatialDatasetServiceSetting: 'ds_spatial' -} - @console_superuser_required def app_settings_list_command(args): + from tethys_apps.models import (TethysApp, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, + SpatialDatasetServiceSetting) + + setting_type_dict = { + PersistentStoreConnectionSetting: 'ps_connection', + PersistentStoreDatabaseSetting: 'ps_database', + SpatialDatasetServiceSetting: 'ds_spatial' + } + app_package = args.app try: app = TethysApp.objects.get(package=app_package) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index 239013b3c..f46ea8a38 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -1,38 +1,39 @@ from django.core.exceptions import ObjectDoesNotExist -from tethys_apps.models import TethysApp -from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, - PersistentStoreDatabaseSetting) -from tethys_services.models import (SpatialDatasetService, PersistentStoreService) from .cli_colors import * from .cli_helpers import console_superuser_required -service_type_to_model_dict = { - 'spatial': SpatialDatasetService, - 'persistent': PersistentStoreService -} - -setting_type_to_link_model_dict = { - 'ps_database': { - 'setting_model': PersistentStoreDatabaseSetting, - 'service_field': 'persistent_store_service' - }, - 'ps_connection': { - 'setting_model': PersistentStoreConnectionSetting, - 'service_field': 'persistent_store_service' - }, - 'ds_spatial': { - 'setting_model': SpatialDatasetServiceSetting, - 'service_field': 'spatial_dataset_service' - } -} - @console_superuser_required def link_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_apps.models import TethysApp + from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, + PersistentStoreDatabaseSetting) + from tethys_services.models import (SpatialDatasetService, PersistentStoreService) + + service_type_to_model_dict = { + 'spatial': SpatialDatasetService, + 'persistent': PersistentStoreService + } + + setting_type_to_link_model_dict = { + 'ps_database': { + 'setting_model': PersistentStoreDatabaseSetting, + 'service_field': 'persistent_store_service' + }, + 'ps_connection': { + 'setting_model': PersistentStoreConnectionSetting, + 'service_field': 'persistent_store_service' + }, + 'ds_spatial': { + 'setting_model': SpatialDatasetServiceSetting, + 'service_field': 'spatial_dataset_service' + } + } + try: service = args.service setting = args.setting diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 81bc42a2f..4d33c05b1 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -1,7 +1,6 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.utils import IntegrityError from django.forms.models import model_to_dict -from tethys_services.models import SpatialDatasetService, PersistentStoreService from .cli_colors import * from .cli_helpers import console_superuser_required, add_geoserver_rest_to_endpoint @@ -23,6 +22,7 @@ def services_create_persistent_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_services.models import PersistentStoreService name = None try: @@ -52,6 +52,7 @@ def services_create_persistent_command(args): @console_superuser_required def services_remove_persistent_command(args): + from tethys_services.models import PersistentStoreService persistent_service_id = None try: @@ -84,6 +85,7 @@ def services_create_spatial_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_services.models import SpatialDatasetService name = None try: @@ -128,6 +130,7 @@ def services_create_spatial_command(args): @console_superuser_required def services_remove_spatial_command(args): + from tethys_services.models import SpatialDatasetService spatial_service_id = None try: @@ -160,6 +163,7 @@ def services_list_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_services.models import SpatialDatasetService, PersistentStoreService list_persistent = False list_spatial = False From 8ff05d16385cce3c73ba0e19f4c2382453805547 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 25 Sep 2017 10:49:06 -0600 Subject: [PATCH 039/215] remove unnecessary authentication from tethys commands --- tethys_apps/cli/__init__.py | 25 ---------------- tethys_apps/cli/app_settings_commands.py | 2 -- tethys_apps/cli/cli_helpers.py | 36 +----------------------- tethys_apps/cli/link_commands.py | 2 -- tethys_apps/cli/services_commands.py | 7 +---- 5 files changed, 2 insertions(+), 70 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index 918d9ef08..b095d764a 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -251,21 +251,12 @@ def tethys_command(): services_remove_persistent = services_remove_subparsers.add_parser('persistent', help='Remove a Persistent Store Service.') services_remove_persistent.add_argument('service_id', help='The ID of the service that you are removing.') - services_remove_persistent.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_remove_persistent.set_defaults(func=services_remove_persistent_command) # REMOVE SPATIAL SERVICE COMMAND services_remove_spatial = services_remove_subparsers.add_parser('spatial', help='Remove a Spatial Dataset Service.') services_remove_spatial.add_argument('service_id', help='The ID of the service that you are removing.') - services_remove_spatial.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') - services_remove_spatial.set_defaults(func=services_remove_spatial_command) # SERVICES CREATE COMMANDS @@ -279,10 +270,6 @@ def tethys_command(): services_create_ps.add_argument('-c', '--connection', required=True, type=str, help='The connection of the Service in the form ' '":@:"') - services_create_ps.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_create_ps.set_defaults(func=services_create_persistent_command) # CREATE SPATIAL DATASET SERVICE COMMAND @@ -297,10 +284,6 @@ def tethys_command(): '--connection argument, of the form ":"') services_create_sd.add_argument('-k', '--apikey', required=False, type=str, help='The API key, if any, required to establish a connection.') - services_create_sd.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_create_sd.set_defaults(func=services_create_spatial_command) # LIST SERVICES COMMAND @@ -308,10 +291,6 @@ def tethys_command(): group = services_list_parser.add_mutually_exclusive_group() group.add_argument('-p', '--persistent', action='store_true', help='Only list Persistent Store Services.') group.add_argument('-s', '--spatial', action='store_true', help='Only list Spatial Dataset Services.') - services_list_parser.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_list_parser.set_defaults(func=services_list_command) # Setup app_settings command @@ -319,10 +298,6 @@ def tethys_command(): app_settings_subparsers = app_settings_parser.add_subparsers(title='Options') app_settings_list_parser = app_settings_subparsers.add_parser('list', help='List all settings for a specified app') app_settings_list_parser.add_argument('app', help='The app ("") to list the Settings for.') - app_settings_list_parser.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') app_settings_list_parser.set_defaults(func=app_settings_list_command) # Setup link command diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index e606f7cd4..e572b9094 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -1,10 +1,8 @@ -from cli_helpers import console_superuser_required from django.core.exceptions import ObjectDoesNotExist from .cli_colors import * -@console_superuser_required def app_settings_list_command(args): from tethys_apps.models import (TethysApp, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, SpatialDatasetServiceSetting) diff --git a/tethys_apps/cli/cli_helpers.py b/tethys_apps/cli/cli_helpers.py index d91eed05e..693e68778 100644 --- a/tethys_apps/cli/cli_helpers.py +++ b/tethys_apps/cli/cli_helpers.py @@ -1,37 +1,3 @@ -from getpass import getpass -from django.contrib.auth import authenticate -from .cli_colors import * - - -def console_superuser_required(func): - def _wrapped(args): - credentials = args.authenticate - username = None - password = None - - if credentials: - cred_parts = credentials.split(':') - if len(cred_parts) > 0: - username = cred_parts[0] - if len(cred_parts) > 1: - password = cred_parts[1] - if not username: - username = raw_input('username: ') - if not password: - password = getpass('password: ') - user = authenticate(username=username, password=password) - if not user: - with pretty_output(FG_RED) as p: - p.write('The username or password provided was incorrect. Command aborted.') - exit(1) - if not user.is_superuser: - with pretty_output(FG_RED) as p: - p.write('You are not authorized to perform this action.') - exit(1) - - return func(args) - - return _wrapped def add_geoserver_rest_to_endpoint(endpoint): @@ -42,4 +8,4 @@ def add_geoserver_rest_to_endpoint(endpoint): port_and_path = parts2[1] port = port_and_path.split('/')[0] - return '{0}//{1}:{2}/geoserver/rest/'.format(protocol, host, port) \ No newline at end of file + return '{0}//{1}:{2}/geoserver/rest/'.format(protocol, host, port) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index f46ea8a38..b4d176a44 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -1,10 +1,8 @@ from django.core.exceptions import ObjectDoesNotExist from .cli_colors import * -from .cli_helpers import console_superuser_required -@console_superuser_required def link_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 4d33c05b1..2967136e1 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -3,7 +3,7 @@ from django.forms.models import model_to_dict from .cli_colors import * -from .cli_helpers import console_superuser_required, add_geoserver_rest_to_endpoint +from .cli_helpers import add_geoserver_rest_to_endpoint SERVICES_CREATE = 'create' SERVICES_CREATE_PERSISTENT = 'persistent' @@ -17,7 +17,6 @@ def __init__(self): Exception.__init__(self) -@console_superuser_required def services_create_persistent_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps @@ -50,7 +49,6 @@ def services_create_persistent_command(args): p.write('Persistent Store Service with name "{0}" already exists. Command aborted.'.format(name)) -@console_superuser_required def services_remove_persistent_command(args): from tethys_services.models import PersistentStoreService persistent_service_id = None @@ -80,7 +78,6 @@ def services_remove_persistent_command(args): p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(persistent_service_id)) -@console_superuser_required def services_create_spatial_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps @@ -128,7 +125,6 @@ def services_create_spatial_command(args): p.write('Spatial Dataset Service with name "{0}" already exists. Command aborted.'.format(name)) -@console_superuser_required def services_remove_spatial_command(args): from tethys_services.models import SpatialDatasetService spatial_service_id = None @@ -158,7 +154,6 @@ def services_remove_spatial_command(args): p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(spatial_service_id)) -@console_superuser_required def services_list_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps From 061cf5db357a995711fdb6efdef7050d05a5e280 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 26 Sep 2017 13:56:12 -0600 Subject: [PATCH 040/215] command to sync apps fully implemented --- tethys_apps/cli/__init__.py | 6 +++--- tethys_apps/cli/manage_commands.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index b095d764a..73776e14f 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -21,7 +21,7 @@ from .gen_commands import GEN_SETTINGS_OPTION, GEN_APACHE_OPTION, generate_command from .manage_commands import (manage_command, get_manage_path, run_process, MANAGE_START, MANAGE_SYNCDB, - MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, + MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_SYNCAPPS, MANAGE_COLLECT, MANAGE_CREATESUPERUSER, TETHYS_SRC_DIRECTORY) from .services_commands import (SERVICES_CREATE, SERVICES_CREATE_PERSISTENT, SERVICES_CREATE_SPATIAL, SERVICES_LINK, services_create_persistent_command, services_create_spatial_command, @@ -230,7 +230,7 @@ def tethys_command(): manage_parser = subparsers.add_parser('manage', help='Management commands for Tethys Platform.') manage_parser.add_argument('command', help='Management command to run.', choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, - MANAGE_COLLECT, MANAGE_CREATESUPERUSER]) + MANAGE_COLLECT, MANAGE_CREATESUPERUSER, MANAGE_SYNCAPPS]) manage_parser.add_argument('-m', '--manage', help='Absolute path to manage.py for Tethys Platform installation.') manage_parser.add_argument('-p', '--port', type=str, help='Host and/or port on which to bind the development server.') manage_parser.add_argument('--noinput', action='store_true', help='Pass the --noinput argument to the manage.py command.') @@ -308,7 +308,7 @@ def tethys_command(): '":" ' '(i.e. "persistent_connection:super_conn")') link_parser.add_argument('setting', help='Setting of an app with which to link the specified service.' - 'Of the form ":' + 'Of the form "::' '" (i.e. "epanet:database:epanet_2")') link_parser.set_defaults(func=link_command) diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index fde3cd513..e228b6b37 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -23,6 +23,7 @@ MANAGE_COLLECTWORKSPACES = 'collectworkspaces' MANAGE_COLLECT = 'collectall' MANAGE_CREATESUPERUSER = 'createsuperuser' +MANAGE_SYNCAPPS = 'syncapps' def get_manage_path(args): @@ -102,7 +103,9 @@ def manage_command(args): elif args.command == MANAGE_CREATESUPERUSER: primary_process = ['python', manage_path, 'createsuperuser'] - + elif args.command == MANAGE_SYNCAPPS: + from tethys_apps.utilities import sync_tethys_app_db + sync_tethys_app_db() if primary_process: run_process(primary_process) From c8d7101bc33e2e86cba7ce45798d87356791efa7 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 26 Sep 2017 16:06:50 -0600 Subject: [PATCH 041/215] added scheduler commands; added documentation for all recent cli cmds --- docs/tethys_sdk/tethys_cli.rst | 156 +++++++++++++++++++++++++- tethys_apps/cli/__init__.py | 42 ++++++- tethys_apps/cli/scheduler_commands.py | 73 ++++++++++++ 3 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 tethys_apps/cli/scheduler_commands.py diff --git a/docs/tethys_sdk/tethys_cli.rst b/docs/tethys_sdk/tethys_cli.rst index 6d6b10b92..a15035fa4 100644 --- a/docs/tethys_sdk/tethys_cli.rst +++ b/docs/tethys_sdk/tethys_cli.rst @@ -87,8 +87,9 @@ This command contains several subcommands that are used to help manage Tethys Pl * **subcommand**: The management command to run. Either "start", "syncdb", or "collectstatic". - * *start*: Starts the Django development server. Wrapper for ``manage.py runserver``. + * *start*: Start the Django development server. Wrapper for ``manage.py runserver``. * *syncdb*: Initialize the database during installation. Wrapper for ``manage.py syncdb``. + * *syncapps*: Sync installed apps with the TethysApp database. * *collectstatic*: Link app static/public directories to STATIC_ROOT directory and then run Django's collectstatic command. Preprocessor and wrapper for ``manage.py collectstatic``. * *collectworkspaces*: Link app workspace directories to TETHYS_WORKSPACES_ROOT directory. * *collectall*: Convenience command for running both *collectstatic* and *collectworkspaces*. @@ -110,6 +111,9 @@ This command contains several subcommands that are used to help manage Tethys Pl # Sync the database $ tethys manage syncdb + # Sync installed apps with the TethysApp database. + $ tethys manage syncapps + # Collect static files $ tethys manage collectstatic @@ -285,3 +289,153 @@ Management commands for running tests for Tethys Platform and Tethys Apps. See : # Run tests for a single app tethys test -f tethys_apps.tethysapp.my_first_app + + +.. _tethys_cli_app_settings: + +app_settings +--------------- + +This command is used to list the Persistent Store and Spatial Dataset Settings that an app has requested. + +**Arguments:** + +* **app_name**: Name of app for which Settings will be listed + +**Optional Arguments:** + +* **-p --persistent**: A flag indicating that only Persistent Store Settings should be listed +* **-s --spatial**: A flag indicating that only Spatial Dataset Settings should be listed + +**Examples:** + +:: + + $ tethys app_settings my_first_app + +.. _tethys_cli_services: + +services [ | options] +--------------- + +This command is used to interact with Tethys Services from the command line, rather than the App Admin interface. + +**Arguments:** + +* **subcommand**: The services command to run. One of the following: + + * *list*: List all existing Tethys Services (Persistent Store and Spatial Dataset Services) + * *create*: Create a new Tethys Service + * **subcommand**: The service type to create + * *persistent*: Create a new Persistent Store Service + **Arguments:** + + * **-n, --name**: A unique name to identify the service being created + * **-c, --connection**: The connection endpoint associated with this service, in the form ":@:" + * *spatial*: Create a new Spatial Dataset Service + **Arguments:** + + * **-n, --name**: A unique name to identify the service being created + * **-c, --connection**: The connection endpoint associated with this service, in the form ":@//:" + + **Optional Arguments:** + + * **-p, --public-endpoint**: The public-facing endpoint of the Service, if different than what was provided with the "--connection" argument, in the form "//:". + * **-k, --apikey**: The API key, if any, required to establish a connection. + * *remove*: Remove a Tethys Service + * **subcommand**: The service type to remove + * *persistent*: Remove a Persistent Store Service + **Arguments:** + * **service_uid**: A unique identifier of the Service to be removed, which can either be the database ID, or the service name + * *spatial*: Remove a Spatial Dataset Service + **Arguments:** + * **service_uid**: A unique identifier of the Service to be removed, which can either be the database ID, or the service name + +**Examples:** + +:: + + # List all Tethys Services + $ tethys services list + + # List only Spatial Dataset Tethys Services + $ tethys services list -s + + # List only Persistent Store Tethys Services + $ tethys services list -p + + # Create a new Spatial Dataset Tethys Service + + $ tethys services create spatial -n my_spatial_service -c my_username:my_password@http://127.0.0.1:8081 -p https://mypublicdomain.com -k mysecretapikey + + # Create a new Persistent Store Tethys Service + $ tethys services create persistent -n my_persistent_service -c my_username:my_password@http://127.0.0.1:8081 + + # Remove a Spatial Dataset Tethys Service + $ tethys services remove my_spatial_service + + # Remove a Persistent Store Tethys Service + $ tethys services remove my_persistent_service + +.. _tethys_cli_link: + +link +--------------- + +This command is used to link a Tethys Service with a TethysApp Setting + +**Arguments:** + +* **service_identifier**: An identifier of the Tethys Service being linked, of the form ":", where can be either "spatial" or "persistent", and must be either the database ID or name of the Tethys Service. +* **app_setting_identifier**: An identifier of the TethysApp Setting being linked, of the form "::", where must be one of "ds_spatial," "ps_connection", or "ps_database" and can be either the database ID or name of the TethysApp Setting. + +**Examples:** + +:: + + # Link a Persistent Store Service to a Persistent Store Connection Setting + $ tethys link persistent:my_persistent_service my_first_app:ps_connection:my_ps_connection + + # Link a Persistent Store Service to a Persistent Store Database Setting + $ tethys link persistent:my_persistent_service my_first_app:ps_database:my_ps_connection + + # Link a Spatial Dataset Service to a Spatial Dataset Service Setting + $ tethys link spatial:my_spatial_service my_first_app:ds_spatial:my_spatial_connection + +.. _tethys_cli_schedulers: + +schedulers +--------------- + +This command is used to interact with Schedulers from the command line, rather than through the App Admin interface + +**Arguments:** + +* **subcommand**: The schedulers command to run. One of the following: + + * *list*: List all existing Schedulers + * *create*: Create a new Scheduler + **Arguments:** + * **-n, --name**: A unique name to identify the Scheduler being created + * **-d, --endpoint**: The endpoint of the remote host the Scheduler will connect with in the form //" + * **-u, --username**: The username that will be used to connect to the remote endpoint" + **Optional Arguments:** + * **-p, --password**: The password associated with the username (required if "-f (--private-key-path)" not specified. + * **-f, --private-key-path**: The path to the private ssh key file (required if "-p (--password)" not specified. + * **-k, --private-key-pass**: The password to the private ssh key file (only meaningful if "-f (--private-key-path)" is specified. + * *remove*: Remove a Scheduler + **Arguments:** + * **scheduler_name**: The unique name of the Scheduler being removed. + +**Examples:** + +:: + + # List all Schedulers + $ tethys schedulers list + + # Create a new scheduler + $ tethys schedulers create -n my_scheduler -e http://127.0.0.1 -u my_username -p my_password + + # Remove a scheduler + $ tethys schedulers remove my_scheduler diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index 73776e14f..eeed30d07 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -29,6 +29,7 @@ services_remove_spatial_command) from .link_commands import link_command from .app_settings_commands import app_settings_list_command +from .scheduler_commands import scheduler_create_command, schedulers_list_command, schedulers_remove_command from .gen_commands import VALID_GEN_OBJECTS, generate_command from tethys_apps.helpers import get_installed_tethys_apps @@ -239,6 +240,37 @@ def tethys_command(): 'location.') manage_parser.set_defaults(func=manage_command) + # Setup scheduler command + scheduler_parser = subparsers.add_parser('schedulers', help='Scheduler commands for Tethys Platform.') + scheduler_subparsers = scheduler_parser.add_subparsers(title='Commands') + + # SCHEDULERS CREATE COMMAND + schedulers_create = scheduler_subparsers.add_parser('create', help='Create a Scheduler that can be ' + 'accessed by Tethys Apps.') + schedulers_create.add_argument('-n', '--name', required=True, help='A unique name for the Scheduler', type=str) + schedulers_create.add_argument('-e', '--endpoint', required=True, type=str, + help='The endpoint of the service in the form //"') + schedulers_create.add_argument('-u', '--username', required=True, help='The username to connect to the host with', + type=str) + group = schedulers_create.add_mutually_exclusive_group(required=True) + group.add_argument('-p', '--password', required=False, type=str, + help='The password associated with the provided username') + group.add_argument('-f', '--private-key-path', required=False, help='The path to the private ssh key file', + type=str) + schedulers_create.add_argument('-k', '--private-key-pass', required=False, type=str, + help='The password to the private ssh key file') + + schedulers_create.set_defaults(func=scheduler_create_command) + + # SCHEDULERS LIST COMMAND + schedulers_list = scheduler_subparsers.add_parser('list', help='List the existing Schedulers.') + schedulers_list.set_defaults(func=schedulers_list_command) + + # SCHEDULERS REMOVE COMMAND + schedulers_remove = scheduler_subparsers.add_parser('remove', help='Remove a Scheduler.') + schedulers_remove.add_argument('scheduler_name', help='The unique name of the Scheduler that you are removing.') + schedulers_remove.set_defaults(func=schedulers_remove_command) + # Setup services command services_parser = subparsers.add_parser('services', help='Services commands for Tethys Platform.') services_subparsers = services_parser.add_subparsers(title='Commands') @@ -250,13 +282,15 @@ def tethys_command(): # REMOVE PERSISTENT SERVICE COMMAND services_remove_persistent = services_remove_subparsers.add_parser('persistent', help='Remove a Persistent Store Service.') - services_remove_persistent.add_argument('service_id', help='The ID of the service that you are removing.') + services_remove_persistent.add_argument('service_uid', help='The ID or name of the Persistent Store Service ' + 'that you are removing.') services_remove_persistent.set_defaults(func=services_remove_persistent_command) # REMOVE SPATIAL SERVICE COMMAND services_remove_spatial = services_remove_subparsers.add_parser('spatial', help='Remove a Spatial Dataset Service.') - services_remove_spatial.add_argument('service_id', help='The ID of the service that you are removing.') + services_remove_spatial.add_argument('service_uid', help='The ID or name of the Spatial Dataset Service ' + 'that you are removing.') services_remove_spatial.set_defaults(func=services_remove_spatial_command) # SERVICES CREATE COMMANDS @@ -266,7 +300,7 @@ def tethys_command(): # CREATE PERSISTENT STORE SERVICE COMMAND services_create_ps = services_create_subparsers.add_parser('persistent', help='Create a Persistent Store Service.') - services_create_ps.add_argument('-n', '--name', required=True, help='The name of the Service', type=str) + services_create_ps.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) services_create_ps.add_argument('-c', '--connection', required=True, type=str, help='The connection of the Service in the form ' '":@:"') @@ -275,7 +309,7 @@ def tethys_command(): # CREATE SPATIAL DATASET SERVICE COMMAND services_create_sd = services_create_subparsers.add_parser('spatial', help='Create a Spatial Dataset Service.') - services_create_sd.add_argument('-n', '--name', required=True, help='The name of the Service', type=str) + services_create_sd.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) services_create_sd.add_argument('-c', '--connection', required=True, type=str, help='The connection of the Service in the form ' '":@//:"') diff --git a/tethys_apps/cli/scheduler_commands.py b/tethys_apps/cli/scheduler_commands.py new file mode 100644 index 000000000..fc44704b4 --- /dev/null +++ b/tethys_apps/cli/scheduler_commands.py @@ -0,0 +1,73 @@ +from .cli_colors import * +from django.core.exceptions import ObjectDoesNotExist + + +def scheduler_create_command(args): + from tethys_compute.models import Scheduler + + name = args.name + host = args.endpoint + username = args.username + password = args.password + private_key_path = args.private_key_path + private_key_pass = args.private_key_pass + + scheduler = Scheduler( + name=name, + host=host, + username=username, + password=password, + private_key_path=private_key_path, + private_key_pass=private_key_pass + ) + + scheduler.save() + + with pretty_output(FG_GREEN) as p: + p.write('Scheduler created successfully!') + exit(0) + + +def schedulers_list_command(args): + from tethys_compute.models import Scheduler + schedulers = Scheduler.objects.all() + + num_schedulers = len(schedulers) + + if num_schedulers > 0: + with pretty_output(BOLD) as p: + p.write('{0: <30}{1: <25}{2: <10}{3: <10}{4: <50}{5: <10}'.format( + 'Name', 'Host', 'Username', 'Password', 'Private Key Path', 'Private Key Pass' + )) + for scheduler in schedulers: + p.write('{0: <30}{1: <25}{2: <10}{3: <10}{4: <50}{5: <10}'.format( + scheduler.name, scheduler.host, scheduler.username, '******' if scheduler.password else 'None', + scheduler.private_key_path, '******' if scheduler.private_key_pass else 'None' + )) + else: + with pretty_output(BOLD) as p: + p.write('There are no Schedulers registered in Tethys.') + + +def schedulers_remove_command(args): + from tethys_compute.models import Scheduler + scheduler = None + name = args.scheduler_name + + try: + scheduler = Scheduler.objects.get(name=name) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('Scheduler with name "{}" does not exist.\nCommand aborted.'.format(name)) + + proceed = raw_input('Are you sure you want to delete this Scheduler? [y/n]: ') + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + scheduler.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed Scheduler "{0}"!'.format(name)) + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. Scheduler not removed.') From 9d2c01d9025e8b0cccd86138215174835dba4d4f Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 28 Sep 2017 12:01:27 -0600 Subject: [PATCH 042/215] add app_settings_create/remove commands --- tethys_apps/cli/__init__.py | 79 +++++++--- tethys_apps/cli/app_settings_commands.py | 33 +++++ tethys_apps/cli/link_commands.py | 79 +--------- tethys_apps/utilities.py | 174 ++++++++++++++++++++++- 4 files changed, 268 insertions(+), 97 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index eeed30d07..f106dc4ab 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -28,7 +28,8 @@ services_list_command, services_remove_persistent_command, services_remove_spatial_command) from .link_commands import link_command -from .app_settings_commands import app_settings_list_command +from .app_settings_commands import (app_settings_list_command, app_settings_create_ps_database_command, + app_settings_remove_command) from .scheduler_commands import scheduler_create_command, schedulers_list_command, schedulers_remove_command from .gen_commands import VALID_GEN_OBJECTS, generate_command from tethys_apps.helpers import get_installed_tethys_apps @@ -240,64 +241,63 @@ def tethys_command(): 'location.') manage_parser.set_defaults(func=manage_command) - # Setup scheduler command + # SCHEDULERS COMMANDS scheduler_parser = subparsers.add_parser('schedulers', help='Scheduler commands for Tethys Platform.') scheduler_subparsers = scheduler_parser.add_subparsers(title='Commands') - # SCHEDULERS CREATE COMMAND + # tethys schedulers create schedulers_create = scheduler_subparsers.add_parser('create', help='Create a Scheduler that can be ' - 'accessed by Tethys Apps.') + 'accessed by Tethys Apps.') schedulers_create.add_argument('-n', '--name', required=True, help='A unique name for the Scheduler', type=str) schedulers_create.add_argument('-e', '--endpoint', required=True, type=str, - help='The endpoint of the service in the form //"') + help='The endpoint of the service in the form //"') schedulers_create.add_argument('-u', '--username', required=True, help='The username to connect to the host with', - type=str) + type=str) group = schedulers_create.add_mutually_exclusive_group(required=True) group.add_argument('-p', '--password', required=False, type=str, help='The password associated with the provided username') group.add_argument('-f', '--private-key-path', required=False, help='The path to the private ssh key file', type=str) schedulers_create.add_argument('-k', '--private-key-pass', required=False, type=str, - help='The password to the private ssh key file') - + help='The password to the private ssh key file') schedulers_create.set_defaults(func=scheduler_create_command) - # SCHEDULERS LIST COMMAND + # tethys schedulers list schedulers_list = scheduler_subparsers.add_parser('list', help='List the existing Schedulers.') schedulers_list.set_defaults(func=schedulers_list_command) - # SCHEDULERS REMOVE COMMAND + # tethys schedulers remove schedulers_remove = scheduler_subparsers.add_parser('remove', help='Remove a Scheduler.') schedulers_remove.add_argument('scheduler_name', help='The unique name of the Scheduler that you are removing.') schedulers_remove.set_defaults(func=schedulers_remove_command) - # Setup services command + # SERVICES COMMANDS services_parser = subparsers.add_parser('services', help='Services commands for Tethys Platform.') services_subparsers = services_parser.add_subparsers(title='Commands') - # SERVICES REMOVE COMMANDS + # tethys services remove services_remove_parser = services_subparsers.add_parser('remove', help='Remove a Tethys Service.') services_remove_subparsers = services_remove_parser.add_subparsers(title='Service Type') - # REMOVE PERSISTENT SERVICE COMMAND + # tethys services remove persistent services_remove_persistent = services_remove_subparsers.add_parser('persistent', help='Remove a Persistent Store Service.') services_remove_persistent.add_argument('service_uid', help='The ID or name of the Persistent Store Service ' 'that you are removing.') services_remove_persistent.set_defaults(func=services_remove_persistent_command) - # REMOVE SPATIAL SERVICE COMMAND + # tethys services remove spatial services_remove_spatial = services_remove_subparsers.add_parser('spatial', help='Remove a Spatial Dataset Service.') services_remove_spatial.add_argument('service_uid', help='The ID or name of the Spatial Dataset Service ' 'that you are removing.') services_remove_spatial.set_defaults(func=services_remove_spatial_command) - # SERVICES CREATE COMMANDS + # tethys services create services_create_parser = services_subparsers.add_parser('create', help='Create a Tethys Service.') services_create_subparsers = services_create_parser.add_subparsers(title='Service Type') - # CREATE PERSISTENT STORE SERVICE COMMAND + # tethys services create persistent services_create_ps = services_create_subparsers.add_parser('persistent', help='Create a Persistent Store Service.') services_create_ps.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) @@ -306,7 +306,7 @@ def tethys_command(): '":@:"') services_create_ps.set_defaults(func=services_create_persistent_command) - # CREATE SPATIAL DATASET SERVICE COMMAND + # tethys services create spatial services_create_sd = services_create_subparsers.add_parser('spatial', help='Create a Spatial Dataset Service.') services_create_sd.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) @@ -320,24 +320,59 @@ def tethys_command(): help='The API key, if any, required to establish a connection.') services_create_sd.set_defaults(func=services_create_spatial_command) - # LIST SERVICES COMMAND + # tethys services list services_list_parser = services_subparsers.add_parser('list', help='List all existing Tethys Services.') group = services_list_parser.add_mutually_exclusive_group() group.add_argument('-p', '--persistent', action='store_true', help='Only list Persistent Store Services.') group.add_argument('-s', '--spatial', action='store_true', help='Only list Spatial Dataset Services.') services_list_parser.set_defaults(func=services_list_command) - # Setup app_settings command + # APP_SETTINGS COMMANDS app_settings_parser = subparsers.add_parser('app_settings', help='Interact with Tethys App Settings.') app_settings_subparsers = app_settings_parser.add_subparsers(title='Options') + + # tethys app_settings list app_settings_list_parser = app_settings_subparsers.add_parser('list', help='List all settings for a specified app') app_settings_list_parser.add_argument('app', help='The app ("") to list the Settings for.') app_settings_list_parser.set_defaults(func=app_settings_list_command) - # Setup link command - - # LINK SERVICE WITH APP COMMAND + # tethys app_settings create + app_settings_create_cmd = app_settings_subparsers.add_parser('create', help='Create a Setting for an app.') + + asc_subparsers = app_settings_create_cmd.add_subparsers(title='Create Options') + app_settings_create_cmd.add_argument('-a', '--app', required=True, + help='The app ("") to create the Setting for.') + app_settings_create_cmd.add_argument('-n', '--name', required=True, help='The name of the Setting to create.') + app_settings_create_cmd.add_argument('-d', '--description', required=False, + help='A description for the Setting to create.') + app_settings_create_cmd.add_argument('-r', '--required', required=False, action='store_true', + help='Include this flag if the Setting is required for the app.') + app_settings_create_cmd.add_argument('-i', '--initializer', required=False, + help='The function that initializes the PersistentStoreSetting database.') + app_settings_create_cmd.add_argument('-z', '--initialized', required=False, action='store_true', + help='Include this flag if the database is already initialized.') + + # tethys app_settings create ps_database + app_settings_create_psdb_cmd = asc_subparsers.add_parser('ps_database', + help='Create a PersistentStoreDatabaseSetting') + app_settings_create_psdb_cmd.add_argument('-s', '--spatial', required=False, action='store_true', + help='Include this flag if the database requires spatial capabilities.') + app_settings_create_psdb_cmd.add_argument('-y', '--dynamic', action='store_true', required=False, + help='Include this flag if the database should be considered to be ' + 'dynamically created.') + app_settings_create_psdb_cmd.set_defaults(func=app_settings_create_ps_database_command) + + # tethys app_settings remove + app_settings_remove_cmd = app_settings_subparsers.add_parser('remove', help='Remove a Setting for an app.') + app_settings_remove_cmd.add_argument('app', help='The app ("") to remove the Setting from.') + app_settings_remove_cmd.add_argument('-n', '--name', help='The name of the Setting to remove.', required=True) + app_settings_remove_cmd.add_argument('-f', '--force', action='store_true', help='Force removal without confirming.') + app_settings_remove_cmd.set_defaults(func=app_settings_remove_command) + + # LINK COMMANDS link_parser = subparsers.add_parser('link', help='Link a Service to a Tethys app Setting.') + + # tethys link link_parser.add_argument('service', help='Service to link to a target app. Of the form ' '":" ' '(i.e. "persistent_connection:super_conn")') diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index e572b9094..b86887b82 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -72,3 +72,36 @@ def app_settings_list_command(args): print e with pretty_output(FG_RED) as p: p.write('Something went wrong. Please try again.') + + +def app_settings_create_ps_database_command(args): + from ..utilities import create_ps_database_setting + app_package = args.app + setting_name = args.name + setting_description = args.description + required = args.required + initializer = args.initializer + initialized = args.initialized + spatial = args.spatial + dynamic = args.dynamic + + success = create_ps_database_setting(app_package, setting_name, setting_description or '', required, initializer or '', + initialized, spatial, dynamic) + + if not success: + exit(1) + + exit(0) + + +def app_settings_remove_command(args): + from ..utilities import remove_ps_database_setting + app_package = args.app + setting_name = args.name + force = args.force + success = remove_ps_database_setting(app_package, setting_name, force) + + if not success: + exit(1) + + exit(0) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index b4d176a44..a7dfe09dd 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -1,36 +1,9 @@ -from django.core.exceptions import ObjectDoesNotExist - -from .cli_colors import * - - def link_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ - from tethys_apps.models import TethysApp - from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, - PersistentStoreDatabaseSetting) - from tethys_services.models import (SpatialDatasetService, PersistentStoreService) - - service_type_to_model_dict = { - 'spatial': SpatialDatasetService, - 'persistent': PersistentStoreService - } - - setting_type_to_link_model_dict = { - 'ps_database': { - 'setting_model': PersistentStoreDatabaseSetting, - 'service_field': 'persistent_store_service' - }, - 'ps_connection': { - 'setting_model': PersistentStoreConnectionSetting, - 'service_field': 'persistent_store_service' - }, - 'ds_spatial': { - 'setting_model': SpatialDatasetServiceSetting, - 'service_field': 'spatial_dataset_service' - } - } + from ..utilities import link_service_to_app_setting + from .cli_colors import pretty_output, FG_RED try: service = args.service @@ -59,55 +32,15 @@ def link_command(args): '\nCommand aborted.') exit(1) - service_model = service_type_to_model_dict[service_type] + success = link_service_to_app_setting(service_type, service_uid, setting_app_package, setting_type, setting_uid) - try: - try: - service_uid = int(service_uid) - service = service_model.objects.get(pk=service_uid) - except ValueError: - service = service_model.objects.get(name=service_uid) - except ObjectDoesNotExist: - with pretty_output(FG_RED) as p: - p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(service_model), service_uid)) - exit(1) - - app = None - try: - app = TethysApp.objects.get(package=setting_app_package) - except ObjectDoesNotExist: - with pretty_output(FG_RED) as p: - p.write('The app you specified ("{0}") does not exist.'.format(setting_app_package)) + if not success: exit(1) - linked_setting_model_dict = None - try: - linked_setting_model_dict = setting_type_to_link_model_dict[setting_type] - except KeyError: - with pretty_output(FG_RED) as p: - p.write('The setting_type you specified ("{0}") does not exist.' - '\nChoose from: "ps_database|ps_connection|ds_spatial"'.format(setting_type)) - exit(1) - - linked_setting_model = linked_setting_model_dict['setting_model'] - linked_service_field = linked_setting_model_dict['service_field'] - - try: - try: - setting_uid = int(setting_uid) - setting = linked_setting_model.objects.get(tethys_app=app, pk=setting_uid) - except ValueError: - setting = linked_setting_model.objects.get(tethys_app=app, name=setting_uid) - except ObjectDoesNotExist: - with pretty_output(FG_RED) as p: - p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(linked_setting_model), setting_uid)) - exit(1) - - setattr(setting, linked_service_field, service) - - setting.save() + exit(0) except Exception as e: print e with pretty_output(FG_RED) as p: p.write('An unexpected error occurred. Please try again.') + exit(1) diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 73370aa03..2de4f34e9 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -317,8 +317,8 @@ def sync_tethys_app_db(): for installed_app in installed_apps: # Query to see if installed app is in the database - db_apps = TethysApp.objects.\ - filter(package__exact=installed_app.package).\ + db_apps = TethysApp.objects. \ + filter(package__exact=installed_app.package). \ all() # If the app is not in the database, then add it @@ -417,3 +417,173 @@ def get_active_app(request=None, url=None): except MultipleObjectsReturned: tethys_log.warning('Multiple apps found with root url "{0}".'.format(app_root_url)) return app + + +def create_ps_database_setting(app_package, name, description='', required=False, initializer='', initialized=False, + spatial=False, dynamic=False): + from cli.cli_colors import pretty_output, FG_RED, FG_GREEN + from tethys_apps.models import PersistentStoreDatabaseSetting + + try: + app = TethysApp.objects.get(package=app_package) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Tethys App with the name "{}" does not exist. Aborted.'.format(app_package)) + return False + + try: + setting = PersistentStoreDatabaseSetting.objects.get(name=name) + if setting: + with pretty_output(FG_RED) as p: + p.write('A PersistentStoreDatabaseSetting with name "{}" already exists. Aborted.'.format(name)) + return False + except ObjectDoesNotExist: + pass + + try: + ps_database_setting = PersistentStoreDatabaseSetting( + tethys_app=app, + name=name, + description=description, + required=required, + initializer=initializer, + initialized=initialized, + spatial=spatial, + dynamic=dynamic + ) + ps_database_setting.save() + with pretty_output(FG_GREEN) as p: + p.write('PersistentStoreDatabaseSetting named "{}" for app "{}" created successfully!'.format(name, + app_package)) + return True + except Exception as e: + print e + with pretty_output(FG_RED) as p: + p.write('The above error was encountered. Aborted.'.format(app_package)) + return False + + +def remove_ps_database_setting(app_package, name, force=False): + from cli.cli_colors import pretty_output, FG_RED, FG_GREEN + from tethys_apps.models import PersistentStoreDatabaseSetting + + try: + app = TethysApp.objects.get(package=app_package) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Tethys App with the name "{}" does not exist. Aborted.'.format(app_package)) + return False + + try: + setting = PersistentStoreDatabaseSetting.objects.get(tethys_app=app, name=name) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('An PersistentStoreDatabaseSetting with the name "{}" for app "{}" does not exist. Aborted.' + .format(name, app_package)) + return False + + if not force: + proceed = raw_input('Are you sure you want to delete the PersistentStoreDatabaseSetting named "{}"? [y/n]: ' + .format(name)) + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + setting.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed PersistentStoreDatabaseSetting with name "{0}"!'.format(name)) + return True + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. PersistentStoreDatabaseSetting not removed.') + else: + setting.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed PersistentStoreDatabaseSetting with name "{0}"!'.format(name)) + return True + + +def link_service_to_app_setting(service_type, service_uid, app_package, setting_type, setting_uid): + """ + Links a Tethys Service to a TethysAppSetting. + :param service_type: The type of service being linked to an app. Must be either 'spatial' or 'persistent'. + :param service_uid: The name or id of the service being linked to an app. + :param app_package: The package name of the app whose setting is being linked to a service. + :param setting_type: The type of setting being linked to a service. Must be one of the following: 'ps_database', + 'ps_connection', or 'ds_spatial'. + :param setting_uid: The name or id of the setting being linked to a service. + :return: True if successful, False otherwise. + """ + from cli.cli_colors import pretty_output, FG_GREEN, FG_RED + from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, + PersistentStoreDatabaseSetting) + from tethys_services.models import (SpatialDatasetService, PersistentStoreService) + + service_type_to_model_dict = { + 'spatial': SpatialDatasetService, + 'persistent': PersistentStoreService + } + + setting_type_to_link_model_dict = { + 'ps_database': { + 'setting_model': PersistentStoreDatabaseSetting, + 'service_field': 'persistent_store_service' + }, + 'ps_connection': { + 'setting_model': PersistentStoreConnectionSetting, + 'service_field': 'persistent_store_service' + }, + 'ds_spatial': { + 'setting_model': SpatialDatasetServiceSetting, + 'service_field': 'spatial_dataset_service' + } + } + + service_model = service_type_to_model_dict[service_type] + + try: + try: + service_uid = int(service_uid) + service = service_model.objects.get(pk=service_uid) + except ValueError: + service = service_model.objects.get(name=service_uid) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(service_model), service_uid)) + return False + + try: + app = TethysApp.objects.get(package=app_package) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Tethys App with the name "{}" does not exist. Aborted.'.format(app_package)) + return False + + try: + linked_setting_model_dict = setting_type_to_link_model_dict[setting_type] + except KeyError: + with pretty_output(FG_RED) as p: + p.write('The setting_type you specified ("{0}") does not exist.' + '\nChoose from: "ps_database|ps_connection|ds_spatial"'.format(setting_type)) + return False + + linked_setting_model = linked_setting_model_dict['setting_model'] + linked_service_field = linked_setting_model_dict['service_field'] + + try: + try: + setting_uid = int(setting_uid) + setting = linked_setting_model.objects.get(tethys_app=app, pk=setting_uid) + except ValueError: + setting = linked_setting_model.objects.get(tethys_app=app, name=setting_uid) + + setattr(setting, linked_service_field, service) + setting.save() + with pretty_output(FG_GREEN) as p: + p.write('{} with name "{}" was successfully linked to "{}" with name "{}" of the "{}" Tethys App' + .format(str(service_model), service_uid, linked_setting_model, setting_uid, app_package)) + return True + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(linked_setting_model), setting_uid)) + return False From 553ae8f516ca1df1127eda92db47ebe52128869a Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 21 Sep 2017 13:23:03 -0600 Subject: [PATCH 043/215] fully implemented --- tethys_apps/cli/__init__.py | 6 +- tethys_apps/cli/manage_commands.py | 5 +- .../management/commands/collectworkspaces.py | 70 ++++++++++++------- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index fec4d3223..918d9ef08 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -229,10 +229,14 @@ def tethys_command(): # Setup start server command manage_parser = subparsers.add_parser('manage', help='Management commands for Tethys Platform.') manage_parser.add_argument('command', help='Management command to run.', - choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_COLLECT, MANAGE_CREATESUPERUSER]) + choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, + MANAGE_COLLECT, MANAGE_CREATESUPERUSER]) manage_parser.add_argument('-m', '--manage', help='Absolute path to manage.py for Tethys Platform installation.') manage_parser.add_argument('-p', '--port', type=str, help='Host and/or port on which to bind the development server.') manage_parser.add_argument('--noinput', action='store_true', help='Pass the --noinput argument to the manage.py command.') + manage_parser.add_argument('-f', '--force', required=False, action='store_true', + help='Used only with {} to force the overwrite the app directory into its collect-to ' + 'location.') manage_parser.set_defaults(func=manage_command) # Setup services command diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index ee747db4c..fde3cd513 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -78,7 +78,10 @@ def manage_command(args): elif args.command == MANAGE_COLLECTWORKSPACES: # Run collectworkspaces command - primary_process = ['python', manage_path, 'collectworkspaces'] + if args.force: + primary_process = ['python', manage_path, 'collectworkspaces', '--force'] + else: + primary_process = ['python', manage_path, 'collectworkspaces'] elif args.command == MANAGE_COLLECT: # Convenience command to run collectstatic and collectworkspaces diff --git a/tethys_apps/management/commands/collectworkspaces.py b/tethys_apps/management/commands/collectworkspaces.py index 77b100a28..fc3da9949 100644 --- a/tethys_apps/management/commands/collectworkspaces.py +++ b/tethys_apps/management/commands/collectworkspaces.py @@ -18,18 +18,26 @@ class Command(BaseCommand): """ - Command class that handles the syncstores command. Provides persistent store management functionality. + Command class that handles the collectworkspaces command. """ + def add_arguments(self, parser): + parser.add_argument('-f', '--force', action='store_true', default=False, + help='Force the overwrite the app directory into its collected-to location.') + def handle(self, *args, **options): """ - Symbolically link the static directories of each app into the static/public directory specified by the STATIC_ROOT - parameter of the settings.py. Do this prior to running Django's collectstatic method. + Symbolically link the static directories of each app into the static/public directory specified by the + STATIC_ROOT parameter of the settings.py. Do this prior to running Django's collectstatic method. """ - if not hasattr(settings, 'TETHYS_WORKSPACES_ROOT') or (hasattr(settings, 'TETHYS_WORKSPACES_ROOT') and not settings.TETHYS_WORKSPACES_ROOT): + if not hasattr(settings, 'TETHYS_WORKSPACES_ROOT') or (hasattr(settings, 'TETHYS_WORKSPACES_ROOT') + and not settings.TETHYS_WORKSPACES_ROOT): print('WARNING: Cannot find the TETHYS_WORKSPACES_ROOT setting in the settings.py file. ' - 'Please provide the path to the static directory using the TETHYS_WORKSPACES_ROOT setting and try again.') + 'Please provide the path to the static directory using the TETHYS_WORKSPACES_ROOT ' + 'setting and try again.') exit(1) + # Get optional force arg + force = options['force'] # Read settings workspaces_root = settings.TETHYS_WORKSPACES_ROOT @@ -42,28 +50,40 @@ def handle(self, *args, **options): for app, path in installed_apps.items(): # Check for both variants of the static directory (public and static) - workspaces_path = os.path.join(path, 'workspaces') - workspaces_root_path = os.path.join(workspaces_root, app) + app_ws_path = os.path.join(path, 'workspaces') + tethys_ws_root_path = os.path.join(workspaces_root, app) # Only perform if workspaces_path is a directory - if os.path.isdir(workspaces_path) and not os.path.islink(workspaces_path): - # Clear out old symbolic links/directories in workspace root if necessary - try: - # Remove link - os.remove(workspaces_root_path) - except OSError: - try: - # Remove directory - shutil.rmtree(workspaces_root_path) - except OSError: - # No file - pass + if not os.path.isdir(app_ws_path): + print 'WARNING: The workspace_path for app "{}" is not a directory. Skipping...'.format(app) + continue - # Move the directory to workspace root path - shutil.move(workspaces_path, workspaces_root_path) + if not os.path.islink(app_ws_path): + if not os.path.exists(tethys_ws_root_path): + # Move the directory to workspace root path + shutil.move(app_ws_path, tethys_ws_root_path) + else: + if force: + # Clear out old symbolic links/directories in workspace root if necessary + try: + # Remove link + os.remove(tethys_ws_root_path) + except OSError: + shutil.rmtree(tethys_ws_root_path, ignore_errors=True) - # Create appropriate symbolic link - if os.path.isdir(workspaces_root_path): - os.symlink(workspaces_root_path, workspaces_path) - print('INFO: Successfully linked "workspaces" directory to TETHYS_WORKSPACES_ROOT for app "{0}".'.format(app)) + # Move the directory to workspace root path + shutil.move(app_ws_path, tethys_ws_root_path) + else: + print('WARNING: Workspace directory for app "{}" already exists in the TETHYS_WORKSPACES_ROOT ' + 'directory. A symbolic link is being created to the existing directory. To force overwrite ' + 'the existing directory, re-run the command with the "-f" argument.'.format(app)) + shutil.rmtree(app_ws_path, ignore_errors=True) + # Create appropriate symbolic link + if os.path.isdir(tethys_ws_root_path): + os.symlink(tethys_ws_root_path, app_ws_path) + print('INFO: Successfully linked "workspaces" directory to TETHYS_WORKSPACES_ROOT for app ' + '"{0}".'.format(app)) + else: + print('WARNING: Workspace directory for app "{}" is already symbolically linked to another directory ' + 'within the TETHYS_WORKSPACES_ROOT directory. Skipping... '.format(app)) From 20f165d8c486de1609b5c43917c2ee72994f0129 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 21 Sep 2017 13:57:30 -0600 Subject: [PATCH 044/215] fix bug with global model imports --- tethys_apps/cli/app_settings_commands.py | 17 ++++---- tethys_apps/cli/link_commands.py | 49 ++++++++++++------------ tethys_apps/cli/services_commands.py | 6 ++- 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index d00117473..e606f7cd4 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -1,19 +1,20 @@ from cli_helpers import console_superuser_required from django.core.exceptions import ObjectDoesNotExist -from tethys_apps.models import (TethysApp, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, - SpatialDatasetServiceSetting) from .cli_colors import * -setting_type_dict = { - PersistentStoreConnectionSetting: 'ps_connection', - PersistentStoreDatabaseSetting: 'ps_database', - SpatialDatasetServiceSetting: 'ds_spatial' -} - @console_superuser_required def app_settings_list_command(args): + from tethys_apps.models import (TethysApp, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, + SpatialDatasetServiceSetting) + + setting_type_dict = { + PersistentStoreConnectionSetting: 'ps_connection', + PersistentStoreDatabaseSetting: 'ps_database', + SpatialDatasetServiceSetting: 'ds_spatial' + } + app_package = args.app try: app = TethysApp.objects.get(package=app_package) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index 239013b3c..f46ea8a38 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -1,38 +1,39 @@ from django.core.exceptions import ObjectDoesNotExist -from tethys_apps.models import TethysApp -from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, - PersistentStoreDatabaseSetting) -from tethys_services.models import (SpatialDatasetService, PersistentStoreService) from .cli_colors import * from .cli_helpers import console_superuser_required -service_type_to_model_dict = { - 'spatial': SpatialDatasetService, - 'persistent': PersistentStoreService -} - -setting_type_to_link_model_dict = { - 'ps_database': { - 'setting_model': PersistentStoreDatabaseSetting, - 'service_field': 'persistent_store_service' - }, - 'ps_connection': { - 'setting_model': PersistentStoreConnectionSetting, - 'service_field': 'persistent_store_service' - }, - 'ds_spatial': { - 'setting_model': SpatialDatasetServiceSetting, - 'service_field': 'spatial_dataset_service' - } -} - @console_superuser_required def link_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_apps.models import TethysApp + from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, + PersistentStoreDatabaseSetting) + from tethys_services.models import (SpatialDatasetService, PersistentStoreService) + + service_type_to_model_dict = { + 'spatial': SpatialDatasetService, + 'persistent': PersistentStoreService + } + + setting_type_to_link_model_dict = { + 'ps_database': { + 'setting_model': PersistentStoreDatabaseSetting, + 'service_field': 'persistent_store_service' + }, + 'ps_connection': { + 'setting_model': PersistentStoreConnectionSetting, + 'service_field': 'persistent_store_service' + }, + 'ds_spatial': { + 'setting_model': SpatialDatasetServiceSetting, + 'service_field': 'spatial_dataset_service' + } + } + try: service = args.service setting = args.setting diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 81bc42a2f..4d33c05b1 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -1,7 +1,6 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.utils import IntegrityError from django.forms.models import model_to_dict -from tethys_services.models import SpatialDatasetService, PersistentStoreService from .cli_colors import * from .cli_helpers import console_superuser_required, add_geoserver_rest_to_endpoint @@ -23,6 +22,7 @@ def services_create_persistent_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_services.models import PersistentStoreService name = None try: @@ -52,6 +52,7 @@ def services_create_persistent_command(args): @console_superuser_required def services_remove_persistent_command(args): + from tethys_services.models import PersistentStoreService persistent_service_id = None try: @@ -84,6 +85,7 @@ def services_create_spatial_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_services.models import SpatialDatasetService name = None try: @@ -128,6 +130,7 @@ def services_create_spatial_command(args): @console_superuser_required def services_remove_spatial_command(args): + from tethys_services.models import SpatialDatasetService spatial_service_id = None try: @@ -160,6 +163,7 @@ def services_list_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ + from tethys_services.models import SpatialDatasetService, PersistentStoreService list_persistent = False list_spatial = False From 8ae26ba86c725688d6826429171bc8a57a355d87 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 25 Sep 2017 10:49:06 -0600 Subject: [PATCH 045/215] remove unnecessary authentication from tethys commands --- tethys_apps/cli/__init__.py | 25 ---------------- tethys_apps/cli/app_settings_commands.py | 2 -- tethys_apps/cli/cli_helpers.py | 36 +----------------------- tethys_apps/cli/link_commands.py | 2 -- tethys_apps/cli/services_commands.py | 7 +---- 5 files changed, 2 insertions(+), 70 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index 918d9ef08..b095d764a 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -251,21 +251,12 @@ def tethys_command(): services_remove_persistent = services_remove_subparsers.add_parser('persistent', help='Remove a Persistent Store Service.') services_remove_persistent.add_argument('service_id', help='The ID of the service that you are removing.') - services_remove_persistent.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_remove_persistent.set_defaults(func=services_remove_persistent_command) # REMOVE SPATIAL SERVICE COMMAND services_remove_spatial = services_remove_subparsers.add_parser('spatial', help='Remove a Spatial Dataset Service.') services_remove_spatial.add_argument('service_id', help='The ID of the service that you are removing.') - services_remove_spatial.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') - services_remove_spatial.set_defaults(func=services_remove_spatial_command) # SERVICES CREATE COMMANDS @@ -279,10 +270,6 @@ def tethys_command(): services_create_ps.add_argument('-c', '--connection', required=True, type=str, help='The connection of the Service in the form ' '":@:"') - services_create_ps.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_create_ps.set_defaults(func=services_create_persistent_command) # CREATE SPATIAL DATASET SERVICE COMMAND @@ -297,10 +284,6 @@ def tethys_command(): '--connection argument, of the form ":"') services_create_sd.add_argument('-k', '--apikey', required=False, type=str, help='The API key, if any, required to establish a connection.') - services_create_sd.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_create_sd.set_defaults(func=services_create_spatial_command) # LIST SERVICES COMMAND @@ -308,10 +291,6 @@ def tethys_command(): group = services_list_parser.add_mutually_exclusive_group() group.add_argument('-p', '--persistent', action='store_true', help='Only list Persistent Store Services.') group.add_argument('-s', '--spatial', action='store_true', help='Only list Spatial Dataset Services.') - services_list_parser.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') services_list_parser.set_defaults(func=services_list_command) # Setup app_settings command @@ -319,10 +298,6 @@ def tethys_command(): app_settings_subparsers = app_settings_parser.add_subparsers(title='Options') app_settings_list_parser = app_settings_subparsers.add_parser('list', help='List all settings for a specified app') app_settings_list_parser.add_argument('app', help='The app ("") to list the Settings for.') - app_settings_list_parser.add_argument('-a', '--authenticate', required=False, type=str, - help='The superuser credentials needed to perform this command in the form ' - '"" or ":". ' - 'You will be prompted for unprovided parts.') app_settings_list_parser.set_defaults(func=app_settings_list_command) # Setup link command diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index e606f7cd4..e572b9094 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -1,10 +1,8 @@ -from cli_helpers import console_superuser_required from django.core.exceptions import ObjectDoesNotExist from .cli_colors import * -@console_superuser_required def app_settings_list_command(args): from tethys_apps.models import (TethysApp, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting, SpatialDatasetServiceSetting) diff --git a/tethys_apps/cli/cli_helpers.py b/tethys_apps/cli/cli_helpers.py index d91eed05e..693e68778 100644 --- a/tethys_apps/cli/cli_helpers.py +++ b/tethys_apps/cli/cli_helpers.py @@ -1,37 +1,3 @@ -from getpass import getpass -from django.contrib.auth import authenticate -from .cli_colors import * - - -def console_superuser_required(func): - def _wrapped(args): - credentials = args.authenticate - username = None - password = None - - if credentials: - cred_parts = credentials.split(':') - if len(cred_parts) > 0: - username = cred_parts[0] - if len(cred_parts) > 1: - password = cred_parts[1] - if not username: - username = raw_input('username: ') - if not password: - password = getpass('password: ') - user = authenticate(username=username, password=password) - if not user: - with pretty_output(FG_RED) as p: - p.write('The username or password provided was incorrect. Command aborted.') - exit(1) - if not user.is_superuser: - with pretty_output(FG_RED) as p: - p.write('You are not authorized to perform this action.') - exit(1) - - return func(args) - - return _wrapped def add_geoserver_rest_to_endpoint(endpoint): @@ -42,4 +8,4 @@ def add_geoserver_rest_to_endpoint(endpoint): port_and_path = parts2[1] port = port_and_path.split('/')[0] - return '{0}//{1}:{2}/geoserver/rest/'.format(protocol, host, port) \ No newline at end of file + return '{0}//{1}:{2}/geoserver/rest/'.format(protocol, host, port) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index f46ea8a38..b4d176a44 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -1,10 +1,8 @@ from django.core.exceptions import ObjectDoesNotExist from .cli_colors import * -from .cli_helpers import console_superuser_required -@console_superuser_required def link_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 4d33c05b1..2967136e1 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -3,7 +3,7 @@ from django.forms.models import model_to_dict from .cli_colors import * -from .cli_helpers import console_superuser_required, add_geoserver_rest_to_endpoint +from .cli_helpers import add_geoserver_rest_to_endpoint SERVICES_CREATE = 'create' SERVICES_CREATE_PERSISTENT = 'persistent' @@ -17,7 +17,6 @@ def __init__(self): Exception.__init__(self) -@console_superuser_required def services_create_persistent_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps @@ -50,7 +49,6 @@ def services_create_persistent_command(args): p.write('Persistent Store Service with name "{0}" already exists. Command aborted.'.format(name)) -@console_superuser_required def services_remove_persistent_command(args): from tethys_services.models import PersistentStoreService persistent_service_id = None @@ -80,7 +78,6 @@ def services_remove_persistent_command(args): p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(persistent_service_id)) -@console_superuser_required def services_create_spatial_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps @@ -128,7 +125,6 @@ def services_create_spatial_command(args): p.write('Spatial Dataset Service with name "{0}" already exists. Command aborted.'.format(name)) -@console_superuser_required def services_remove_spatial_command(args): from tethys_services.models import SpatialDatasetService spatial_service_id = None @@ -158,7 +154,6 @@ def services_remove_spatial_command(args): p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(spatial_service_id)) -@console_superuser_required def services_list_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps From 7c17cc823520f6de3cb675628d7375b4c9de64e6 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 26 Sep 2017 13:56:12 -0600 Subject: [PATCH 046/215] command to sync apps fully implemented --- tethys_apps/cli/__init__.py | 6 +++--- tethys_apps/cli/manage_commands.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index b095d764a..73776e14f 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -21,7 +21,7 @@ from .gen_commands import GEN_SETTINGS_OPTION, GEN_APACHE_OPTION, generate_command from .manage_commands import (manage_command, get_manage_path, run_process, MANAGE_START, MANAGE_SYNCDB, - MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, + MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_SYNCAPPS, MANAGE_COLLECT, MANAGE_CREATESUPERUSER, TETHYS_SRC_DIRECTORY) from .services_commands import (SERVICES_CREATE, SERVICES_CREATE_PERSISTENT, SERVICES_CREATE_SPATIAL, SERVICES_LINK, services_create_persistent_command, services_create_spatial_command, @@ -230,7 +230,7 @@ def tethys_command(): manage_parser = subparsers.add_parser('manage', help='Management commands for Tethys Platform.') manage_parser.add_argument('command', help='Management command to run.', choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, - MANAGE_COLLECT, MANAGE_CREATESUPERUSER]) + MANAGE_COLLECT, MANAGE_CREATESUPERUSER, MANAGE_SYNCAPPS]) manage_parser.add_argument('-m', '--manage', help='Absolute path to manage.py for Tethys Platform installation.') manage_parser.add_argument('-p', '--port', type=str, help='Host and/or port on which to bind the development server.') manage_parser.add_argument('--noinput', action='store_true', help='Pass the --noinput argument to the manage.py command.') @@ -308,7 +308,7 @@ def tethys_command(): '":" ' '(i.e. "persistent_connection:super_conn")') link_parser.add_argument('setting', help='Setting of an app with which to link the specified service.' - 'Of the form ":' + 'Of the form "::' '" (i.e. "epanet:database:epanet_2")') link_parser.set_defaults(func=link_command) diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index fde3cd513..e228b6b37 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -23,6 +23,7 @@ MANAGE_COLLECTWORKSPACES = 'collectworkspaces' MANAGE_COLLECT = 'collectall' MANAGE_CREATESUPERUSER = 'createsuperuser' +MANAGE_SYNCAPPS = 'syncapps' def get_manage_path(args): @@ -102,7 +103,9 @@ def manage_command(args): elif args.command == MANAGE_CREATESUPERUSER: primary_process = ['python', manage_path, 'createsuperuser'] - + elif args.command == MANAGE_SYNCAPPS: + from tethys_apps.utilities import sync_tethys_app_db + sync_tethys_app_db() if primary_process: run_process(primary_process) From 8a5d59646404b3f06b14a8a29115499c0999d42a Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 26 Sep 2017 16:06:50 -0600 Subject: [PATCH 047/215] added scheduler commands; added documentation for all recent cli cmds --- docs/tethys_sdk/tethys_cli.rst | 156 +++++++++++++++++++++++++- tethys_apps/cli/__init__.py | 42 ++++++- tethys_apps/cli/scheduler_commands.py | 73 ++++++++++++ 3 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 tethys_apps/cli/scheduler_commands.py diff --git a/docs/tethys_sdk/tethys_cli.rst b/docs/tethys_sdk/tethys_cli.rst index 6d6b10b92..a15035fa4 100644 --- a/docs/tethys_sdk/tethys_cli.rst +++ b/docs/tethys_sdk/tethys_cli.rst @@ -87,8 +87,9 @@ This command contains several subcommands that are used to help manage Tethys Pl * **subcommand**: The management command to run. Either "start", "syncdb", or "collectstatic". - * *start*: Starts the Django development server. Wrapper for ``manage.py runserver``. + * *start*: Start the Django development server. Wrapper for ``manage.py runserver``. * *syncdb*: Initialize the database during installation. Wrapper for ``manage.py syncdb``. + * *syncapps*: Sync installed apps with the TethysApp database. * *collectstatic*: Link app static/public directories to STATIC_ROOT directory and then run Django's collectstatic command. Preprocessor and wrapper for ``manage.py collectstatic``. * *collectworkspaces*: Link app workspace directories to TETHYS_WORKSPACES_ROOT directory. * *collectall*: Convenience command for running both *collectstatic* and *collectworkspaces*. @@ -110,6 +111,9 @@ This command contains several subcommands that are used to help manage Tethys Pl # Sync the database $ tethys manage syncdb + # Sync installed apps with the TethysApp database. + $ tethys manage syncapps + # Collect static files $ tethys manage collectstatic @@ -285,3 +289,153 @@ Management commands for running tests for Tethys Platform and Tethys Apps. See : # Run tests for a single app tethys test -f tethys_apps.tethysapp.my_first_app + + +.. _tethys_cli_app_settings: + +app_settings +--------------- + +This command is used to list the Persistent Store and Spatial Dataset Settings that an app has requested. + +**Arguments:** + +* **app_name**: Name of app for which Settings will be listed + +**Optional Arguments:** + +* **-p --persistent**: A flag indicating that only Persistent Store Settings should be listed +* **-s --spatial**: A flag indicating that only Spatial Dataset Settings should be listed + +**Examples:** + +:: + + $ tethys app_settings my_first_app + +.. _tethys_cli_services: + +services [ | options] +--------------- + +This command is used to interact with Tethys Services from the command line, rather than the App Admin interface. + +**Arguments:** + +* **subcommand**: The services command to run. One of the following: + + * *list*: List all existing Tethys Services (Persistent Store and Spatial Dataset Services) + * *create*: Create a new Tethys Service + * **subcommand**: The service type to create + * *persistent*: Create a new Persistent Store Service + **Arguments:** + + * **-n, --name**: A unique name to identify the service being created + * **-c, --connection**: The connection endpoint associated with this service, in the form ":@:" + * *spatial*: Create a new Spatial Dataset Service + **Arguments:** + + * **-n, --name**: A unique name to identify the service being created + * **-c, --connection**: The connection endpoint associated with this service, in the form ":@//:" + + **Optional Arguments:** + + * **-p, --public-endpoint**: The public-facing endpoint of the Service, if different than what was provided with the "--connection" argument, in the form "//:". + * **-k, --apikey**: The API key, if any, required to establish a connection. + * *remove*: Remove a Tethys Service + * **subcommand**: The service type to remove + * *persistent*: Remove a Persistent Store Service + **Arguments:** + * **service_uid**: A unique identifier of the Service to be removed, which can either be the database ID, or the service name + * *spatial*: Remove a Spatial Dataset Service + **Arguments:** + * **service_uid**: A unique identifier of the Service to be removed, which can either be the database ID, or the service name + +**Examples:** + +:: + + # List all Tethys Services + $ tethys services list + + # List only Spatial Dataset Tethys Services + $ tethys services list -s + + # List only Persistent Store Tethys Services + $ tethys services list -p + + # Create a new Spatial Dataset Tethys Service + + $ tethys services create spatial -n my_spatial_service -c my_username:my_password@http://127.0.0.1:8081 -p https://mypublicdomain.com -k mysecretapikey + + # Create a new Persistent Store Tethys Service + $ tethys services create persistent -n my_persistent_service -c my_username:my_password@http://127.0.0.1:8081 + + # Remove a Spatial Dataset Tethys Service + $ tethys services remove my_spatial_service + + # Remove a Persistent Store Tethys Service + $ tethys services remove my_persistent_service + +.. _tethys_cli_link: + +link +--------------- + +This command is used to link a Tethys Service with a TethysApp Setting + +**Arguments:** + +* **service_identifier**: An identifier of the Tethys Service being linked, of the form ":", where can be either "spatial" or "persistent", and must be either the database ID or name of the Tethys Service. +* **app_setting_identifier**: An identifier of the TethysApp Setting being linked, of the form "::", where must be one of "ds_spatial," "ps_connection", or "ps_database" and can be either the database ID or name of the TethysApp Setting. + +**Examples:** + +:: + + # Link a Persistent Store Service to a Persistent Store Connection Setting + $ tethys link persistent:my_persistent_service my_first_app:ps_connection:my_ps_connection + + # Link a Persistent Store Service to a Persistent Store Database Setting + $ tethys link persistent:my_persistent_service my_first_app:ps_database:my_ps_connection + + # Link a Spatial Dataset Service to a Spatial Dataset Service Setting + $ tethys link spatial:my_spatial_service my_first_app:ds_spatial:my_spatial_connection + +.. _tethys_cli_schedulers: + +schedulers +--------------- + +This command is used to interact with Schedulers from the command line, rather than through the App Admin interface + +**Arguments:** + +* **subcommand**: The schedulers command to run. One of the following: + + * *list*: List all existing Schedulers + * *create*: Create a new Scheduler + **Arguments:** + * **-n, --name**: A unique name to identify the Scheduler being created + * **-d, --endpoint**: The endpoint of the remote host the Scheduler will connect with in the form //" + * **-u, --username**: The username that will be used to connect to the remote endpoint" + **Optional Arguments:** + * **-p, --password**: The password associated with the username (required if "-f (--private-key-path)" not specified. + * **-f, --private-key-path**: The path to the private ssh key file (required if "-p (--password)" not specified. + * **-k, --private-key-pass**: The password to the private ssh key file (only meaningful if "-f (--private-key-path)" is specified. + * *remove*: Remove a Scheduler + **Arguments:** + * **scheduler_name**: The unique name of the Scheduler being removed. + +**Examples:** + +:: + + # List all Schedulers + $ tethys schedulers list + + # Create a new scheduler + $ tethys schedulers create -n my_scheduler -e http://127.0.0.1 -u my_username -p my_password + + # Remove a scheduler + $ tethys schedulers remove my_scheduler diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index 73776e14f..eeed30d07 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -29,6 +29,7 @@ services_remove_spatial_command) from .link_commands import link_command from .app_settings_commands import app_settings_list_command +from .scheduler_commands import scheduler_create_command, schedulers_list_command, schedulers_remove_command from .gen_commands import VALID_GEN_OBJECTS, generate_command from tethys_apps.helpers import get_installed_tethys_apps @@ -239,6 +240,37 @@ def tethys_command(): 'location.') manage_parser.set_defaults(func=manage_command) + # Setup scheduler command + scheduler_parser = subparsers.add_parser('schedulers', help='Scheduler commands for Tethys Platform.') + scheduler_subparsers = scheduler_parser.add_subparsers(title='Commands') + + # SCHEDULERS CREATE COMMAND + schedulers_create = scheduler_subparsers.add_parser('create', help='Create a Scheduler that can be ' + 'accessed by Tethys Apps.') + schedulers_create.add_argument('-n', '--name', required=True, help='A unique name for the Scheduler', type=str) + schedulers_create.add_argument('-e', '--endpoint', required=True, type=str, + help='The endpoint of the service in the form //"') + schedulers_create.add_argument('-u', '--username', required=True, help='The username to connect to the host with', + type=str) + group = schedulers_create.add_mutually_exclusive_group(required=True) + group.add_argument('-p', '--password', required=False, type=str, + help='The password associated with the provided username') + group.add_argument('-f', '--private-key-path', required=False, help='The path to the private ssh key file', + type=str) + schedulers_create.add_argument('-k', '--private-key-pass', required=False, type=str, + help='The password to the private ssh key file') + + schedulers_create.set_defaults(func=scheduler_create_command) + + # SCHEDULERS LIST COMMAND + schedulers_list = scheduler_subparsers.add_parser('list', help='List the existing Schedulers.') + schedulers_list.set_defaults(func=schedulers_list_command) + + # SCHEDULERS REMOVE COMMAND + schedulers_remove = scheduler_subparsers.add_parser('remove', help='Remove a Scheduler.') + schedulers_remove.add_argument('scheduler_name', help='The unique name of the Scheduler that you are removing.') + schedulers_remove.set_defaults(func=schedulers_remove_command) + # Setup services command services_parser = subparsers.add_parser('services', help='Services commands for Tethys Platform.') services_subparsers = services_parser.add_subparsers(title='Commands') @@ -250,13 +282,15 @@ def tethys_command(): # REMOVE PERSISTENT SERVICE COMMAND services_remove_persistent = services_remove_subparsers.add_parser('persistent', help='Remove a Persistent Store Service.') - services_remove_persistent.add_argument('service_id', help='The ID of the service that you are removing.') + services_remove_persistent.add_argument('service_uid', help='The ID or name of the Persistent Store Service ' + 'that you are removing.') services_remove_persistent.set_defaults(func=services_remove_persistent_command) # REMOVE SPATIAL SERVICE COMMAND services_remove_spatial = services_remove_subparsers.add_parser('spatial', help='Remove a Spatial Dataset Service.') - services_remove_spatial.add_argument('service_id', help='The ID of the service that you are removing.') + services_remove_spatial.add_argument('service_uid', help='The ID or name of the Spatial Dataset Service ' + 'that you are removing.') services_remove_spatial.set_defaults(func=services_remove_spatial_command) # SERVICES CREATE COMMANDS @@ -266,7 +300,7 @@ def tethys_command(): # CREATE PERSISTENT STORE SERVICE COMMAND services_create_ps = services_create_subparsers.add_parser('persistent', help='Create a Persistent Store Service.') - services_create_ps.add_argument('-n', '--name', required=True, help='The name of the Service', type=str) + services_create_ps.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) services_create_ps.add_argument('-c', '--connection', required=True, type=str, help='The connection of the Service in the form ' '":@:"') @@ -275,7 +309,7 @@ def tethys_command(): # CREATE SPATIAL DATASET SERVICE COMMAND services_create_sd = services_create_subparsers.add_parser('spatial', help='Create a Spatial Dataset Service.') - services_create_sd.add_argument('-n', '--name', required=True, help='The name of the Service', type=str) + services_create_sd.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) services_create_sd.add_argument('-c', '--connection', required=True, type=str, help='The connection of the Service in the form ' '":@//:"') diff --git a/tethys_apps/cli/scheduler_commands.py b/tethys_apps/cli/scheduler_commands.py new file mode 100644 index 000000000..fc44704b4 --- /dev/null +++ b/tethys_apps/cli/scheduler_commands.py @@ -0,0 +1,73 @@ +from .cli_colors import * +from django.core.exceptions import ObjectDoesNotExist + + +def scheduler_create_command(args): + from tethys_compute.models import Scheduler + + name = args.name + host = args.endpoint + username = args.username + password = args.password + private_key_path = args.private_key_path + private_key_pass = args.private_key_pass + + scheduler = Scheduler( + name=name, + host=host, + username=username, + password=password, + private_key_path=private_key_path, + private_key_pass=private_key_pass + ) + + scheduler.save() + + with pretty_output(FG_GREEN) as p: + p.write('Scheduler created successfully!') + exit(0) + + +def schedulers_list_command(args): + from tethys_compute.models import Scheduler + schedulers = Scheduler.objects.all() + + num_schedulers = len(schedulers) + + if num_schedulers > 0: + with pretty_output(BOLD) as p: + p.write('{0: <30}{1: <25}{2: <10}{3: <10}{4: <50}{5: <10}'.format( + 'Name', 'Host', 'Username', 'Password', 'Private Key Path', 'Private Key Pass' + )) + for scheduler in schedulers: + p.write('{0: <30}{1: <25}{2: <10}{3: <10}{4: <50}{5: <10}'.format( + scheduler.name, scheduler.host, scheduler.username, '******' if scheduler.password else 'None', + scheduler.private_key_path, '******' if scheduler.private_key_pass else 'None' + )) + else: + with pretty_output(BOLD) as p: + p.write('There are no Schedulers registered in Tethys.') + + +def schedulers_remove_command(args): + from tethys_compute.models import Scheduler + scheduler = None + name = args.scheduler_name + + try: + scheduler = Scheduler.objects.get(name=name) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('Scheduler with name "{}" does not exist.\nCommand aborted.'.format(name)) + + proceed = raw_input('Are you sure you want to delete this Scheduler? [y/n]: ') + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + scheduler.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed Scheduler "{0}"!'.format(name)) + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. Scheduler not removed.') From 65b9518b49a2d5395d92ee6595ecf3cacb256b96 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 28 Sep 2017 12:01:27 -0600 Subject: [PATCH 048/215] add app_settings_create/remove commands --- tethys_apps/cli/__init__.py | 79 +++++++--- tethys_apps/cli/app_settings_commands.py | 33 +++++ tethys_apps/cli/link_commands.py | 79 +--------- tethys_apps/utilities.py | 174 ++++++++++++++++++++++- 4 files changed, 268 insertions(+), 97 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index eeed30d07..f106dc4ab 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -28,7 +28,8 @@ services_list_command, services_remove_persistent_command, services_remove_spatial_command) from .link_commands import link_command -from .app_settings_commands import app_settings_list_command +from .app_settings_commands import (app_settings_list_command, app_settings_create_ps_database_command, + app_settings_remove_command) from .scheduler_commands import scheduler_create_command, schedulers_list_command, schedulers_remove_command from .gen_commands import VALID_GEN_OBJECTS, generate_command from tethys_apps.helpers import get_installed_tethys_apps @@ -240,64 +241,63 @@ def tethys_command(): 'location.') manage_parser.set_defaults(func=manage_command) - # Setup scheduler command + # SCHEDULERS COMMANDS scheduler_parser = subparsers.add_parser('schedulers', help='Scheduler commands for Tethys Platform.') scheduler_subparsers = scheduler_parser.add_subparsers(title='Commands') - # SCHEDULERS CREATE COMMAND + # tethys schedulers create schedulers_create = scheduler_subparsers.add_parser('create', help='Create a Scheduler that can be ' - 'accessed by Tethys Apps.') + 'accessed by Tethys Apps.') schedulers_create.add_argument('-n', '--name', required=True, help='A unique name for the Scheduler', type=str) schedulers_create.add_argument('-e', '--endpoint', required=True, type=str, - help='The endpoint of the service in the form //"') + help='The endpoint of the service in the form //"') schedulers_create.add_argument('-u', '--username', required=True, help='The username to connect to the host with', - type=str) + type=str) group = schedulers_create.add_mutually_exclusive_group(required=True) group.add_argument('-p', '--password', required=False, type=str, help='The password associated with the provided username') group.add_argument('-f', '--private-key-path', required=False, help='The path to the private ssh key file', type=str) schedulers_create.add_argument('-k', '--private-key-pass', required=False, type=str, - help='The password to the private ssh key file') - + help='The password to the private ssh key file') schedulers_create.set_defaults(func=scheduler_create_command) - # SCHEDULERS LIST COMMAND + # tethys schedulers list schedulers_list = scheduler_subparsers.add_parser('list', help='List the existing Schedulers.') schedulers_list.set_defaults(func=schedulers_list_command) - # SCHEDULERS REMOVE COMMAND + # tethys schedulers remove schedulers_remove = scheduler_subparsers.add_parser('remove', help='Remove a Scheduler.') schedulers_remove.add_argument('scheduler_name', help='The unique name of the Scheduler that you are removing.') schedulers_remove.set_defaults(func=schedulers_remove_command) - # Setup services command + # SERVICES COMMANDS services_parser = subparsers.add_parser('services', help='Services commands for Tethys Platform.') services_subparsers = services_parser.add_subparsers(title='Commands') - # SERVICES REMOVE COMMANDS + # tethys services remove services_remove_parser = services_subparsers.add_parser('remove', help='Remove a Tethys Service.') services_remove_subparsers = services_remove_parser.add_subparsers(title='Service Type') - # REMOVE PERSISTENT SERVICE COMMAND + # tethys services remove persistent services_remove_persistent = services_remove_subparsers.add_parser('persistent', help='Remove a Persistent Store Service.') services_remove_persistent.add_argument('service_uid', help='The ID or name of the Persistent Store Service ' 'that you are removing.') services_remove_persistent.set_defaults(func=services_remove_persistent_command) - # REMOVE SPATIAL SERVICE COMMAND + # tethys services remove spatial services_remove_spatial = services_remove_subparsers.add_parser('spatial', help='Remove a Spatial Dataset Service.') services_remove_spatial.add_argument('service_uid', help='The ID or name of the Spatial Dataset Service ' 'that you are removing.') services_remove_spatial.set_defaults(func=services_remove_spatial_command) - # SERVICES CREATE COMMANDS + # tethys services create services_create_parser = services_subparsers.add_parser('create', help='Create a Tethys Service.') services_create_subparsers = services_create_parser.add_subparsers(title='Service Type') - # CREATE PERSISTENT STORE SERVICE COMMAND + # tethys services create persistent services_create_ps = services_create_subparsers.add_parser('persistent', help='Create a Persistent Store Service.') services_create_ps.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) @@ -306,7 +306,7 @@ def tethys_command(): '":@:"') services_create_ps.set_defaults(func=services_create_persistent_command) - # CREATE SPATIAL DATASET SERVICE COMMAND + # tethys services create spatial services_create_sd = services_create_subparsers.add_parser('spatial', help='Create a Spatial Dataset Service.') services_create_sd.add_argument('-n', '--name', required=True, help='A unique name for the Service', type=str) @@ -320,24 +320,59 @@ def tethys_command(): help='The API key, if any, required to establish a connection.') services_create_sd.set_defaults(func=services_create_spatial_command) - # LIST SERVICES COMMAND + # tethys services list services_list_parser = services_subparsers.add_parser('list', help='List all existing Tethys Services.') group = services_list_parser.add_mutually_exclusive_group() group.add_argument('-p', '--persistent', action='store_true', help='Only list Persistent Store Services.') group.add_argument('-s', '--spatial', action='store_true', help='Only list Spatial Dataset Services.') services_list_parser.set_defaults(func=services_list_command) - # Setup app_settings command + # APP_SETTINGS COMMANDS app_settings_parser = subparsers.add_parser('app_settings', help='Interact with Tethys App Settings.') app_settings_subparsers = app_settings_parser.add_subparsers(title='Options') + + # tethys app_settings list app_settings_list_parser = app_settings_subparsers.add_parser('list', help='List all settings for a specified app') app_settings_list_parser.add_argument('app', help='The app ("") to list the Settings for.') app_settings_list_parser.set_defaults(func=app_settings_list_command) - # Setup link command - - # LINK SERVICE WITH APP COMMAND + # tethys app_settings create + app_settings_create_cmd = app_settings_subparsers.add_parser('create', help='Create a Setting for an app.') + + asc_subparsers = app_settings_create_cmd.add_subparsers(title='Create Options') + app_settings_create_cmd.add_argument('-a', '--app', required=True, + help='The app ("") to create the Setting for.') + app_settings_create_cmd.add_argument('-n', '--name', required=True, help='The name of the Setting to create.') + app_settings_create_cmd.add_argument('-d', '--description', required=False, + help='A description for the Setting to create.') + app_settings_create_cmd.add_argument('-r', '--required', required=False, action='store_true', + help='Include this flag if the Setting is required for the app.') + app_settings_create_cmd.add_argument('-i', '--initializer', required=False, + help='The function that initializes the PersistentStoreSetting database.') + app_settings_create_cmd.add_argument('-z', '--initialized', required=False, action='store_true', + help='Include this flag if the database is already initialized.') + + # tethys app_settings create ps_database + app_settings_create_psdb_cmd = asc_subparsers.add_parser('ps_database', + help='Create a PersistentStoreDatabaseSetting') + app_settings_create_psdb_cmd.add_argument('-s', '--spatial', required=False, action='store_true', + help='Include this flag if the database requires spatial capabilities.') + app_settings_create_psdb_cmd.add_argument('-y', '--dynamic', action='store_true', required=False, + help='Include this flag if the database should be considered to be ' + 'dynamically created.') + app_settings_create_psdb_cmd.set_defaults(func=app_settings_create_ps_database_command) + + # tethys app_settings remove + app_settings_remove_cmd = app_settings_subparsers.add_parser('remove', help='Remove a Setting for an app.') + app_settings_remove_cmd.add_argument('app', help='The app ("") to remove the Setting from.') + app_settings_remove_cmd.add_argument('-n', '--name', help='The name of the Setting to remove.', required=True) + app_settings_remove_cmd.add_argument('-f', '--force', action='store_true', help='Force removal without confirming.') + app_settings_remove_cmd.set_defaults(func=app_settings_remove_command) + + # LINK COMMANDS link_parser = subparsers.add_parser('link', help='Link a Service to a Tethys app Setting.') + + # tethys link link_parser.add_argument('service', help='Service to link to a target app. Of the form ' '":" ' '(i.e. "persistent_connection:super_conn")') diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index e572b9094..b86887b82 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -72,3 +72,36 @@ def app_settings_list_command(args): print e with pretty_output(FG_RED) as p: p.write('Something went wrong. Please try again.') + + +def app_settings_create_ps_database_command(args): + from ..utilities import create_ps_database_setting + app_package = args.app + setting_name = args.name + setting_description = args.description + required = args.required + initializer = args.initializer + initialized = args.initialized + spatial = args.spatial + dynamic = args.dynamic + + success = create_ps_database_setting(app_package, setting_name, setting_description or '', required, initializer or '', + initialized, spatial, dynamic) + + if not success: + exit(1) + + exit(0) + + +def app_settings_remove_command(args): + from ..utilities import remove_ps_database_setting + app_package = args.app + setting_name = args.name + force = args.force + success = remove_ps_database_setting(app_package, setting_name, force) + + if not success: + exit(1) + + exit(0) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index b4d176a44..a7dfe09dd 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -1,36 +1,9 @@ -from django.core.exceptions import ObjectDoesNotExist - -from .cli_colors import * - - def link_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ - from tethys_apps.models import TethysApp - from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, - PersistentStoreDatabaseSetting) - from tethys_services.models import (SpatialDatasetService, PersistentStoreService) - - service_type_to_model_dict = { - 'spatial': SpatialDatasetService, - 'persistent': PersistentStoreService - } - - setting_type_to_link_model_dict = { - 'ps_database': { - 'setting_model': PersistentStoreDatabaseSetting, - 'service_field': 'persistent_store_service' - }, - 'ps_connection': { - 'setting_model': PersistentStoreConnectionSetting, - 'service_field': 'persistent_store_service' - }, - 'ds_spatial': { - 'setting_model': SpatialDatasetServiceSetting, - 'service_field': 'spatial_dataset_service' - } - } + from ..utilities import link_service_to_app_setting + from .cli_colors import pretty_output, FG_RED try: service = args.service @@ -59,55 +32,15 @@ def link_command(args): '\nCommand aborted.') exit(1) - service_model = service_type_to_model_dict[service_type] + success = link_service_to_app_setting(service_type, service_uid, setting_app_package, setting_type, setting_uid) - try: - try: - service_uid = int(service_uid) - service = service_model.objects.get(pk=service_uid) - except ValueError: - service = service_model.objects.get(name=service_uid) - except ObjectDoesNotExist: - with pretty_output(FG_RED) as p: - p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(service_model), service_uid)) - exit(1) - - app = None - try: - app = TethysApp.objects.get(package=setting_app_package) - except ObjectDoesNotExist: - with pretty_output(FG_RED) as p: - p.write('The app you specified ("{0}") does not exist.'.format(setting_app_package)) + if not success: exit(1) - linked_setting_model_dict = None - try: - linked_setting_model_dict = setting_type_to_link_model_dict[setting_type] - except KeyError: - with pretty_output(FG_RED) as p: - p.write('The setting_type you specified ("{0}") does not exist.' - '\nChoose from: "ps_database|ps_connection|ds_spatial"'.format(setting_type)) - exit(1) - - linked_setting_model = linked_setting_model_dict['setting_model'] - linked_service_field = linked_setting_model_dict['service_field'] - - try: - try: - setting_uid = int(setting_uid) - setting = linked_setting_model.objects.get(tethys_app=app, pk=setting_uid) - except ValueError: - setting = linked_setting_model.objects.get(tethys_app=app, name=setting_uid) - except ObjectDoesNotExist: - with pretty_output(FG_RED) as p: - p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(linked_setting_model), setting_uid)) - exit(1) - - setattr(setting, linked_service_field, service) - - setting.save() + exit(0) except Exception as e: print e with pretty_output(FG_RED) as p: p.write('An unexpected error occurred. Please try again.') + exit(1) diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 73370aa03..2de4f34e9 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -317,8 +317,8 @@ def sync_tethys_app_db(): for installed_app in installed_apps: # Query to see if installed app is in the database - db_apps = TethysApp.objects.\ - filter(package__exact=installed_app.package).\ + db_apps = TethysApp.objects. \ + filter(package__exact=installed_app.package). \ all() # If the app is not in the database, then add it @@ -417,3 +417,173 @@ def get_active_app(request=None, url=None): except MultipleObjectsReturned: tethys_log.warning('Multiple apps found with root url "{0}".'.format(app_root_url)) return app + + +def create_ps_database_setting(app_package, name, description='', required=False, initializer='', initialized=False, + spatial=False, dynamic=False): + from cli.cli_colors import pretty_output, FG_RED, FG_GREEN + from tethys_apps.models import PersistentStoreDatabaseSetting + + try: + app = TethysApp.objects.get(package=app_package) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Tethys App with the name "{}" does not exist. Aborted.'.format(app_package)) + return False + + try: + setting = PersistentStoreDatabaseSetting.objects.get(name=name) + if setting: + with pretty_output(FG_RED) as p: + p.write('A PersistentStoreDatabaseSetting with name "{}" already exists. Aborted.'.format(name)) + return False + except ObjectDoesNotExist: + pass + + try: + ps_database_setting = PersistentStoreDatabaseSetting( + tethys_app=app, + name=name, + description=description, + required=required, + initializer=initializer, + initialized=initialized, + spatial=spatial, + dynamic=dynamic + ) + ps_database_setting.save() + with pretty_output(FG_GREEN) as p: + p.write('PersistentStoreDatabaseSetting named "{}" for app "{}" created successfully!'.format(name, + app_package)) + return True + except Exception as e: + print e + with pretty_output(FG_RED) as p: + p.write('The above error was encountered. Aborted.'.format(app_package)) + return False + + +def remove_ps_database_setting(app_package, name, force=False): + from cli.cli_colors import pretty_output, FG_RED, FG_GREEN + from tethys_apps.models import PersistentStoreDatabaseSetting + + try: + app = TethysApp.objects.get(package=app_package) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Tethys App with the name "{}" does not exist. Aborted.'.format(app_package)) + return False + + try: + setting = PersistentStoreDatabaseSetting.objects.get(tethys_app=app, name=name) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('An PersistentStoreDatabaseSetting with the name "{}" for app "{}" does not exist. Aborted.' + .format(name, app_package)) + return False + + if not force: + proceed = raw_input('Are you sure you want to delete the PersistentStoreDatabaseSetting named "{}"? [y/n]: ' + .format(name)) + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + setting.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed PersistentStoreDatabaseSetting with name "{0}"!'.format(name)) + return True + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. PersistentStoreDatabaseSetting not removed.') + else: + setting.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed PersistentStoreDatabaseSetting with name "{0}"!'.format(name)) + return True + + +def link_service_to_app_setting(service_type, service_uid, app_package, setting_type, setting_uid): + """ + Links a Tethys Service to a TethysAppSetting. + :param service_type: The type of service being linked to an app. Must be either 'spatial' or 'persistent'. + :param service_uid: The name or id of the service being linked to an app. + :param app_package: The package name of the app whose setting is being linked to a service. + :param setting_type: The type of setting being linked to a service. Must be one of the following: 'ps_database', + 'ps_connection', or 'ds_spatial'. + :param setting_uid: The name or id of the setting being linked to a service. + :return: True if successful, False otherwise. + """ + from cli.cli_colors import pretty_output, FG_GREEN, FG_RED + from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, + PersistentStoreDatabaseSetting) + from tethys_services.models import (SpatialDatasetService, PersistentStoreService) + + service_type_to_model_dict = { + 'spatial': SpatialDatasetService, + 'persistent': PersistentStoreService + } + + setting_type_to_link_model_dict = { + 'ps_database': { + 'setting_model': PersistentStoreDatabaseSetting, + 'service_field': 'persistent_store_service' + }, + 'ps_connection': { + 'setting_model': PersistentStoreConnectionSetting, + 'service_field': 'persistent_store_service' + }, + 'ds_spatial': { + 'setting_model': SpatialDatasetServiceSetting, + 'service_field': 'spatial_dataset_service' + } + } + + service_model = service_type_to_model_dict[service_type] + + try: + try: + service_uid = int(service_uid) + service = service_model.objects.get(pk=service_uid) + except ValueError: + service = service_model.objects.get(name=service_uid) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(service_model), service_uid)) + return False + + try: + app = TethysApp.objects.get(package=app_package) + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A Tethys App with the name "{}" does not exist. Aborted.'.format(app_package)) + return False + + try: + linked_setting_model_dict = setting_type_to_link_model_dict[setting_type] + except KeyError: + with pretty_output(FG_RED) as p: + p.write('The setting_type you specified ("{0}") does not exist.' + '\nChoose from: "ps_database|ps_connection|ds_spatial"'.format(setting_type)) + return False + + linked_setting_model = linked_setting_model_dict['setting_model'] + linked_service_field = linked_setting_model_dict['service_field'] + + try: + try: + setting_uid = int(setting_uid) + setting = linked_setting_model.objects.get(tethys_app=app, pk=setting_uid) + except ValueError: + setting = linked_setting_model.objects.get(tethys_app=app, name=setting_uid) + + setattr(setting, linked_service_field, service) + setting.save() + with pretty_output(FG_GREEN) as p: + p.write('{} with name "{}" was successfully linked to "{}" with name "{}" of the "{}" Tethys App' + .format(str(service_model), service_uid, linked_setting_model, setting_uid, app_package)) + return True + except ObjectDoesNotExist: + with pretty_output(FG_RED) as p: + p.write('A {0} with ID/Name "{1}" does not exist.'.format(str(linked_setting_model), setting_uid)) + return False From ba48c0c8124ebe6f69904d9f8cc8cc644752e732 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Tue, 10 Oct 2017 21:55:36 +0000 Subject: [PATCH 049/215] sed out expire and warn for session --- docker/setup_tethys.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index 1ba0d4143..30a8e7623 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -218,6 +218,9 @@ sed -i -e "s/'HOST': '127.0.0.1',/'HOST': '${TETHYSBUILD_DB_HOST}',/g" /usr/lib/ sed -i -e 's/BYPASS_TETHYS_HOME_PAGE = False/BYPASS_TETHYS_HOME_PAGE = True/g' /usr/lib/tethys/src/tethys_portal/settings.py sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" /usr/lib/tethys/src/tethys_portal/settings.py +sed -i -e 's/SESSION_SECURITY_WARN_AFTER = 840/SESSION_SECURITY_WARN_AFTER = 25 * 60/g' /usr/lib/tethys/src/tethys_portal/settings.py +sed -i -e 's/SESSION_SECURITY_EXPIRE_AFTER = 900/SESSION_SECURITY_EXPIRE_AFTER = 30 * 60/g' /usr/lib/tethys/src/tethys_portal/settings.py + # DB ROLLS/ETC if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command ""; echo $?) -ne 0 ]]; then # Check if postgres has a password echo "default postgres user has a password set, assuming database is setup correctly..." From 623ac12cf81165ca11fc35ab98a26f37e4eadbd1 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Wed, 11 Oct 2017 18:13:51 +0000 Subject: [PATCH 050/215] mount a volume for keys. --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index ef189d2a7..d9d2432d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ RUN pwd \ && mkdir /usr/lib/tethys/workspaces VOLUME ["/usr/lib/tethys/workspaces"] +VOLUME ["/usr/lib/tethys/keys"] ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh ADD aquaveo_static/images/aquaveo_favicon.ico /usr/lib/tethys/src/static/tethys_portal/images/default_favicon.png From da0ef88142829e61918cd33a8568c9b424f82a8e Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Wed, 11 Oct 2017 18:15:54 +0000 Subject: [PATCH 051/215] fix docker volume syntax --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d9d2432d4..178b3ee1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,7 @@ RUN pwd \ --conda-env-name tethys \ && mkdir /usr/lib/tethys/workspaces -VOLUME ["/usr/lib/tethys/workspaces"] -VOLUME ["/usr/lib/tethys/keys"] +VOLUME ["/usr/lib/tethys/workspaces", "/usr/lib/tethys/keys"] ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh ADD aquaveo_static/images/aquaveo_favicon.ico /usr/lib/tethys/src/static/tethys_portal/images/default_favicon.png From d8dfd1de57b88c206afcc0bc7205def31d532557 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 12 Oct 2017 13:14:47 -0600 Subject: [PATCH 052/215] fix bug with "tethys services remove" --- tethys_apps/cli/services_commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 2967136e1..56dc414be 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -54,7 +54,7 @@ def services_remove_persistent_command(args): persistent_service_id = None try: - persistent_service_id = args.service_id + persistent_service_id = args.service_uid try: persistent_service_id = int(persistent_service_id) @@ -130,7 +130,7 @@ def services_remove_spatial_command(args): spatial_service_id = None try: - spatial_service_id = args.service_id + spatial_service_id = args.service_uid try: spatial_service_id = int(spatial_service_id) From 226f00449e539fae093e3ef8eb578834086587ed Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Thu, 12 Oct 2017 21:23:32 +0000 Subject: [PATCH 053/215] Add new file --- docker/run_tethys.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docker/run_tethys.sh diff --git a/docker/run_tethys.sh b/docker/run_tethys.sh new file mode 100644 index 000000000..e69de29bb From 05f60c47f5a7cb426a490524fe764d25a1889b62 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Thu, 12 Oct 2017 21:26:44 +0000 Subject: [PATCH 054/215] add startup code. --- docker/run_tethys.sh | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docker/run_tethys.sh b/docker/run_tethys.sh index e69de29bb..605c1d07b 100644 --- a/docker/run_tethys.sh +++ b/docker/run_tethys.sh @@ -0,0 +1,43 @@ +if [ ! -f "/usr/lib/tethys/setup_complete" ] ; +then + + echo Starting TethysCore Setup + apt-get update && apt-get install -y gcc + bash setup_tethys.sh \ + --allowed-host ${TETHYSBUILD_ALLOWED_HOST:-127.0.0.1} \ + --python-version ${TETHYSBUILD_PY_VERSION:-2} \ + --db-username ${TETHYSBUILD_DB_USERNAME:-tethys_default} \ + --db-password ${TETHYSBUILD_DB_PASSWORD:-pass} \ + --db-host ${TETHYSBUILD_DB_HOST:-127.0.0.1} \ + --db-port ${TETHYSBUILD_DB_PORT:-5432} \ + --superuser ${TETHYSBUILD_SUPERUSER:-tethys_super} \ + --superuser-pass ${TETHYSBUILD_SUPERUSER_PASS:-admin} \ + --tethys-home ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys} \ + echo TethysCore Setup Complete + cd /var/www/tethys/apps + echo Setting Up CityWater + + echo "----------------------------------------------------------------------------------" + echo "Setting Up NGINX" + echo "----------------------------------------------------------------------------------" + NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') + NGINX_GROUP=${NGINX_USER} + NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') + + chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src + chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/static + + /bin/bash -c 'mkdir -p /run/uwsgi; chown www-data:www-data /run/uwsgi' + + touch /usr/lib/tethys/setup_complete + +fi + + +echo "----------------------------------------------------------------------------------" +echo Starting Server +echo "----------------------------------------------------------------------------------" +nohup /usr/lib/tethys/miniconda/envs/tethys/bin/uwsgi --yaml /usr/lib/tethys/src/tethys_portal/tethys_uwsgi.yml --uid www-data --gid www-data& +nginx -g 'daemon off;' + +. deactivate From 8a686ee9b8ecc986c6273fa7590844c634daaed6 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Thu, 12 Oct 2017 21:29:58 +0000 Subject: [PATCH 055/215] run tethys --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 178b3ee1c..b13777ae4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /usr/lib/tethys # Add files to docker image ADD docker/install_tethys.sh /usr/lib/tethys/install_tethys.sh +ADD docker/run_tethys.sh /usr/lib/tethys/run_tethys.sh ADD . /usr/lib/tethys/src # Arguments @@ -38,4 +39,4 @@ EXPOSE 80 ENV PATH ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda}/envs/tethys/bin:$PATH # Install Tethys -CMD echo Error: Not a Standalone Docker +CMD bash /usr/lib/tethys/run_tethys.sh From 069e68bcc45a9c454aa80daf6afee05714e12831 Mon Sep 17 00:00:00 2001 From: nswain Date: Fri, 13 Oct 2017 14:29:06 -0600 Subject: [PATCH 056/215] Added PUBLIC_HOST setting to settings.py --- docker/setup_tethys.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh index 30a8e7623..bc4f5392c 100644 --- a/docker/setup_tethys.sh +++ b/docker/setup_tethys.sh @@ -220,6 +220,7 @@ sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/ sed -i -e 's/SESSION_SECURITY_WARN_AFTER = 840/SESSION_SECURITY_WARN_AFTER = 25 * 60/g' /usr/lib/tethys/src/tethys_portal/settings.py sed -i -e 's/SESSION_SECURITY_EXPIRE_AFTER = 900/SESSION_SECURITY_EXPIRE_AFTER = 30 * 60/g' /usr/lib/tethys/src/tethys_portal/settings.py +echo "PUBLIC_HOST = \"${TETHYSBUILD_PUBLIC_HOST}\"" >> /usr/lib/tethys/src/tethys_portal/settings.py # DB ROLLS/ETC if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command ""; echo $?) -ne 0 ]]; then # Check if postgres has a password From cac5cd47415f0609dbec6b3a879e9dacbb75fdb8 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 16 Oct 2017 10:21:46 -0600 Subject: [PATCH 057/215] add --force args to commands; check if Scheduler w/name exists --- tethys_apps/cli/__init__.py | 4 +++ tethys_apps/cli/scheduler_commands.py | 30 ++++++++++++---- tethys_apps/cli/services_commands.py | 52 ++++++++++++++++++--------- 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index f106dc4ab..2bfc400b1 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -269,6 +269,7 @@ def tethys_command(): # tethys schedulers remove schedulers_remove = scheduler_subparsers.add_parser('remove', help='Remove a Scheduler.') schedulers_remove.add_argument('scheduler_name', help='The unique name of the Scheduler that you are removing.') + schedulers_remove.add_argument('-f', '--force', action='store_true', help='Force removal without confirming.') schedulers_remove.set_defaults(func=schedulers_remove_command) # SERVICES COMMANDS @@ -284,6 +285,8 @@ def tethys_command(): help='Remove a Persistent Store Service.') services_remove_persistent.add_argument('service_uid', help='The ID or name of the Persistent Store Service ' 'that you are removing.') + services_remove_persistent.add_argument('-f', '--force', action='store_true', + help='Force removal without confirming.') services_remove_persistent.set_defaults(func=services_remove_persistent_command) # tethys services remove spatial @@ -291,6 +294,7 @@ def tethys_command(): help='Remove a Spatial Dataset Service.') services_remove_spatial.add_argument('service_uid', help='The ID or name of the Spatial Dataset Service ' 'that you are removing.') + services_remove_spatial.add_argument('-f', '--force', action='store_true', help='Force removal without confirming.') services_remove_spatial.set_defaults(func=services_remove_spatial_command) # tethys services create diff --git a/tethys_apps/cli/scheduler_commands.py b/tethys_apps/cli/scheduler_commands.py index fc44704b4..d6af5ab32 100644 --- a/tethys_apps/cli/scheduler_commands.py +++ b/tethys_apps/cli/scheduler_commands.py @@ -12,6 +12,12 @@ def scheduler_create_command(args): private_key_path = args.private_key_path private_key_pass = args.private_key_pass + existing_scheduler = Scheduler.objects.filter(name=name).first() + if existing_scheduler: + with pretty_output(FG_RED) as p: + p.write('A Scheduler with name "{}" already exists. Command aborted.'.format(name)) + exit(1) + scheduler = Scheduler( name=name, host=host, @@ -53,21 +59,31 @@ def schedulers_remove_command(args): from tethys_compute.models import Scheduler scheduler = None name = args.scheduler_name + force = args.force try: scheduler = Scheduler.objects.get(name=name) except ObjectDoesNotExist: with pretty_output(FG_RED) as p: p.write('Scheduler with name "{}" does not exist.\nCommand aborted.'.format(name)) + exit(1) - proceed = raw_input('Are you sure you want to delete this Scheduler? [y/n]: ') - while proceed not in ['y', 'n', 'Y', 'N']: - proceed = raw_input('Please enter either "y" or "n": ') - - if proceed in ['y', 'Y']: + if force: scheduler.delete() with pretty_output(FG_GREEN) as p: p.write('Successfully removed Scheduler "{0}"!'.format(name)) + exit(0) else: - with pretty_output(FG_RED) as p: - p.write('Aborted. Scheduler not removed.') + proceed = raw_input('Are you sure you want to delete this Scheduler? [y/n]: ') + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + scheduler.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed Scheduler "{0}"!'.format(name)) + exit(0) + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. Scheduler not removed.') + exit(1) diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 56dc414be..fe6633b70 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -55,6 +55,7 @@ def services_remove_persistent_command(args): try: persistent_service_id = args.service_uid + force = args.force try: persistent_service_id = int(persistent_service_id) @@ -62,20 +63,29 @@ def services_remove_persistent_command(args): except ValueError: service = PersistentStoreService.objects.get(name=persistent_service_id) - proceed = raw_input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') - while proceed not in ['y', 'n', 'Y', 'N']: - proceed = raw_input('Please enter either "y" or "n": ') - - if proceed in ['y', 'Y']: + if force: service.delete() with pretty_output(FG_GREEN) as p: p.write('Successfully removed Persistent Store Service {0}!'.format(persistent_service_id)) + exit(0) else: - with pretty_output(FG_RED) as p: - p.write('Aborted. Persistent Store Service not removed.') + proceed = raw_input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + service.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed Persistent Store Service {0}!'.format(persistent_service_id)) + exit(0) + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. Persistent Store Service not removed.') + exit(1) except ObjectDoesNotExist: with pretty_output(FG_RED) as p: p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(persistent_service_id)) + exit(1) def services_create_spatial_command(args): @@ -131,6 +141,7 @@ def services_remove_spatial_command(args): try: spatial_service_id = args.service_uid + force = args.force try: spatial_service_id = int(spatial_service_id) @@ -138,20 +149,29 @@ def services_remove_spatial_command(args): except ValueError: service = SpatialDatasetService.objects.get(name=spatial_service_id) - proceed = raw_input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') - while proceed not in ['y', 'n', 'Y', 'N']: - proceed = raw_input('Please enter either "y" or "n": ') - - if proceed in ['y', 'Y']: + if force: service.delete() with pretty_output(FG_GREEN) as p: - p.write('Successfully removed Persistent Store Service {0}!'.format(spatial_service_id)) + p.write('Successfully removed Spatial Dataset Service {0}!'.format(persistent_service_id)) + exit(0) else: - with pretty_output(FG_RED) as p: - p.write('Aborted. Persistent Store Service not removed.') + proceed = raw_input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') + while proceed not in ['y', 'n', 'Y', 'N']: + proceed = raw_input('Please enter either "y" or "n": ') + + if proceed in ['y', 'Y']: + service.delete() + with pretty_output(FG_GREEN) as p: + p.write('Successfully removed Spatial Dataset Service {0}!'.format(spatial_service_id)) + exit(0) + else: + with pretty_output(FG_RED) as p: + p.write('Aborted. Spatial Dataset Service not removed.') + exit(1) except ObjectDoesNotExist: with pretty_output(FG_RED) as p: - p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(spatial_service_id)) + p.write('A Spatial Dataset Service with ID/Name "{0}" does not exist.'.format(spatial_service_id)) + exit(1) def services_list_command(args): From b2d4d04719a39fb0f7a7aa2faaacef2172066a9a Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Tue, 31 Oct 2017 18:12:04 +0000 Subject: [PATCH 058/215] Use python slim --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b13777ae4..32b502a38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use an official Python runtime as a parent image -FROM python:2 +FROM python:2-slim-stretch WORKDIR /usr/lib/tethys From b255d5c75e8d75ce56a7790f7bf007459cf8e1c3 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Wed, 1 Nov 2017 12:32:39 -0600 Subject: [PATCH 059/215] Ignore swp files from vim --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index a570488c1..43e239725 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ docs/_build tethys_gizmos/static/tethys_gizmos/less/bower_components/* .coverage tests/coverage_html_report +.*.swp +.DS_Store From 2f02b5f21a591c873d3c3223f177347bedf7de3c Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 09:18:18 -0600 Subject: [PATCH 060/215] Use salt for init (First Pass) --- docker/salt/setup/activate.jinja | 0 docker/salt/setup/init.sls | 116 +++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 docker/salt/setup/activate.jinja create mode 100644 docker/salt/setup/init.sls diff --git a/docker/salt/setup/activate.jinja b/docker/salt/setup/activate.jinja new file mode 100644 index 000000000..e69de29bb diff --git a/docker/salt/setup/init.sls b/docker/salt/setup/init.sls new file mode 100644 index 000000000..a91ea9dd6 --- /dev/null +++ b/docker/salt/setup/init.sls @@ -0,0 +1,116 @@ +{%- set ACTIVATE_DIR = salt['environ.get']('ACTIVATE_DIR') -%} +{%- set DEACTIVATE_DIR = salt['environ.get']('DEACTIVATE_DIR') -%} +{%- set ACTIVATE_SCRIPT = salt['environ.get']('ACTIVATE_SCRIPT') -%} +{%- set DEACTIVATE_SCRIPT = salt['environ.get']('DEACTIVATE_SCRIPT') -%} +{%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} +{%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} +{%- set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') -%} +{%- set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') -%} +{%- set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') -%} +{%- set TETHYSBUILD_PUBLIC_HOST = salt['environ.get']('TETHYSBUILD_PUBLIC_HOST') -%} +{%- set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') -%} +{%- set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') -%} +{%- set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') -%} +{%- set BLERG = salt['environ.get']('BLERG') -%} +{%- set BLERG = salt['environ.get']('BLERG') -%} +{%- set BLERG = salt['environ.get']('BLERG') -%} +{%- set BLERG = salt['environ.get']('BLERG') -%} + +ACTIVATE_DIR: + file.directory: + - name: {{ ACTIVATE_DIR }} + - makedirs: True + +DEACTIVATE_DIR: + file.directory: + - name: {{ DEACTIVATE_DIR }} + - makedirs: True + +~/.bashrc: + file.append: + - text: | + # Tethys Platform + alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} + +ACTIVATE_SCRIPT: + file.managed: + - name: {{ ACTIVATE_SCRIPT }} + - source: "salt://setup/files/activate.jinja" + - template: jinja + +DEACTIVATE_SCRIPT: + file.managed: + - name: {{ DEACTIVATE_SCRIPT }} + - source: "salt://setup/files/deactivate.jinja" + - template: jinja + +Generate Tethys Settings: + cmd.run: + - name | + tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite + +Edit Tethys Settings File (HOST): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "'HOST': '127.0.0.1'" + - repl: {{ TETHYSBUILD_DB_HOST }} + +Edit Tethys Settings File (HOME_PAGE): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "BYPASS_TETHYS_HOME_PAGE = False" + - repl: "BYPASS_TETHYS_HOME_PAGE = True" + +Edit Tethys Settings File (SESSION_WARN): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "SESSION_SECURITY_WARN_AFTER = 840" + - repl: "SESSION_SECURITY_WARN_AFTER = 25 * 60" + +Edit Tethys Settings File (SESSION_EXPIRE): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "SESSION_SECURITY_EXPIRE_AFTER = 900" + - repl: "SESSION_SECURITY_EXPIRE_AFTER = 30 * 60" + +Edit Tethys Settings File (SESSION_EXPIRE): + file.append: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - text: "PUBLIC_HOST = \"{{ TETHYSBUILD_PUBLIC_HOST }}\"" + +Generate NGINX Settings: + cmd.run: + - tethys gen nginx --overwrite + +Generate uwsgi Settings: + cmd.run: + - tethys gen uwsgi_settings --overwrite + +Generate uwsgi service: + cmd.run: + - tethys gen uwsgi_service --overwrite + file.managed: + - name: /var/log/uwsgi/tethys.log + - makedirs: True + +Prepare Database: + postgres_user.present: + - name: {{ TETHYS_DB_USERNAME }} + - password: {{ TETHYS_DB_PASSWORD }} + - login: True + postgres_database.present: + - name: {{ TETHYS_DB_USERNAME }} + cmd.run: + - name: tethys manage syncdb + +Create Super User: + cmd.run: + - name: | + echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')" | python manage.py shell + - cwd: {{ TETHYS_HOME }} + +Link NGINX Config: + file.symlink: + - name: /etc/nginx/sites-enabled + - target: ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + From 7a32a96bb00e38a24cf54cae4d7f4f41c575a19c Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 09:18:54 -0600 Subject: [PATCH 061/215] Optimize Docker Build Process (First Pass) --- Dockerfile | 142 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 105 insertions(+), 37 deletions(-) diff --git a/Dockerfile b/Dockerfile index 32b502a38..073663979 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,42 +1,110 @@ # Use an official Python runtime as a parent image FROM python:2-slim-stretch -WORKDIR /usr/lib/tethys - -# Add files to docker image -ADD docker/install_tethys.sh /usr/lib/tethys/install_tethys.sh -ADD docker/run_tethys.sh /usr/lib/tethys/run_tethys.sh -ADD . /usr/lib/tethys/src - -# Arguments -# ARG TETHYSBUILD_BRANCH=release -# ARG TETHYSBUILD_PY_VERSION=2 -# ARG TETHYSBUILD_TETHYS_HOME=/usr/lib/tethys -# ARG TETHYSBUILD_CONDA_HOME=/usr/lib/tethys/miniconda -# ARG TETHYSBUILD_CONDA_ENV_NAME=tethys - -# Run Scripts to Get Files -RUN pwd \ - && apt-get update \ - && apt-get --assume-yes install wget bzip2 git nginx \ - && bash install_tethys.sh \ - --python-version 2 \ - --tethys-home /usr/lib/tethys \ - --conda-env-name tethys \ - && mkdir /usr/lib/tethys/workspaces - -VOLUME ["/usr/lib/tethys/workspaces", "/usr/lib/tethys/keys"] - -ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh -ADD aquaveo_static/images/aquaveo_favicon.ico /usr/lib/tethys/src/static/tethys_portal/images/default_favicon.png -ADD aquaveo_static/images/aquaveo_logo.png /usr/lib/tethys/src/static/tethys_portal/images/tethys-logo-75.png -ADD aquaveo_static/tethys_main.css /usr/lib/tethys/src/static/tethys_portal/css/tethys_main.css - -# Make port 8000 available to the outside world -EXPOSE 80 +##################### +# Default Variables # +##################### + +# Tethys +ENV TETHYS_HOME "/usr/lib/tethys" +ENV TETHYS_PORT 80 +ENV TETHYS_DB_USERNAME 'tethys_default' +ENV TETHYS_DB_PASSWORD 'pass' +ENV TETHYS_DB_HOST '172.17.0.1' +ENV TETHYS_DB_PORT 5432 +ENV TETHYS_SUPER_USER 'admin' +ENV TETHYS_SUPER_USER_EMAIL '' +ENV TETHYS_SUPER_USER_PASS 'pass' +ENV TETHYS_CONDA_HOME ${CONDA_HOME} +ENV TETHYS_CONDA_ENV_NAME ${CONDA_ENV_NAME} + +# Misc +ENV ALLOWED_HOST 127.0.0.1 +ENV BASH_PROFILE ".bashrc" +ENV CONDA_HOME "${TETHYS_HOME}/miniconda" +ENV CONDA_ENV_NAME tethys +ENV MINICONDA_URL "https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" +ENV PYTHON_VERSION 2 + +# Set paths for environment activate/deactivate scripts +ENV ACTIVATE_DIR "${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" +ENV DEACTIVATE_DIR "${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" +ENV ACTIVATE_SCRIPT "${ACTIVATE_DIR}/tethys-activate.sh" +ENV DEACTIVATE_SCRIPT "${DEACTIVATE_DIR}/tethys-deactivate.sh" + + +######### +# SETUP # +######### +RUN mkdir -p "${TETHYS_HOME}/src" + +# Speed up APT installs +RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup +RUN echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache + +# Install APT packages +RUN apt-get update && apt-get -y install wget \ + && wget -O - https://repo.saltstack.com/apt/debian/9/amd64/latest/SALTSTACK-GPG-KEY.pub | apt-key add - \ + && echo "deb http://repo.saltstack.com/apt/debian/9/amd64/latest stretch main" > /etc/apt/sources.list.d/saltstack.list +RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion +RUN rm -f /etc/nginx/sites-enabled/default -# Configure Tethys -ENV PATH ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda}/envs/tethys/bin:$PATH +# Install Miniconda +RUN wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" +RUN bash ${TETHYS_HOME}/miniconda.sh -b -p "${CONDA_HOME}" + +# Setup Conda Environment +ADD environment_py2.yml ${TETHYS_HOME}/src/ +WORKDIR ${TETHYS_HOME}/src +RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ENV_NAME}" -f "environment_py${PYTHON_VERSION}.yml" + +########### +# INSTALL # +########### +# ADD files from repo +ADD resources ${TETHYS_HOME}/src/ +ADD templates ${TETHYS_HOME}/src/ +ADD tethys_apps ${TETHYS_HOME}/src/ +ADD tethys_compute ${TETHYS_HOME}/src/ +ADD tethys_config ${TETHYS_HOME}/src/ +ADD tethys_gizmos ${TETHYS_HOME}/src/ +ADD tethys_portal ${TETHYS_HOME}/src/ +ADD tethys_sdk ${TETHYS_HOME}/src/ +ADD tethys_services ${TETHYS_HOME}/src/ +ADD *.py ${TETHYS_HOME}/src/ + +# Run Installer +RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ + ; python setup.py develop \ + ; conda install -c conda-forge uwsgi -y \ + ; mkdir ${TETHYS_HOME}/workspaces + +# Add static files +ADD static ${TETHYS_HOME}/src/ +ADD aquaveo_static/images/aquaveo_favicon.ico ${TETHYS_HOME}/src/static/tethys_portal/images/default_favicon.png +ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_portal/images/tethys-logo-75.png +ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css + +# Give NGINX Permission +RUN NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') \ + find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} + +############ +# CLEAN UP # +############ +RUN apt-get -y remove wget gcc \ + ; apt-get -y autoremove \ + ; apt-get -y clean + +######################### +# CONFIGURE ENVIRONMENT # +######################### +ENV PATH ${CONDA_HOME}/miniconda}/envs/tethys/bin:$PATH +VOLUME ["${TETHYS_HOME}/workspaces", "${TETHYS_HOME}/keys"] +EXPOSE 80 -# Install Tethys -CMD bash /usr/lib/tethys/run_tethys.sh +######## +# RUN! # +######## +CMD echo "Sleeping Forever" +CMD while [ 1 ]; do sleep 1; done From 14414f6c3d33a6d3e5319cbc055d336d5de82922 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 11:05:24 -0600 Subject: [PATCH 062/215] Consolidate ENVs --- Dockerfile | 61 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/Dockerfile b/Dockerfile index 073663979..ab8fee60e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,31 +6,29 @@ FROM python:2-slim-stretch ##################### # Tethys -ENV TETHYS_HOME "/usr/lib/tethys" -ENV TETHYS_PORT 80 -ENV TETHYS_DB_USERNAME 'tethys_default' -ENV TETHYS_DB_PASSWORD 'pass' -ENV TETHYS_DB_HOST '172.17.0.1' -ENV TETHYS_DB_PORT 5432 -ENV TETHYS_SUPER_USER 'admin' -ENV TETHYS_SUPER_USER_EMAIL '' -ENV TETHYS_SUPER_USER_PASS 'pass' -ENV TETHYS_CONDA_HOME ${CONDA_HOME} -ENV TETHYS_CONDA_ENV_NAME ${CONDA_ENV_NAME} +ENV TETHYS_HOME="/usr/lib/tethys" \ + TETHYS_PORT=80 \ + TETHYS_DB_USERNAME='tethys_default' \ + TETHYS_DB_PASSWORD='pass' \ + TETHYS_DB_HOST='172.17.0.1' \ + TETHYS_DB_PORT=5432 \ + TETHYS_SUPER_USER='admin' \ + TETHYS_SUPER_USER_EMAIL='' \ + TETHYS_SUPER_USER_PASS='pass' \ + TETHYS_CONDA_HOME=${CONDA_HOME} \ + TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} # Misc -ENV ALLOWED_HOST 127.0.0.1 -ENV BASH_PROFILE ".bashrc" -ENV CONDA_HOME "${TETHYS_HOME}/miniconda" -ENV CONDA_ENV_NAME tethys -ENV MINICONDA_URL "https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" -ENV PYTHON_VERSION 2 - -# Set paths for environment activate/deactivate scripts -ENV ACTIVATE_DIR "${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" -ENV DEACTIVATE_DIR "${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" -ENV ACTIVATE_SCRIPT "${ACTIVATE_DIR}/tethys-activate.sh" -ENV DEACTIVATE_SCRIPT "${DEACTIVATE_DIR}/tethys-deactivate.sh" +ENV ALLOWED_HOST=127.0.0.1 \ + BASH_PROFILE=".bashrc" \ + CONDA_HOME="${TETHYS_HOME}/miniconda" \ + CONDA_ENV_NAME=tethys \ + MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" \ + PYTHON_VERSION=2 \ + ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" \ + DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" \ + ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" \ + DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" ######### @@ -56,7 +54,7 @@ RUN bash ${TETHYS_HOME}/miniconda.sh -b -p "${CONDA_HOME}" # Setup Conda Environment ADD environment_py2.yml ${TETHYS_HOME}/src/ WORKDIR ${TETHYS_HOME}/src -RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ENV_NAME}" -f "environment_py${PYTHON_VERSION}.yml" +RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ _NAME}" -f="environment_py${PYTHON_VERSION}.yml" \ ########### # INSTALL # @@ -74,7 +72,7 @@ ADD tethys_services ${TETHYS_HOME}/src/ ADD *.py ${TETHYS_HOME}/src/ # Run Installer -RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ +RUN . ${CONDA_HOME}/bin/activate ${CONDA_ _NAME}=\ \ ; python setup.py develop \ ; conda install -c conda-forge uwsgi -y \ ; mkdir ${TETHYS_HOME}/workspaces @@ -85,6 +83,15 @@ ADD aquaveo_static/images/aquaveo_favicon.ico ${TETHYS_HOME}/src/static/tethys_p ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_portal/images/tethys-logo-75.png ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css +# Generate Inital Settings Files +RUN . ${CONDA_HOME}/bin/activate ${CONDA_ _NAME}=\ \ + ; tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite \ + ; sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" ${TETHYS_HOME}/src/tethys_portal/settings.py \ + ; tethys gen nginx --overwrite \ + ; tethys gen uwsgi_settings --overwrite \ + ; tethys gen uwsgi_service --overwrite + + # Give NGINX Permission RUN NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') \ find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} @@ -97,9 +104,9 @@ RUN apt-get -y remove wget gcc \ ; apt-get -y clean ######################### -# CONFIGURE ENVIRONMENT # +# CONFIGURE ENVIRONMENT# ######################### -ENV PATH ${CONDA_HOME}/miniconda}/envs/tethys/bin:$PATH +ENV PATH ${CONDA_HOME}/miniconda}/envs/tethys/bin:$PATH VOLUME ["${TETHYS_HOME}/workspaces", "${TETHYS_HOME}/keys"] EXPOSE 80 From 05739e8c1bcc72c6c842a97a6c7c396513ea4338 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 11:09:44 -0600 Subject: [PATCH 063/215] Fix Typos --- Dockerfile | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index ab8fee60e..82740677a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,10 +16,10 @@ ENV TETHYS_HOME="/usr/lib/tethys" \ TETHYS_SUPER_USER_EMAIL='' \ TETHYS_SUPER_USER_PASS='pass' \ TETHYS_CONDA_HOME=${CONDA_HOME} \ - TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} + TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} \ # Misc -ENV ALLOWED_HOST=127.0.0.1 \ + ALLOWED_HOST=127.0.0.1 \ BASH_PROFILE=".bashrc" \ CONDA_HOME="${TETHYS_HOME}/miniconda" \ CONDA_ENV_NAME=tethys \ @@ -54,7 +54,7 @@ RUN bash ${TETHYS_HOME}/miniconda.sh -b -p "${CONDA_HOME}" # Setup Conda Environment ADD environment_py2.yml ${TETHYS_HOME}/src/ WORKDIR ${TETHYS_HOME}/src -RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ _NAME}" -f="environment_py${PYTHON_VERSION}.yml" \ +RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ENV_NAME}" -f "environment_py${PYTHON_VERSION}.yml" ########### # INSTALL # @@ -72,7 +72,7 @@ ADD tethys_services ${TETHYS_HOME}/src/ ADD *.py ${TETHYS_HOME}/src/ # Run Installer -RUN . ${CONDA_HOME}/bin/activate ${CONDA_ _NAME}=\ \ +RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; python setup.py develop \ ; conda install -c conda-forge uwsgi -y \ ; mkdir ${TETHYS_HOME}/workspaces @@ -84,8 +84,8 @@ ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_port ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css # Generate Inital Settings Files -RUN . ${CONDA_HOME}/bin/activate ${CONDA_ _NAME}=\ \ - ; tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite \ +RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ + ; tethys gen settings --production --allowed-host ${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite \ ; sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" ${TETHYS_HOME}/src/tethys_portal/settings.py \ ; tethys gen nginx --overwrite \ ; tethys gen uwsgi_settings --overwrite \ @@ -106,7 +106,7 @@ RUN apt-get -y remove wget gcc \ ######################### # CONFIGURE ENVIRONMENT# ######################### -ENV PATH ${CONDA_HOME}/miniconda}/envs/tethys/bin:$PATH +ENV PATH ${CONDA_HOME}/miniconda/envs/tethys/bin:$PATH VOLUME ["${TETHYS_HOME}/workspaces", "${TETHYS_HOME}/keys"] EXPOSE 80 From e8ae46d1391ba4f6fa10e08a64c16ecb1142ad45 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 11:11:00 -0600 Subject: [PATCH 064/215] Merge some RUNs --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 82740677a..ae1b1a15f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,8 +37,8 @@ ENV TETHYS_HOME="/usr/lib/tethys" \ RUN mkdir -p "${TETHYS_HOME}/src" # Speed up APT installs -RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup -RUN echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache +RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ + ; echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache # Install APT packages RUN apt-get update && apt-get -y install wget \ @@ -48,8 +48,8 @@ RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion RUN rm -f /etc/nginx/sites-enabled/default # Install Miniconda -RUN wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" -RUN bash ${TETHYS_HOME}/miniconda.sh -b -p "${CONDA_HOME}" +RUN wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" \ + && bash ${TETHYS_HOME}/miniconda.sh -b -p "${CONDA_HOME}" # Setup Conda Environment ADD environment_py2.yml ${TETHYS_HOME}/src/ From 607c750d4a7527ed18adff7a382f7849a2402ace Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 11:52:25 -0600 Subject: [PATCH 065/215] Use bash when activating miniconda --- Dockerfile | 55 ++++++++++++++++++++++++++---------------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/Dockerfile b/Dockerfile index ae1b1a15f..a1b0c1eb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,20 @@ # Use an official Python runtime as a parent image FROM python:2-slim-stretch -##################### -# Default Variables # -##################### - -# Tethys +############### +# ENVIRONMENT # +############### ENV TETHYS_HOME="/usr/lib/tethys" \ TETHYS_PORT=80 \ - TETHYS_DB_USERNAME='tethys_default' \ - TETHYS_DB_PASSWORD='pass' \ - TETHYS_DB_HOST='172.17.0.1' \ + TETHYS_DB_USERNAME="tethys_default" \ + TETHYS_DB_PASSWORD="pass" \ + TETHYS_DB_HOST="172.17.0.1" \ TETHYS_DB_PORT=5432 \ - TETHYS_SUPER_USER='admin' \ - TETHYS_SUPER_USER_EMAIL='' \ - TETHYS_SUPER_USER_PASS='pass' \ + TETHYS_SUPER_USER="admin" \ + TETHYS_SUPER_USER_EMAIL="" \ + TETHYS_SUPER_USER_PASS="pass" \ TETHYS_CONDA_HOME=${CONDA_HOME} \ TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} \ - -# Misc ALLOWED_HOST=127.0.0.1 \ BASH_PROFILE=".bashrc" \ CONDA_HOME="${TETHYS_HOME}/miniconda" \ @@ -60,22 +56,23 @@ RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ENV_NAME}" -f "environment_py # INSTALL # ########### # ADD files from repo -ADD resources ${TETHYS_HOME}/src/ -ADD templates ${TETHYS_HOME}/src/ -ADD tethys_apps ${TETHYS_HOME}/src/ -ADD tethys_compute ${TETHYS_HOME}/src/ -ADD tethys_config ${TETHYS_HOME}/src/ -ADD tethys_gizmos ${TETHYS_HOME}/src/ -ADD tethys_portal ${TETHYS_HOME}/src/ -ADD tethys_sdk ${TETHYS_HOME}/src/ -ADD tethys_services ${TETHYS_HOME}/src/ +ADD resources ${TETHYS_HOME}/src/resources/ +ADD templates ${TETHYS_HOME}/src/templates/ +ADD tethys_apps ${TETHYS_HOME}/src/tethys_apps/ +ADD tethys_compute ${TETHYS_HOME}/src/tethys_compute/ +ADD tethys_config ${TETHYS_HOME}/src/tethys_config/ +ADD tethys_gizmos ${TETHYS_HOME}/src/tethys_gizmos/ +ADD tethys_portal ${TETHYS_HOME}/src/tethys_portal/ +ADD tethys_sdk ${TETHYS_HOME}/src/tethys_sdk/ +ADD tethys_services ${TETHYS_HOME}/src/tethys_services/ +ADD README.rst ${TETHYS_HOME}/src/ ADD *.py ${TETHYS_HOME}/src/ # Run Installer -RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ +RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; python setup.py develop \ ; conda install -c conda-forge uwsgi -y \ - ; mkdir ${TETHYS_HOME}/workspaces + ; mkdir ${TETHYS_HOME}/workspaces' # Add static files ADD static ${TETHYS_HOME}/src/ @@ -84,17 +81,17 @@ ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_port ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css # Generate Inital Settings Files -RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ +RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; tethys gen settings --production --allowed-host ${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite \ - ; sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" ${TETHYS_HOME}/src/tethys_portal/settings.py \ + ; sed -i -e "s:#TETHYS_WORKSPACES_ROOT = .*$:TETHYS_WORKSPACES_ROOT = \"/usr/lib/tethys/workspaces\":" ${TETHYS_HOME}/src/tethys_portal/settings.py \ ; tethys gen nginx --overwrite \ ; tethys gen uwsgi_settings --overwrite \ - ; tethys gen uwsgi_service --overwrite + ; tethys gen uwsgi_service --overwrite' # Give NGINX Permission -RUN NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') \ - find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} +RUN export NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') \ + ; find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} ############ # CLEAN UP # From 8d673ff18b2330d4bac6ee7f48ccdfa4291df699 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 12:52:09 -0600 Subject: [PATCH 066/215] Break down some env vars --- docker/salt/setup/{ => files}/activate.jinja | 0 docker/salt/setup/init.sls | 30 ++++++++------------ 2 files changed, 12 insertions(+), 18 deletions(-) rename docker/salt/setup/{ => files}/activate.jinja (100%) diff --git a/docker/salt/setup/activate.jinja b/docker/salt/setup/files/activate.jinja similarity index 100% rename from docker/salt/setup/activate.jinja rename to docker/salt/setup/files/activate.jinja diff --git a/docker/salt/setup/init.sls b/docker/salt/setup/init.sls index a91ea9dd6..78aeb89c0 100644 --- a/docker/salt/setup/init.sls +++ b/docker/salt/setup/init.sls @@ -1,7 +1,5 @@ -{%- set ACTIVATE_DIR = salt['environ.get']('ACTIVATE_DIR') -%} -{%- set DEACTIVATE_DIR = salt['environ.get']('DEACTIVATE_DIR') -%} -{%- set ACTIVATE_SCRIPT = salt['environ.get']('ACTIVATE_SCRIPT') -%} -{%- set DEACTIVATE_SCRIPT = salt['environ.get']('DEACTIVATE_SCRIPT') -%} +{%- set ACTIVATE_DIR = salt['environ.get']('CONDA_HOME')/envs/salt['environ.get']('CONDA_ENV_NAME')/etc/conda/activate.d -%} +{%- set DEACTIVATE_DIR = salt['environ.get']('CONDA_HOME')/envs/salt['environ.get']('CONDA_ENV_NAME')/etc/conda/deactivate.d -%} {%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} {%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} {%- set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') -%} @@ -16,15 +14,23 @@ {%- set BLERG = salt['environ.get']('BLERG') -%} {%- set BLERG = salt['environ.get']('BLERG') -%} -ACTIVATE_DIR: +ACTIVATE: file.directory: - name: {{ ACTIVATE_DIR }} - makedirs: True + file.managed: + - name: {{ ACTIVATE_DIR/tethys-activate.sh }} + - source: "salt://setup/files/activate.jinja" + - template: jinja -DEACTIVATE_DIR: +DEACTIVATE: file.directory: - name: {{ DEACTIVATE_DIR }} - makedirs: True + file.managed: + - name: {{ DEACTIVATE_DIR/tethys-deactivate.sh }} + - source: "salt://setup/files/deactivate.jinja" + - template: jinja ~/.bashrc: file.append: @@ -32,18 +38,6 @@ DEACTIVATE_DIR: # Tethys Platform alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} -ACTIVATE_SCRIPT: - file.managed: - - name: {{ ACTIVATE_SCRIPT }} - - source: "salt://setup/files/activate.jinja" - - template: jinja - -DEACTIVATE_SCRIPT: - file.managed: - - name: {{ DEACTIVATE_SCRIPT }} - - source: "salt://setup/files/deactivate.jinja" - - template: jinja - Generate Tethys Settings: cmd.run: - name | From fc8e73030f566ef6198e3dd996968b3df16aa313 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 12:52:31 -0600 Subject: [PATCH 067/215] Split out ENV groups that depend on eachother --- Dockerfile | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index a1b0c1eb7..f6d53f3ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,25 +12,20 @@ ENV TETHYS_HOME="/usr/lib/tethys" \ TETHYS_DB_PORT=5432 \ TETHYS_SUPER_USER="admin" \ TETHYS_SUPER_USER_EMAIL="" \ - TETHYS_SUPER_USER_PASS="pass" \ - TETHYS_CONDA_HOME=${CONDA_HOME} \ - TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} \ - ALLOWED_HOST=127.0.0.1 \ + TETHYS_SUPER_USER_PASS="pass" +# Misc +ENV ALLOWED_HOST=127.0.0.1 \ BASH_PROFILE=".bashrc" \ CONDA_HOME="${TETHYS_HOME}/miniconda" \ CONDA_ENV_NAME=tethys \ MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" \ - PYTHON_VERSION=2 \ - ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" \ - DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" \ - ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" \ - DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" - + PYTHON_VERSION=2 ######### # SETUP # ######### RUN mkdir -p "${TETHYS_HOME}/src" +WORKDIR ${TETHYS_HOME} # Speed up APT installs RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ @@ -110,5 +105,6 @@ EXPOSE 80 ######## # RUN! # ######## +WORKDIR ${TETHYS_HOME} CMD echo "Sleeping Forever" CMD while [ 1 ]; do sleep 1; done From 31626b44ab6a93461473594c6ef3736270d8ea1d Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 14:58:46 -0600 Subject: [PATCH 068/215] Add salt (Second Pass) --- Dockerfile | 11 ++++++-- docker/salt/config | 1 + docker/salt/setup/files/activate.jinja | 0 docker/salt/{setup => states}/init.sls | 37 +++++++++----------------- docker/salt/states/top.sls | 3 +++ 5 files changed, 25 insertions(+), 27 deletions(-) create mode 100644 docker/salt/config delete mode 100644 docker/salt/setup/files/activate.jinja rename docker/salt/{setup => states}/init.sls (75%) create mode 100644 docker/salt/states/top.sls diff --git a/Dockerfile b/Dockerfile index f6d53f3ad..f0e5b813b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -102,9 +102,16 @@ ENV PATH ${CONDA_HOME}/miniconda/envs/tethys/bin:$PATH VOLUME ["${TETHYS_HOME}/workspaces", "${TETHYS_HOME}/keys"] EXPOSE 80 + +############### +# SET UP SALT # +############### +ADD docker/salt/config /etc/salt/minion +ADD docker/salt/states /srv/salt/ + ######## # RUN! # ######## WORKDIR ${TETHYS_HOME} -CMD echo "Sleeping Forever" -CMD while [ 1 ]; do sleep 1; done +CMD salt-call --local state.apply +CMD tail -qF /var/log/nginx/* /var/log/uwsgi/* diff --git a/docker/salt/config b/docker/salt/config new file mode 100644 index 000000000..4e8636633 --- /dev/null +++ b/docker/salt/config @@ -0,0 +1 @@ +file_client: local diff --git a/docker/salt/setup/files/activate.jinja b/docker/salt/setup/files/activate.jinja deleted file mode 100644 index e69de29bb..000000000 diff --git a/docker/salt/setup/init.sls b/docker/salt/states/init.sls similarity index 75% rename from docker/salt/setup/init.sls rename to docker/salt/states/init.sls index 78aeb89c0..7bfc03727 100644 --- a/docker/salt/setup/init.sls +++ b/docker/salt/states/init.sls @@ -1,5 +1,3 @@ -{%- set ACTIVATE_DIR = salt['environ.get']('CONDA_HOME')/envs/salt['environ.get']('CONDA_ENV_NAME')/etc/conda/activate.d -%} -{%- set DEACTIVATE_DIR = salt['environ.get']('CONDA_HOME')/envs/salt['environ.get']('CONDA_ENV_NAME')/etc/conda/deactivate.d -%} {%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} {%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} {%- set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') -%} @@ -9,28 +7,6 @@ {%- set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') -%} {%- set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') -%} {%- set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') -%} -{%- set BLERG = salt['environ.get']('BLERG') -%} -{%- set BLERG = salt['environ.get']('BLERG') -%} -{%- set BLERG = salt['environ.get']('BLERG') -%} -{%- set BLERG = salt['environ.get']('BLERG') -%} - -ACTIVATE: - file.directory: - - name: {{ ACTIVATE_DIR }} - - makedirs: True - file.managed: - - name: {{ ACTIVATE_DIR/tethys-activate.sh }} - - source: "salt://setup/files/activate.jinja" - - template: jinja - -DEACTIVATE: - file.directory: - - name: {{ DEACTIVATE_DIR }} - - makedirs: True - file.managed: - - name: {{ DEACTIVATE_DIR/tethys-deactivate.sh }} - - source: "salt://setup/files/deactivate.jinja" - - template: jinja ~/.bashrc: file.append: @@ -100,7 +76,7 @@ Prepare Database: Create Super User: cmd.run: - name: | - echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')" | python manage.py shell + echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}') == 0)" | python manage.py shell - cwd: {{ TETHYS_HOME }} Link NGINX Config: @@ -108,3 +84,14 @@ Link NGINX Config: - name: /etc/nginx/sites-enabled - target: ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + +uwsgi: + cmd.run: + - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml /usr/lib/tethys/src/tethys_portal/tethys_uwsgi.yml --uid www-data --gid www-data + - bg: True + - ignore_timeout: True + +nginx: + service.running: + - name: nginx + diff --git a/docker/salt/states/top.sls b/docker/salt/states/top.sls new file mode 100644 index 000000000..a966bc0fb --- /dev/null +++ b/docker/salt/states/top.sls @@ -0,0 +1,3 @@ +base: + '*': + - init From 4448e3b1aabc861f7deba6b19f24686681692dd8 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 15:39:48 -0600 Subject: [PATCH 069/215] Fix salt parsing errors --- Dockerfile | 1 + docker/salt/states/init.sls | 70 +++++++++++++++++++------------------ 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/Dockerfile b/Dockerfile index f0e5b813b..2ee4f20c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ FROM python:2-slim-stretch ############### ENV TETHYS_HOME="/usr/lib/tethys" \ TETHYS_PORT=80 \ + TETHYS_PUBLIC_HOST="172.17.0.1" \ TETHYS_DB_USERNAME="tethys_default" \ TETHYS_DB_PASSWORD="pass" \ TETHYS_DB_HOST="172.17.0.1" \ diff --git a/docker/salt/states/init.sls b/docker/salt/states/init.sls index 7bfc03727..d6680c7c2 100644 --- a/docker/salt/states/init.sls +++ b/docker/salt/states/init.sls @@ -1,69 +1,70 @@ -{%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} -{%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} +{%- set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') -%} +{%- set CONDA_ENV_NAME = salt['environ.get']('CONDA_ENV_NAME') -%} +{%- set CONDA_HOME = salt['environ.get']('CONDA_HOME') -%} +{%- set TETHYS_BIN_DIR = [CONDA_HOME, "/envs/", CONDA_ENV_NAME, "/bin"]|join -%} +{%- set TETHYS_DB_HOST = salt['environ.get']('TETHYS_DB_HOST') -%} {%- set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') -%} {%- set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') -%} -{%- set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') -%} -{%- set TETHYSBUILD_PUBLIC_HOST = salt['environ.get']('TETHYSBUILD_PUBLIC_HOST') -%} +{%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} +{%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} +{%- set TETHYS_PUBLIC_HOST = salt['environ.get']('TETHYS_PUBLIC_HOST') -%} {%- set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') -%} -{%- set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') -%} {%- set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') -%} +{%- set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') -%} ~/.bashrc: file.append: - - text: | - # Tethys Platform - alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} + - text: alias t=. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} -Generate Tethys Settings: +Generate_Tethys_Settings: cmd.run: - - name | - tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite + - name: {{ TETHYS_BIN_DIR }}/tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite -Edit Tethys Settings File (HOST): +Edit_Tethys_Settings_File_(HOST): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "'HOST': '127.0.0.1'" - - repl: {{ TETHYSBUILD_DB_HOST }} + - repl: "'HOST': '{{ TETHYS_DB_HOST }}'" -Edit Tethys Settings File (HOME_PAGE): +Edit_Tethys_Settings_File_(HOME_PAGE): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "BYPASS_TETHYS_HOME_PAGE = False" - repl: "BYPASS_TETHYS_HOME_PAGE = True" -Edit Tethys Settings File (SESSION_WARN): +Edit_Tethys_Settings_File_(SESSION_WARN): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "SESSION_SECURITY_WARN_AFTER = 840" - repl: "SESSION_SECURITY_WARN_AFTER = 25 * 60" -Edit Tethys Settings File (SESSION_EXPIRE): +Edit_Tethys_Settings_File_(SESSION_EXPIRE): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "SESSION_SECURITY_EXPIRE_AFTER = 900" - repl: "SESSION_SECURITY_EXPIRE_AFTER = 30 * 60" -Edit Tethys Settings File (SESSION_EXPIRE): +Edit_Tethys_Settings_File_(PUBLIC_HOST): file.append: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - text: "PUBLIC_HOST = \"{{ TETHYSBUILD_PUBLIC_HOST }}\"" + - text: "PUBLIC_HOST = \"{{ TETHYS_PUBLIC_HOST }}\"" -Generate NGINX Settings: +Generate_NGINX_Settings: cmd.run: - - tethys gen nginx --overwrite + - name: {{ TETHYS_BIN_DIR }}/tethys gen nginx --overwrite -Generate uwsgi Settings: +Generate_uwsgi_Settings: cmd.run: - - tethys gen uwsgi_settings --overwrite + - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_settings --overwrite -Generate uwsgi service: +Generate_uwsgi_service: cmd.run: - - tethys gen uwsgi_service --overwrite + - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite file.managed: - name: /var/log/uwsgi/tethys.log - makedirs: True -Prepare Database: +Prepare_Database: postgres_user.present: - name: {{ TETHYS_DB_USERNAME }} - password: {{ TETHYS_DB_PASSWORD }} @@ -71,17 +72,16 @@ Prepare Database: postgres_database.present: - name: {{ TETHYS_DB_USERNAME }} cmd.run: - - name: tethys manage syncdb + - name: {{ TETHYS_BIN_DIR }}/tethys manage syncdb -Create Super User: +Create_Super_User: cmd.run: - - name: | - echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}') == 0)" | python manage.py shell - - cwd: {{ TETHYS_HOME }} + - name: echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}') == 0)" | {{TETHYS_BIN_DIR }}/python manage.py shell + - cwd: {{ TETHYS_HOME }}/src -Link NGINX Config: +Link_NGINX_Config: file.symlink: - - name: /etc/nginx/sites-enabled + - name: /etc/nginx/sites-enabled/tethys_nginx.conf - target: ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf @@ -92,6 +92,8 @@ uwsgi: - ignore_timeout: True nginx: - service.running: - - name: nginx + cmd.run: + - name: nginx -g 'daemon off' + - bg: True + - ignore_timeout: True From c694d0609e7b2dbace1e111ddc067bca4fca6e54 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 20:05:10 -0600 Subject: [PATCH 070/215] Add postgres config for salt --- Dockerfile | 6 ++++++ docker/salt/config | 2 ++ docker/salt/states/init.sls | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2ee4f20c8..50432a570 100644 --- a/Dockerfile +++ b/Dockerfile @@ -114,5 +114,11 @@ ADD docker/salt/states /srv/salt/ # RUN! # ######## WORKDIR ${TETHYS_HOME} +# Tell Salt how to connect to the DB +CMD echo "postgres.host: '${TETHYS_DB_HOST}'" >> /etc/salt/minion \ + ; echo "postgres.port: '${TETHYS_DB_PORT}'" >> /etc/salt/minion \ + ; echo "postgres.user: '${TETHYS_DB_USERNAME}'" >> /etc/salt/minion \ + ; echo "postgres.pass: '${TETHYS_DB_PASSWORD}'" >> /etc/salt/minion \ + ; echo "postgres.bins_dir: '${CONDA_HOME}/envs/${CONDA_ENV_NAME}/bin'" >> /etc/salt/minion CMD salt-call --local state.apply CMD tail -qF /var/log/nginx/* /var/log/uwsgi/* diff --git a/docker/salt/config b/docker/salt/config index 4e8636633..0927a07c7 100644 --- a/docker/salt/config +++ b/docker/salt/config @@ -1 +1,3 @@ + file_client: local +postgres.bins_dir: /usr/lib/tethys/miniconda/envs/tethys/bin diff --git a/docker/salt/states/init.sls b/docker/salt/states/init.sls index d6680c7c2..32346d788 100644 --- a/docker/salt/states/init.sls +++ b/docker/salt/states/init.sls @@ -76,7 +76,7 @@ Prepare_Database: Create_Super_User: cmd.run: - - name: echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}') == 0)" | {{TETHYS_BIN_DIR }}/python manage.py shell + - name: echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0)" | {{TETHYS_BIN_DIR }}/python manage.py shell - cwd: {{ TETHYS_HOME }}/src Link_NGINX_Config: From 12de95b7b8267432929b7ec51703b3ac1856f3ef Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 09:02:51 -0600 Subject: [PATCH 071/215] Move salt conf generation into docker runtime --- Dockerfile | 5 +++-- docker/salt/config | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 50432a570..8e538ba2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -114,8 +114,9 @@ ADD docker/salt/states /srv/salt/ # RUN! # ######## WORKDIR ${TETHYS_HOME} -# Tell Salt how to connect to the DB -CMD echo "postgres.host: '${TETHYS_DB_HOST}'" >> /etc/salt/minion \ +# Create Salt configuration based on ENVs +CMD echo "file_client: local" > /etc/salt/minion \ + ; echo "postgres.host: '${TETHYS_DB_HOST}'" >> /etc/salt/minion \ ; echo "postgres.port: '${TETHYS_DB_PORT}'" >> /etc/salt/minion \ ; echo "postgres.user: '${TETHYS_DB_USERNAME}'" >> /etc/salt/minion \ ; echo "postgres.pass: '${TETHYS_DB_PASSWORD}'" >> /etc/salt/minion \ diff --git a/docker/salt/config b/docker/salt/config index 0927a07c7..4e8636633 100644 --- a/docker/salt/config +++ b/docker/salt/config @@ -1,3 +1 @@ - file_client: local -postgres.bins_dir: /usr/lib/tethys/miniconda/envs/tethys/bin From 60ee75bd15038167eeddf8cdaab2db706b225358 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 09:04:12 -0600 Subject: [PATCH 072/215] Remove (now) unused config file --- docker/salt/config | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docker/salt/config diff --git a/docker/salt/config b/docker/salt/config deleted file mode 100644 index 4e8636633..000000000 --- a/docker/salt/config +++ /dev/null @@ -1 +0,0 @@ -file_client: local From 5740dffbb38b023994c18d8635f4d3510b8be25c Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Fri, 3 Nov 2017 09:11:04 -0600 Subject: [PATCH 073/215] fix bug with test coverage; fix variable name in cli print statement --- tethys_apps/cli/__init__.py | 6 ++++-- tethys_apps/cli/services_commands.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index 2bfc400b1..0c7a1f8d4 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -151,8 +151,10 @@ def test_command(args): os.environ['TETHYS_TEST_DIR'] = tests_path if args.file and app_package_tag in args.file: app_package_parts = args.file.split(app_package_tag) - app_package = app_package_tag + app_package_parts[1].split('.')[0] - config_opt = '--source={0}'.format(app_package) + app_name = app_package_parts[1].split('.')[0] + core_app_package = '{}{}'.format(app_package_tag, app_name) + app_package = 'tethysapp.{}'.format(app_name) + config_opt = '--source={},{}'.format(core_app_package, app_package) else: config_opt = '--rcfile={0}'.format(os.path.join(tests_path, 'coverage.cfg')) primary_process = ['coverage', 'run', config_opt, manage_path, 'test'] diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index fe6633b70..c4b0794ef 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -152,7 +152,7 @@ def services_remove_spatial_command(args): if force: service.delete() with pretty_output(FG_GREEN) as p: - p.write('Successfully removed Spatial Dataset Service {0}!'.format(persistent_service_id)) + p.write('Successfully removed Spatial Dataset Service {0}!'.format(spatial_service_id)) exit(0) else: proceed = raw_input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') From 9a7e0ec49ad68607a87fbae618fe896dc0fa257a Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 15:22:04 -0600 Subject: [PATCH 074/215] Move startup to external script and add HEALTHCHECK --- Dockerfile | 25 ++- docker/install_tethys.sh | 130 -------------- docker/run.sh | 34 ++++ docker/run_tethys.sh | 43 ----- docker/salt/{states => }/init.sls | 51 +++--- docker/salt/{states => }/top.sls | 0 docker/setup_tethys.sh | 275 ------------------------------ 7 files changed, 75 insertions(+), 483 deletions(-) delete mode 100644 docker/install_tethys.sh create mode 100644 docker/run.sh delete mode 100644 docker/run_tethys.sh rename docker/salt/{states => }/init.sls (54%) rename docker/salt/{states => }/top.sls (100%) delete mode 100644 docker/setup_tethys.sh diff --git a/Dockerfile b/Dockerfile index 8e538ba2f..7e562a7bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ RUN apt-get update && apt-get -y install wget \ && wget -O - https://repo.saltstack.com/apt/debian/9/amd64/latest/SALTSTACK-GPG-KEY.pub | apt-key add - \ && echo "deb http://repo.saltstack.com/apt/debian/9/amd64/latest stretch main" > /etc/apt/sources.list.d/saltstack.list -RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion +RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion procps RUN rm -f /etc/nginx/sites-enabled/default # Install Miniconda @@ -71,7 +71,7 @@ RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; mkdir ${TETHYS_HOME}/workspaces' # Add static files -ADD static ${TETHYS_HOME}/src/ +ADD static ${TETHYS_HOME}/src/static/ ADD aquaveo_static/images/aquaveo_favicon.ico ${TETHYS_HOME}/src/static/tethys_portal/images/default_favicon.png ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_portal/images/tethys-logo-75.png ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css @@ -104,22 +104,17 @@ VOLUME ["${TETHYS_HOME}/workspaces", "${TETHYS_HOME}/keys"] EXPOSE 80 -############### -# SET UP SALT # -############### -ADD docker/salt/config /etc/salt/minion -ADD docker/salt/states /srv/salt/ +###############* +# COPY IN SALT # +###############* +ADD docker/salt/ /srv/salt/ +ADD docker/run.sh ${TETHYS_HOME}/ ######## # RUN! # ######## WORKDIR ${TETHYS_HOME} # Create Salt configuration based on ENVs -CMD echo "file_client: local" > /etc/salt/minion \ - ; echo "postgres.host: '${TETHYS_DB_HOST}'" >> /etc/salt/minion \ - ; echo "postgres.port: '${TETHYS_DB_PORT}'" >> /etc/salt/minion \ - ; echo "postgres.user: '${TETHYS_DB_USERNAME}'" >> /etc/salt/minion \ - ; echo "postgres.pass: '${TETHYS_DB_PASSWORD}'" >> /etc/salt/minion \ - ; echo "postgres.bins_dir: '${CONDA_HOME}/envs/${CONDA_ENV_NAME}/bin'" >> /etc/salt/minion -CMD salt-call --local state.apply -CMD tail -qF /var/log/nginx/* /var/log/uwsgi/* +CMD bash run.sh +HEALTHCHECK --start-period=240s \ + CMD ps $(cat $(grep 'pid .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}')) > /dev/null && ps $(cat $(grep 'pidfile2: .*' src/tethys_portal/tethys_uwsgi.yml | awk '{print $1}')) > /dev/null; diff --git a/docker/install_tethys.sh b/docker/install_tethys.sh deleted file mode 100644 index 899d5becc..000000000 --- a/docker/install_tethys.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/bin/bash - -USAGE="USAGE: . install_tethys.sh [options]\n -\n -OPTIONS:\n - -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n - --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n - -x Flag to turn on shell command echoing.\n - -h, --help Print this help information.\n -" - -print_usage () -{ - echo -e ${USAGE} - exit -} -set -e # exit on error -LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") -# convert to lower case -LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} -MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" -BASH_PROFILE=".bashrc" -resolve_relative_path () -{ - local __path_var="$1" - eval $__path_var="'$(readlink -f $2)'" -} - -# Set default options -TETHYS_HOME=~/tethys -TETHYS_PORT=80 -CONDA_ENV_NAME='tethys' -PYTHON_VERSION='2' - - -# parse command line options -set_option_value () -{ - local __option_key="$1" - value="$2" - if [[ $value == -* ]] - then - print_usage - fi - eval $__option_key="$value" -} -while [[ $# -gt 0 ]] -do -key="$1" - -case $key in - -t|--tethys-home) - set_option_value TETHYS_HOME "$2" - shift # past argument - ;; - -n|--conda-env-name) - set_option_value CONDA_ENV_NAME "$2" - shift # past argument - ;; - --python-version) - set_option_value PYTHON_VERSION "$2" - shift # past argument - ;; - -x) - ECHO_COMMANDS="true" - ;; - -h|--help) - print_usage - ;; - *) # unknown option - echo Ignoring unrecognized option: $key - ;; -esac -shift # past argument or value -done - -# resolve relative paths -CONDA_HOME="${TETHYS_HOME}/miniconda" -resolve_relative_path TETHYS_HOME ${TETHYS_HOME} -resolve_relative_path CONDA_HOME ${CONDA_HOME} - - - -if [ -n "${ECHO_COMMANDS}" ] -then - set -x # echo commands as they are executed -fi - - -# Set paths for environment activate/deactivate scripts -ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" -DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" -ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" -DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" - - -echo "Starting Tethys Installation..." - -mkdir -p "${TETHYS_HOME}" - -# Install Miniconda -echo "Installing Miniconda..." -wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" || (echo -using curl instead; curl ${MINICONDA_URL} -o "${TETHYS_HOME}/miniconda.sh") -pushd ./ -cd "${TETHYS_HOME}" -bash miniconda.sh -b -p "${CONDA_HOME}" -popd - -export PATH="${CONDA_HOME}/bin:$PATH" - -cd "${TETHYS_HOME}/src" - -# create conda env and install Tethys -echo "Setting up the ${CONDA_ENV_NAME} environment..." -conda env create -n ${CONDA_ENV_NAME} -f "environment_py${PYTHON_VERSION}.yml" -. activate ${CONDA_ENV_NAME} - -python setup.py develop - -set +e # don't exit on error anymore - -# Rename some variables for reference after deactivating tethys environment. -TETHYS_CONDA_HOME=${CONDA_HOME} -TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} - -on_exit(){ - set +e - set +x -} -trap on_exit EXIT diff --git a/docker/run.sh b/docker/run.sh new file mode 100644 index 000000000..5c5763bbd --- /dev/null +++ b/docker/run.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +echo_status() { + local args="${@}" + tput setaf 4 + tput bold + echo -e "- $args" + tput sgr0 +} + +echo_status "Starting up..." + +# Set extra ENVs +export NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') +export NGINX_PIDFILE=$(grep 'pid .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') +export UWSGI_PIDFILE=$(grep 'pidfile2: .*' src/tethys_portal/tethys_uwsgi.yml | awk '{print $2}') + +# Create Salt Config +echo "file_client: local" > /etc/salt/minion +echo "postgres.host: '${TETHYS_DB_HOST}'" >> /etc/salt/minion +echo "postgres.port: '${TETHYS_DB_PORT}'" >> /etc/salt/minion +echo "postgres.user: '${TETHYS_DB_USERNAME}'" >> /etc/salt/minion +echo "postgres.pass: '${TETHYS_DB_PASSWORD}'" >> /etc/salt/minion +echo "postgres.bins_dir: '${CONDA_HOME}/envs/${CONDA_ENV_NAME}/bin'" >> /etc/salt/minion + +# Apply States +echo_status "Enforcing start state... (This might take a bit)" +salt-call --local state.apply +find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} +echo_status "Done!" + +# Watch Logs +echo_status "Watching logs" +tail -qF /var/log/nginx/* /var/log/uwsgi/* diff --git a/docker/run_tethys.sh b/docker/run_tethys.sh deleted file mode 100644 index 605c1d07b..000000000 --- a/docker/run_tethys.sh +++ /dev/null @@ -1,43 +0,0 @@ -if [ ! -f "/usr/lib/tethys/setup_complete" ] ; -then - - echo Starting TethysCore Setup - apt-get update && apt-get install -y gcc - bash setup_tethys.sh \ - --allowed-host ${TETHYSBUILD_ALLOWED_HOST:-127.0.0.1} \ - --python-version ${TETHYSBUILD_PY_VERSION:-2} \ - --db-username ${TETHYSBUILD_DB_USERNAME:-tethys_default} \ - --db-password ${TETHYSBUILD_DB_PASSWORD:-pass} \ - --db-host ${TETHYSBUILD_DB_HOST:-127.0.0.1} \ - --db-port ${TETHYSBUILD_DB_PORT:-5432} \ - --superuser ${TETHYSBUILD_SUPERUSER:-tethys_super} \ - --superuser-pass ${TETHYSBUILD_SUPERUSER_PASS:-admin} \ - --tethys-home ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys} \ - echo TethysCore Setup Complete - cd /var/www/tethys/apps - echo Setting Up CityWater - - echo "----------------------------------------------------------------------------------" - echo "Setting Up NGINX" - echo "----------------------------------------------------------------------------------" - NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') - NGINX_GROUP=${NGINX_USER} - NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') - - chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src - chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/static - - /bin/bash -c 'mkdir -p /run/uwsgi; chown www-data:www-data /run/uwsgi' - - touch /usr/lib/tethys/setup_complete - -fi - - -echo "----------------------------------------------------------------------------------" -echo Starting Server -echo "----------------------------------------------------------------------------------" -nohup /usr/lib/tethys/miniconda/envs/tethys/bin/uwsgi --yaml /usr/lib/tethys/src/tethys_portal/tethys_uwsgi.yml --uid www-data --gid www-data& -nginx -g 'daemon off;' - -. deactivate diff --git a/docker/salt/states/init.sls b/docker/salt/init.sls similarity index 54% rename from docker/salt/states/init.sls rename to docker/salt/init.sls index 32346d788..4dfbc2043 100644 --- a/docker/salt/states/init.sls +++ b/docker/salt/init.sls @@ -1,20 +1,21 @@ -{%- set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') -%} -{%- set CONDA_ENV_NAME = salt['environ.get']('CONDA_ENV_NAME') -%} -{%- set CONDA_HOME = salt['environ.get']('CONDA_HOME') -%} -{%- set TETHYS_BIN_DIR = [CONDA_HOME, "/envs/", CONDA_ENV_NAME, "/bin"]|join -%} -{%- set TETHYS_DB_HOST = salt['environ.get']('TETHYS_DB_HOST') -%} -{%- set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') -%} -{%- set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') -%} -{%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} -{%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} -{%- set TETHYS_PUBLIC_HOST = salt['environ.get']('TETHYS_PUBLIC_HOST') -%} -{%- set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') -%} -{%- set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') -%} -{%- set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') -%} +{% set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') %} +{% set CONDA_ENV_NAME = salt['environ.get']('CONDA_ENV_NAME') %} +{% set CONDA_HOME = salt['environ.get']('CONDA_HOME') %} +{% set NGINX_USER = salt['environ.get']('NGINX_USER') %} +{% set TETHYS_BIN_DIR = [CONDA_HOME, "/envs/", CONDA_ENV_NAME, "/bin"]|join %} +{% set TETHYS_DB_HOST = salt['environ.get']('TETHYS_DB_HOST') %} +{% set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') %} +{% set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') %} +{% set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') %} +{% set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') %} +{% set TETHYS_PUBLIC_HOST = salt['environ.get']('TETHYS_PUBLIC_HOST') %} +{% set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') %} +{% set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') %} +{% set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') %} ~/.bashrc: file.append: - - text: alias t=. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} + - text: "alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }}'" Generate_Tethys_Settings: cmd.run: @@ -60,8 +61,16 @@ Generate_uwsgi_Settings: Generate_uwsgi_service: cmd.run: - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite + +/run/uwsgi: + file.directory: + - user: {{ NGINX_USER }} + - makedirs: True + +/var/log/uwsgi/tethys.log: file.managed: - - name: /var/log/uwsgi/tethys.log + - user: {{ NGINX_USER }} + - replace: False - makedirs: True Prepare_Database: @@ -72,28 +81,30 @@ Prepare_Database: postgres_database.present: - name: {{ TETHYS_DB_USERNAME }} cmd.run: - - name: {{ TETHYS_BIN_DIR }}/tethys manage syncdb + - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys manage syncdb + - shell: /bin/bash Create_Super_User: cmd.run: - - name: echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0)" | {{TETHYS_BIN_DIR }}/python manage.py shell + - name: > + echo "from django.contrib.auth.models import User; if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0): User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')" | {{TETHYS_BIN_DIR }}/python manage.py shell - cwd: {{ TETHYS_HOME }}/src Link_NGINX_Config: file.symlink: - name: /etc/nginx/sites-enabled/tethys_nginx.conf - - target: ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + - target: {{ TETHYS_HOME }}/src/tethys_portal/tethys_nginx.conf uwsgi: cmd.run: - - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml /usr/lib/tethys/src/tethys_portal/tethys_uwsgi.yml --uid www-data --gid www-data + - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} - bg: True - ignore_timeout: True nginx: cmd.run: - - name: nginx -g 'daemon off' + - name: nginx -g 'daemon off;' - bg: True - ignore_timeout: True diff --git a/docker/salt/states/top.sls b/docker/salt/top.sls similarity index 100% rename from docker/salt/states/top.sls rename to docker/salt/top.sls diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh deleted file mode 100644 index bc4f5392c..000000000 --- a/docker/setup_tethys.sh +++ /dev/null @@ -1,275 +0,0 @@ -#!/bin/bash - -USAGE="USAGE: . install_tethys.sh [options]\n -\n -OPTIONS:\n - -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n - -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n - -p, --port Port on which to serve tethys. Default is 8000.\n - --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n - --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n - --db-password Password that the tethys database server will use. Default is 'pass'.\n - --db-port Port that the tethys database server will use. Default is 5436.\n - -S, --superuser Tethys super user name. Default is 'admin'.\n - -E, --superuser-email Tethys super user email. Default is ''.\n - -P, --superuser-pass Tethys super user password. Default is 'pass'.\n - -x Flag to turn on shell command echoing.\n - -h, --help Print this help information.\n -" - -print_usage () -{ - echo -e ${USAGE} - exit -} -set -e # exit on error - -# Set platform specific default options -LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") -# convert to lower case -echo "Linux Distribution: ${LINUX_DISTRIBUTION}" -LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} -MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" -BASH_PROFILE=".bashrc" -resolve_relative_path () -{ - local __path_var="$1" - eval $__path_var="'$(readlink -f $2)'" -} - - -# Set default options -ALLOWED_HOST='127.0.0.1' -TETHYS_HOME=~/tethys -TETHYS_PORT=80 -TETHYS_DB_USERNAME='tethys_default' -TETHYS_DB_PASSWORD='pass' -TETHYS_DB_HOST='172.17.0.1' -TETHYS_DB_PORT=5432 -CONDA_ENV_NAME='tethys' -PYTHON_VERSION='2' - -TETHYS_SUPER_USER='admin' -TETHYS_SUPER_USER_EMAIL='' -TETHYS_SUPER_USER_PASS='pass' - -# parse command line options -set_option_value () -{ - local __option_key="$1" - value="$2" - if [[ $value == -* ]] - then - print_usage - fi - eval $__option_key="$value" -} -while [[ $# -gt 0 ]] -do -key="$1" - -case $key in - -t|--tethys-home) - set_option_value TETHYS_HOME "$2" - shift # past argument - ;; - -a|--allowed-host) - set_option_value ALLOWED_HOST "$2" - shift # past argument - ;; - -p|--port) - set_option_value TETHYS_PORT "$2" - shift # past argument - ;; - --python-version) - set_option_value PYTHON_VERSION "$2" - shift # past argument - ;; - --db-username) - set_option_value TETHYS_DB_USERNAME "$2" - shift # past argument - ;; - --db-password) - set_option_value TETHYS_DB_PASSWORD "$2" - shift # past argument - ;; - --db-port) - set_option_value TETHYS_DB_PORT "$2" - shift # past argument - ;; - --db-host) - set_option_value TETHYS_DB_HOST "$2" - shift # past argument - ;; - -S|--superuser) - set_option_value TETHYS_SUPER_USER "$2" - shift # past argument - ;; - -E|--superuser-email) - set_option_value TETHYS_SUPER_USER_EMAIL "$2" - shift # past argument - ;; - -P|--superuser-pass) - set_option_value TETHYS_SUPER_USER_PASS "$2" - shift # past argument - ;; - -x) - ECHO_COMMANDS="true" - ;; - -h|--help) - print_usage - ;; - *) # unknown option - echo Ignoring unrecognized option: $key - ;; -esac -shift # past argument or value -done - -# resolve relative paths -CONDA_HOME="${TETHYS_HOME}/miniconda" -resolve_relative_path TETHYS_HOME ${TETHYS_HOME} -resolve_relative_path CONDA_HOME ${CONDA_HOME} - - -if [ -n "${ECHO_COMMANDS}" ] -then - set -x # echo commands as they are executed -fi - - -# Set paths for environment activate/deactivate scripts -ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" -DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" -ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" -DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" - - -# Rename some variables for reference after deactivating tethys environment. -TETHYS_CONDA_HOME=${CONDA_HOME} -TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} - - -# prompt for sudo -echo "Installing Tethys Production Server..." - -# NGINEX -rm /etc/nginx/sites-enabled/default -NGINX_SITES_DIR='sites-enabled' - -# Create environment activate/deactivate scripts -mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" - -echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" -echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" -echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" -echo "export CONDA_HOME='${CONDA_HOME}'" >> "${ACTIVATE_SCRIPT}" -echo "export CONDA_ENV_NAME='${CONDA_ENV_NAME}'" >> "${ACTIVATE_SCRIPT}" -echo "alias tethys_start_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" -l \"\${TETHYS_HOME}/psql/logfile\" start -o \"-p \${TETHYS_DB_PORT}\"'" >> "${ACTIVATE_SCRIPT}" -echo "alias tstartdb=tethys_start_db" >> "${ACTIVATE_SCRIPT}" -echo "alias tethys_stop_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" stop'" >> "${ACTIVATE_SCRIPT}" -echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" -echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" -echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" - -echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" -echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" -echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" -echo "unset CONDA_HOME" >> "${DEACTIVATE_SCRIPT}" -echo "unset CONDA_ENV_NAME" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tethys_start_db" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tstartdb" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tethys_stop_db" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" - -echo "# Tethys Platform" >> ~/${BASH_PROFILE} -echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} - -echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" -echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" -echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" -echo "alias tuo=tethys_user_own" >> "${ACTIVATE_SCRIPT}" -echo "alias tethys_server_own='sudo chown -R \${NGINX_USER}:\${NGINX_USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" -echo "alias tso=tethys_server_own" >> "${ACTIVATE_SCRIPT}" -echo "alias tethys_server_restart='tso; sudo systemctl restart tethys.uwsgi.service; sudo systemctl restart nginx'" >> "${ACTIVATE_SCRIPT}" -echo "alias tsr=tethys_server_restart" >> "${ACTIVATE_SCRIPT}" - -echo "unset NGINX_USER" >> "${DEACTIVATE_SCRIPT}" -echo "unset NGINX_HOME" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tethys_user_own" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tuo" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tethys_server_own" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tso" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tethys_server_restart" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tsr" >> "${DEACTIVATE_SCRIPT}" - - -# ACTIVATE -. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} - -# INSTALL REQUIREMENTS -conda install -c conda-forge uwsgi -y - -# GEN SETTINGS -tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite -sed -i -e "s/'HOST': '127.0.0.1',/'HOST': '${TETHYSBUILD_DB_HOST}',/g" /usr/lib/tethys/src/tethys_portal/settings.py -sed -i -e 's/BYPASS_TETHYS_HOME_PAGE = False/BYPASS_TETHYS_HOME_PAGE = True/g' /usr/lib/tethys/src/tethys_portal/settings.py -sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" /usr/lib/tethys/src/tethys_portal/settings.py - -sed -i -e 's/SESSION_SECURITY_WARN_AFTER = 840/SESSION_SECURITY_WARN_AFTER = 25 * 60/g' /usr/lib/tethys/src/tethys_portal/settings.py -sed -i -e 's/SESSION_SECURITY_EXPIRE_AFTER = 900/SESSION_SECURITY_EXPIRE_AFTER = 30 * 60/g' /usr/lib/tethys/src/tethys_portal/settings.py -echo "PUBLIC_HOST = \"${TETHYSBUILD_PUBLIC_HOST}\"" >> /usr/lib/tethys/src/tethys_portal/settings.py - -# DB ROLLS/ETC -if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command ""; echo $?) -ne 0 ]]; then # Check if postgres has a password - echo "default postgres user has a password set, assuming database is setup correctly..." - tethys manage syncdb -else - if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1; echo $?) -ne 0 ]]; then - echo "Creating DB User and Password" - cd /usr/lib/tethys/src - psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" - createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 - tethys manage syncdb - echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" - echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell - cd /usr/lib/tethys - else - tethys manage syncdb - fi -fi - -# NGINX AND UWSGI -tethys gen nginx --overwrite -tethys gen uwsgi_settings --overwrite -tethys gen uwsgi_service --overwrite -NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') -NGINX_GROUP=${NGINX_USER} -NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') -chmod 705 ~ -mkdir /var/log/uwsgi -touch /var/log/uwsgi/tethys.log -ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ -chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log - -# STATIC FILES AND WORKSPACES -mkdir -p ${TETHYS_HOME}/static ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps -tethys manage collectall --noinput -chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME} - -# ECHOING -set +x - -# DEACTIVATE -. deactivate - - -# EXIT -on_exit(){ - set +e - set +x -} -trap on_exit EXIT - From 8d05f490f69e4dcbad7c3c70bcbdbfcad3859e61 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 15:35:36 -0600 Subject: [PATCH 075/215] Fix permissions --- Dockerfile | 2 +- docker/run.sh | 1 + docker/salt/init.sls | 5 +++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7e562a7bc..e60f510df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -117,4 +117,4 @@ WORKDIR ${TETHYS_HOME} # Create Salt configuration based on ENVs CMD bash run.sh HEALTHCHECK --start-period=240s \ - CMD ps $(cat $(grep 'pid .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}')) > /dev/null && ps $(cat $(grep 'pidfile2: .*' src/tethys_portal/tethys_uwsgi.yml | awk '{print $1}')) > /dev/null; + CMD ps $(cat $(grep 'pid .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}')) > /dev/null && ps $(cat $(grep 'pidfile2: .*' src/tethys_portal/tethys_uwsgi.yml | awk '{print $2}')) > /dev/null; diff --git a/docker/run.sh b/docker/run.sh index 5c5763bbd..5f491f879 100644 --- a/docker/run.sh +++ b/docker/run.sh @@ -26,6 +26,7 @@ echo "postgres.bins_dir: '${CONDA_HOME}/envs/${CONDA_ENV_NAME}/bin'" >> /etc/sal # Apply States echo_status "Enforcing start state... (This might take a bit)" salt-call --local state.apply +echo_status "Fixing permissions" find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} echo_status "Done!" diff --git a/docker/salt/init.sls b/docker/salt/init.sls index 4dfbc2043..04d3a6deb 100644 --- a/docker/salt/init.sls +++ b/docker/salt/init.sls @@ -62,9 +62,10 @@ Generate_uwsgi_service: cmd.run: - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite -/run/uwsgi: - file.directory: +/run/uwsgi/tethys.pid: + file.managed: - user: {{ NGINX_USER }} + - replace: False - makedirs: True /var/log/uwsgi/tethys.log: From 408695e204cb3ea32df4bbe2185fbf6b6ebc7803 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 15:50:10 -0600 Subject: [PATCH 076/215] Run collectall during build --- Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e60f510df..a1dd2f053 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,8 +67,8 @@ ADD *.py ${TETHYS_HOME}/src/ # Run Installer RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; python setup.py develop \ - ; conda install -c conda-forge uwsgi -y \ - ; mkdir ${TETHYS_HOME}/workspaces' + ; conda install -c conda-forge uwsgi -y' +RUN mkdir ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps ${TETHYS_HOME}/static # Add static files ADD static ${TETHYS_HOME}/src/static/ @@ -82,7 +82,8 @@ RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; sed -i -e "s:#TETHYS_WORKSPACES_ROOT = .*$:TETHYS_WORKSPACES_ROOT = \"/usr/lib/tethys/workspaces\":" ${TETHYS_HOME}/src/tethys_portal/settings.py \ ; tethys gen nginx --overwrite \ ; tethys gen uwsgi_settings --overwrite \ - ; tethys gen uwsgi_service --overwrite' + ; tethys gen uwsgi_service --overwrite \ + ; tethys manage collectall --noinput' # Give NGINX Permission From 3552d82343cee8ae90bfa68bdb0ec3c1d879985e Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 15:55:05 -0600 Subject: [PATCH 077/215] Use manage.py rather than tethys manage to avoid db conn --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a1dd2f053..6e031cb0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,7 +83,7 @@ RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; tethys gen nginx --overwrite \ ; tethys gen uwsgi_settings --overwrite \ ; tethys gen uwsgi_service --overwrite \ - ; tethys manage collectall --noinput' + ; python manage.py collectstatic' # Give NGINX Permission From 7d4b8275d5cb9ad04625118b2460a25d6bfc92c2 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 17:41:51 -0600 Subject: [PATCH 078/215] Fix python script in salt for creating superuser --- docker/salt/init.sls | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/salt/init.sls b/docker/salt/init.sls index 04d3a6deb..5da415290 100644 --- a/docker/salt/init.sls +++ b/docker/salt/init.sls @@ -87,9 +87,9 @@ Prepare_Database: Create_Super_User: cmd.run: - - name: > - echo "from django.contrib.auth.models import User; if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0): User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')" | {{TETHYS_BIN_DIR }}/python manage.py shell + - name: "{{TETHYS_BIN_DIR }}/python {{ TETHYS_HOME }}/src/manage.py shell -c \"from django.contrib.auth.models import User;\nif (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0):\n\tUser.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')\"" - cwd: {{ TETHYS_HOME }}/src + - shell: /bin/bash Link_NGINX_Config: file.symlink: From 2ecd2615d6a531062dc8a1d1c20e7ae9f46c770a Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Sun, 5 Nov 2017 13:25:53 -0700 Subject: [PATCH 079/215] Start updating Docker documentation Add Table of Environmental Variables --- docker/README.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/docker/README.md b/docker/README.md index b1a4daf70..d4c3ef98b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,7 +1,27 @@ -# Tethys Core Docker +# Docker Support Files -This project houses the docker file and scripts needed to make the tethyscore -docker. +This project houses the docker file and scripts needed to make usable docker image. + +### Environment + +| Argument | Description |Phase| Default | +|-----------------------|-----------------------------|-----|-------------------------| +|ALLOWED_HOST | Django Setting |Run |127.0.0.1 | +|BASH_PROFILE | Where to create aliases |Run |.bashrc | +|CONDA_HOME | Path to Conda Home Dir |Build|${TETHYS_HOME}/miniconda”| +|CONDA_ENV_NAME | Name of Conda environ |Build|tethys | +|MINICONDA_URL | URL of conda install script |Build|“https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh”| +|PYTHON_VERSION | Version of Python to use |Build|2 | +|TETHYS_HOME | Path to Tethys Home Dir |Build|/usr/lib/tethys | +|TETHYS_PORT | Port for external web access|Run |80 | +|TETHYS_PUBLIC_HOST | |? |172.17.0.1 | +|TETHYS_DB_USERNAME | Postgres connection username|Run |tethys_default | +|TETHYS_DB_PASSWORD | Postgres connection password|Run |pass | +|TETHYS_DB_HOST | Postgres connection address |Run |172.17.0.1 | +|TETHYS_DB_PORT | Postgres connection Port |Run |5432 | +|TETHYS_SUPER_USER | Default superuser username |Run |admin | +|TETHYS_SUPER_USER_EMAIL| Default superuser email |Run |“” | +|TETHYS_SUPER_USER_PASS | Default superuser password |Run |pass | ### Building the Docker To build the docker use the following commands in the terminal after From 75ca53c39b4d3453b4844306d1c8a28d72423e09 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Mon, 6 Nov 2017 12:57:49 -0700 Subject: [PATCH 080/215] Rename salt state --- docker/salt/top.sls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/salt/top.sls b/docker/salt/top.sls index a966bc0fb..0a1370323 100644 --- a/docker/salt/top.sls +++ b/docker/salt/top.sls @@ -1,3 +1,3 @@ base: '*': - - init + - tethyscore From 317660a4ea48cd05b355872fe5a0b5c06fa3cdfc Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Mon, 6 Nov 2017 13:00:50 -0700 Subject: [PATCH 081/215] Rename salt state --- docker/salt/init.sls => tethyscore.sls | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docker/salt/init.sls => tethyscore.sls (100%) diff --git a/docker/salt/init.sls b/tethyscore.sls similarity index 100% rename from docker/salt/init.sls rename to tethyscore.sls From f4147dd07bfab6c57818f2240073ae3072cb61a9 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Mon, 6 Nov 2017 13:07:38 -0700 Subject: [PATCH 082/215] Fix git wierdness --- docker/salt/tethyscore.sls | 111 +++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docker/salt/tethyscore.sls diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls new file mode 100644 index 000000000..5da415290 --- /dev/null +++ b/docker/salt/tethyscore.sls @@ -0,0 +1,111 @@ +{% set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') %} +{% set CONDA_ENV_NAME = salt['environ.get']('CONDA_ENV_NAME') %} +{% set CONDA_HOME = salt['environ.get']('CONDA_HOME') %} +{% set NGINX_USER = salt['environ.get']('NGINX_USER') %} +{% set TETHYS_BIN_DIR = [CONDA_HOME, "/envs/", CONDA_ENV_NAME, "/bin"]|join %} +{% set TETHYS_DB_HOST = salt['environ.get']('TETHYS_DB_HOST') %} +{% set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') %} +{% set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') %} +{% set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') %} +{% set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') %} +{% set TETHYS_PUBLIC_HOST = salt['environ.get']('TETHYS_PUBLIC_HOST') %} +{% set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') %} +{% set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') %} +{% set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') %} + +~/.bashrc: + file.append: + - text: "alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }}'" + +Generate_Tethys_Settings: + cmd.run: + - name: {{ TETHYS_BIN_DIR }}/tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite + +Edit_Tethys_Settings_File_(HOST): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "'HOST': '127.0.0.1'" + - repl: "'HOST': '{{ TETHYS_DB_HOST }}'" + +Edit_Tethys_Settings_File_(HOME_PAGE): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "BYPASS_TETHYS_HOME_PAGE = False" + - repl: "BYPASS_TETHYS_HOME_PAGE = True" + +Edit_Tethys_Settings_File_(SESSION_WARN): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "SESSION_SECURITY_WARN_AFTER = 840" + - repl: "SESSION_SECURITY_WARN_AFTER = 25 * 60" + +Edit_Tethys_Settings_File_(SESSION_EXPIRE): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "SESSION_SECURITY_EXPIRE_AFTER = 900" + - repl: "SESSION_SECURITY_EXPIRE_AFTER = 30 * 60" + +Edit_Tethys_Settings_File_(PUBLIC_HOST): + file.append: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - text: "PUBLIC_HOST = \"{{ TETHYS_PUBLIC_HOST }}\"" + +Generate_NGINX_Settings: + cmd.run: + - name: {{ TETHYS_BIN_DIR }}/tethys gen nginx --overwrite + +Generate_uwsgi_Settings: + cmd.run: + - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_settings --overwrite + +Generate_uwsgi_service: + cmd.run: + - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite + +/run/uwsgi/tethys.pid: + file.managed: + - user: {{ NGINX_USER }} + - replace: False + - makedirs: True + +/var/log/uwsgi/tethys.log: + file.managed: + - user: {{ NGINX_USER }} + - replace: False + - makedirs: True + +Prepare_Database: + postgres_user.present: + - name: {{ TETHYS_DB_USERNAME }} + - password: {{ TETHYS_DB_PASSWORD }} + - login: True + postgres_database.present: + - name: {{ TETHYS_DB_USERNAME }} + cmd.run: + - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys manage syncdb + - shell: /bin/bash + +Create_Super_User: + cmd.run: + - name: "{{TETHYS_BIN_DIR }}/python {{ TETHYS_HOME }}/src/manage.py shell -c \"from django.contrib.auth.models import User;\nif (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0):\n\tUser.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')\"" + - cwd: {{ TETHYS_HOME }}/src + - shell: /bin/bash + +Link_NGINX_Config: + file.symlink: + - name: /etc/nginx/sites-enabled/tethys_nginx.conf + - target: {{ TETHYS_HOME }}/src/tethys_portal/tethys_nginx.conf + + +uwsgi: + cmd.run: + - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} + - bg: True + - ignore_timeout: True + +nginx: + cmd.run: + - name: nginx -g 'daemon off;' + - bg: True + - ignore_timeout: True + From a3b1d817f0b921f4c21bd941dc434a2575370363 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Mon, 6 Nov 2017 13:14:57 -0700 Subject: [PATCH 083/215] split salt states in two This will allow apps that derive from this docker to more easily inject states in between initialization and running --- docker/salt/run.sls | 14 +++++ docker/salt/tethyscore.sls | 11 ---- docker/salt/top.sls | 1 + tethyscore.sls | 111 ------------------------------------- 4 files changed, 15 insertions(+), 122 deletions(-) create mode 100644 docker/salt/run.sls delete mode 100644 tethyscore.sls diff --git a/docker/salt/run.sls b/docker/salt/run.sls new file mode 100644 index 000000000..c4edcf450 --- /dev/null +++ b/docker/salt/run.sls @@ -0,0 +1,14 @@ +{% set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') %} +{% set NGINX_USER = salt['environ.get']('NGINX_USER') %} + +uwsgi: + cmd.run: + - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} + - bg: True + - ignore_timeout: True + +nginx: + cmd.run: + - name: nginx -g 'daemon off;' + - bg: True + - ignore_timeout: True \ No newline at end of file diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls index 5da415290..639d24533 100644 --- a/docker/salt/tethyscore.sls +++ b/docker/salt/tethyscore.sls @@ -97,15 +97,4 @@ Link_NGINX_Config: - target: {{ TETHYS_HOME }}/src/tethys_portal/tethys_nginx.conf -uwsgi: - cmd.run: - - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} - - bg: True - - ignore_timeout: True - -nginx: - cmd.run: - - name: nginx -g 'daemon off;' - - bg: True - - ignore_timeout: True diff --git a/docker/salt/top.sls b/docker/salt/top.sls index 0a1370323..59329b5ce 100644 --- a/docker/salt/top.sls +++ b/docker/salt/top.sls @@ -1,3 +1,4 @@ base: '*': - tethyscore + - run diff --git a/tethyscore.sls b/tethyscore.sls deleted file mode 100644 index 5da415290..000000000 --- a/tethyscore.sls +++ /dev/null @@ -1,111 +0,0 @@ -{% set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') %} -{% set CONDA_ENV_NAME = salt['environ.get']('CONDA_ENV_NAME') %} -{% set CONDA_HOME = salt['environ.get']('CONDA_HOME') %} -{% set NGINX_USER = salt['environ.get']('NGINX_USER') %} -{% set TETHYS_BIN_DIR = [CONDA_HOME, "/envs/", CONDA_ENV_NAME, "/bin"]|join %} -{% set TETHYS_DB_HOST = salt['environ.get']('TETHYS_DB_HOST') %} -{% set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') %} -{% set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') %} -{% set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') %} -{% set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') %} -{% set TETHYS_PUBLIC_HOST = salt['environ.get']('TETHYS_PUBLIC_HOST') %} -{% set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') %} -{% set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') %} -{% set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') %} - -~/.bashrc: - file.append: - - text: "alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }}'" - -Generate_Tethys_Settings: - cmd.run: - - name: {{ TETHYS_BIN_DIR }}/tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite - -Edit_Tethys_Settings_File_(HOST): - file.replace: - - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - pattern: "'HOST': '127.0.0.1'" - - repl: "'HOST': '{{ TETHYS_DB_HOST }}'" - -Edit_Tethys_Settings_File_(HOME_PAGE): - file.replace: - - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - pattern: "BYPASS_TETHYS_HOME_PAGE = False" - - repl: "BYPASS_TETHYS_HOME_PAGE = True" - -Edit_Tethys_Settings_File_(SESSION_WARN): - file.replace: - - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - pattern: "SESSION_SECURITY_WARN_AFTER = 840" - - repl: "SESSION_SECURITY_WARN_AFTER = 25 * 60" - -Edit_Tethys_Settings_File_(SESSION_EXPIRE): - file.replace: - - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - pattern: "SESSION_SECURITY_EXPIRE_AFTER = 900" - - repl: "SESSION_SECURITY_EXPIRE_AFTER = 30 * 60" - -Edit_Tethys_Settings_File_(PUBLIC_HOST): - file.append: - - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - text: "PUBLIC_HOST = \"{{ TETHYS_PUBLIC_HOST }}\"" - -Generate_NGINX_Settings: - cmd.run: - - name: {{ TETHYS_BIN_DIR }}/tethys gen nginx --overwrite - -Generate_uwsgi_Settings: - cmd.run: - - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_settings --overwrite - -Generate_uwsgi_service: - cmd.run: - - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite - -/run/uwsgi/tethys.pid: - file.managed: - - user: {{ NGINX_USER }} - - replace: False - - makedirs: True - -/var/log/uwsgi/tethys.log: - file.managed: - - user: {{ NGINX_USER }} - - replace: False - - makedirs: True - -Prepare_Database: - postgres_user.present: - - name: {{ TETHYS_DB_USERNAME }} - - password: {{ TETHYS_DB_PASSWORD }} - - login: True - postgres_database.present: - - name: {{ TETHYS_DB_USERNAME }} - cmd.run: - - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys manage syncdb - - shell: /bin/bash - -Create_Super_User: - cmd.run: - - name: "{{TETHYS_BIN_DIR }}/python {{ TETHYS_HOME }}/src/manage.py shell -c \"from django.contrib.auth.models import User;\nif (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0):\n\tUser.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')\"" - - cwd: {{ TETHYS_HOME }}/src - - shell: /bin/bash - -Link_NGINX_Config: - file.symlink: - - name: /etc/nginx/sites-enabled/tethys_nginx.conf - - target: {{ TETHYS_HOME }}/src/tethys_portal/tethys_nginx.conf - - -uwsgi: - cmd.run: - - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} - - bg: True - - ignore_timeout: True - -nginx: - cmd.run: - - name: nginx -g 'daemon off;' - - bg: True - - ignore_timeout: True - From 6f8a6222f36259238481dfebcb1fa8d635ea8822 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Tue, 7 Nov 2017 16:07:02 -0700 Subject: [PATCH 084/215] Add sync stores step Although tethys core itself does not have any stores, every app that is built on it does. --- docker/salt/tethyscore.sls | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls index 639d24533..c88cd9319 100644 --- a/docker/salt/tethyscore.sls +++ b/docker/salt/tethyscore.sls @@ -85,6 +85,10 @@ Prepare_Database: - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys manage syncdb - shell: /bin/bash +Sync_Stores: + cmd.run: + - name: {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys syncstores all + Create_Super_User: cmd.run: - name: "{{TETHYS_BIN_DIR }}/python {{ TETHYS_HOME }}/src/manage.py shell -c \"from django.contrib.auth.models import User;\nif (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0):\n\tUser.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')\"" From 2218f2cd208fd81d2d4366ade4adceceb8ec9561 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Tue, 7 Nov 2017 16:24:20 -0700 Subject: [PATCH 085/215] Add pv to default packages --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6e031cb0f..62ec1cec8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ RUN apt-get update && apt-get -y install wget \ && wget -O - https://repo.saltstack.com/apt/debian/9/amd64/latest/SALTSTACK-GPG-KEY.pub | apt-key add - \ && echo "deb http://repo.saltstack.com/apt/debian/9/amd64/latest stretch main" > /etc/apt/sources.list.d/saltstack.list -RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion procps +RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion procps pv RUN rm -f /etc/nginx/sites-enabled/default # Install Miniconda @@ -88,7 +88,7 @@ RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ # Give NGINX Permission RUN export NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') \ - ; find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} + ; find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | pv | xargs -0 -I{} chown ${NGINX_USER}: {} ############ # CLEAN UP # From 136381620947e49762bc1c599e8fe127ec710f2c Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 9 Nov 2017 10:30:28 -0700 Subject: [PATCH 086/215] Add missing . --- docker/salt/tethyscore.sls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls index c88cd9319..3f678245c 100644 --- a/docker/salt/tethyscore.sls +++ b/docker/salt/tethyscore.sls @@ -87,7 +87,7 @@ Prepare_Database: Sync_Stores: cmd.run: - - name: {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys syncstores all + - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys syncstores all Create_Super_User: cmd.run: From 552d87f2b0a8b28a7b298b20755ca1de4605a660 Mon Sep 17 00:00:00 2001 From: nswain Date: Tue, 14 Nov 2017 16:23:52 -0700 Subject: [PATCH 087/215] Fixed issue where favicon was not loading properly. --- templates/base.html | 2 +- tethys_apps/templates/tethys_apps/app_base.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/base.html b/templates/base.html index c9383ec8c..c4064d11f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -57,7 +57,7 @@ {% endcomment %} {% block links %} - + {% endblock %} {% comment "import_gizmos explanation" %} diff --git a/tethys_apps/templates/tethys_apps/app_base.html b/tethys_apps/templates/tethys_apps/app_base.html index 101979c4b..3283e30ff 100644 --- a/tethys_apps/templates/tethys_apps/app_base.html +++ b/tethys_apps/templates/tethys_apps/app_base.html @@ -57,7 +57,7 @@ {% endcomment %} {% block links %} - {% if site_globals.favicon %}{% endif %} + {% if site_globals.favicon %}{% endif %} {% endblock %} {% comment "import_gizmos explanation" %} From b1fb934ad2807ab53e34984a9f9689f69ae2fefa Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 27 Nov 2017 20:35:21 +0000 Subject: [PATCH 088/215] add shell --- docker/salt/tethyscore.sls | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls index 3f678245c..5e98d7b99 100644 --- a/docker/salt/tethyscore.sls +++ b/docker/salt/tethyscore.sls @@ -88,6 +88,7 @@ Prepare_Database: Sync_Stores: cmd.run: - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys syncstores all + - shell: /bin/bash Create_Super_User: cmd.run: From f840283b009f7252adfa34c8b5c9ad35153de0e0 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Tue, 28 Nov 2017 09:30:40 -0700 Subject: [PATCH 089/215] Add gnupg2 to install/cleanup list This allows verified downloads of saltstack, since we have to add a non default repo --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 62ec1cec8..26936193d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ ; echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache # Install APT packages -RUN apt-get update && apt-get -y install wget \ +RUN apt-get update && apt-get -y install wget gnupg2 \ && wget -O - https://repo.saltstack.com/apt/debian/9/amd64/latest/SALTSTACK-GPG-KEY.pub | apt-key add - \ && echo "deb http://repo.saltstack.com/apt/debian/9/amd64/latest stretch main" > /etc/apt/sources.list.d/saltstack.list RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion procps pv @@ -93,7 +93,7 @@ RUN export NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' ############ # CLEAN UP # ############ -RUN apt-get -y remove wget gcc \ +RUN apt-get -y remove wget gcc gnupg2 \ ; apt-get -y autoremove \ ; apt-get -y clean From 93b3e8eafad0ea3def042199ae89ed443e7fc041 Mon Sep 17 00:00:00 2001 From: sdc50 Date: Wed, 29 Nov 2017 06:48:24 -0600 Subject: [PATCH 090/215] Hotfix/handle unset settings (#326) * Quick fix to handle unset settings. Partially addresses issue #249. * Update app_base.py fix 'errror' (sid) typo. --- tethys_apps/base/app_base.py | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index cdfe82e15..d165e81b9 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -10,6 +10,7 @@ import logging import os import sys +import traceback from django.core.exceptions import ObjectDoesNotExist from django.http import HttpRequest @@ -18,7 +19,7 @@ from tethys_apps.base.testing.environment import is_testing_environment from .handoff import HandoffManager from .workspace import TethysWorkspace -from ..exceptions import TethysAppSettingDoesNotExist +from ..exceptions import TethysAppSettingDoesNotExist, TethysAppSettingNotAssigned tethys_log = logging.getLogger('tethys.app_base') @@ -734,10 +735,11 @@ def get_persistent_store_connection(cls, name, as_url=False, as_sessionmaker=Fal try: ps_connection_setting = ps_connection_settings.get(name=name) + return ps_connection_setting.get_engine(as_url=as_url, as_sessionmaker=as_sessionmaker) except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist('PersistentStoreConnectionSetting named "{0}" does not exist.'.format(name)) - - return ps_connection_setting.get_engine(as_url=as_url, as_sessionmaker=as_sessionmaker) + except TethysAppSettingNotAssigned: + cls._log_tethys_app_setting_not_assigned_errror('PersistentStoreConnectionSetting', name) @classmethod def get_persistent_store_database(cls, name, as_url=False, as_sessionmaker=False): @@ -775,10 +777,11 @@ def get_persistent_store_database(cls, name, as_url=False, as_sessionmaker=False try: ps_database_setting = ps_database_settings.get(name=name) + return ps_database_setting.get_engine(with_db=True, as_url=as_url, as_sessionmaker=as_sessionmaker) except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist('PersistentStoreDatabaseSetting named "{0}" does not exist.'.format(name)) - - return ps_database_setting.get_engine(with_db=True, as_url=as_url, as_sessionmaker=as_sessionmaker) + except TethysAppSettingNotAssigned: + cls._log_tethys_app_setting_not_assigned_errror('PersistentStoreDatabaseSetting', name) @classmethod def create_persistent_store(cls, db_name, connection_name, spatial=False, initializer='', refresh=False, @@ -961,7 +964,7 @@ def list_persistent_store_connections(cls): db_app = TethysApp.objects.get(package=cls.package) ps_connection_settings = db_app.persistent_store_connection_settings return [ps_connection_setting.name for ps_connection_setting in ps_connection_settings - if 'tethys-testing_' not in ps_database_setting.name] + if 'tethys-testing_' not in ps_connection_setting.name] @classmethod def persistent_store_exists(cls, name): @@ -1006,3 +1009,22 @@ def persistent_store_exists(cls, name): # Check if it exists ps_database_setting.persistent_store_database_exists() return True + + @classmethod + def _log_tethys_app_setting_not_assigned_error(cls, setting_type, setting_name): + """ + Logs useful traceback and message without actually raising an exception when an attempt + to access a non-existent setting is made. + + Args: + settings_type (str, required): + Name of specific settings class (e.g. CustomTethysAppSetting, PersistentStoreDatabaseSetting etc). + setting_name (str, required): + Name attribute of the setting. + """ + tethys_log.warn('Tethys app setting is not assigned.\nTraceback (most recent call last):\n{0} ' + 'TethysAppSettingNotAssigned: {1} named "{2}" has not been assigned. ' + 'Please visit the setting page for the app {3} and assign all required settings.' + .format(traceback.format_stack(limit=3)[0], setting_type, setting_name, cls.name) + ) + From b0db2cb51d74b05ba46267caf025f1e3c2f5b81f Mon Sep 17 00:00:00 2001 From: sdc50 Date: Thu, 30 Nov 2017 06:44:42 -0600 Subject: [PATCH 091/215] App loading refactor (#319) * Quick fix to handle unset settings. Partially addresses issue #249. (cherry picked from commit 1a2830e) * Refactor app loading to make it more robust. * Finish refactoring app loading to handle unset settings. Fixes issue #249. * Fix logging so it is only configured in the settings file. * Refactor app loading to make it more robust. * Fix logging so it is only configured in the settings file. * Finish refactoring app loading to handle unset settings. Fixes issue #249. --- tethys_apps/__init__.py | 10 - tethys_apps/app_harvester.py | 102 ++++-- tethys_apps/base/app_base.py | 338 +++++++++++++++--- tethys_apps/cli/gen_templates/settings | 19 +- tethys_apps/exceptions.py | 5 +- .../commands/tethys_app_uninstall.py | 10 +- tethys_apps/models.py | 114 +++++- .../static/tethys_apps/css/app_library.css | 6 + .../templates/tethys_apps/app_library.html | 22 +- tethys_apps/urls.py | 21 +- tethys_apps/utilities.py | 142 +------- tethys_apps/views.py | 12 +- 12 files changed, 529 insertions(+), 272 deletions(-) diff --git a/tethys_apps/__init__.py b/tethys_apps/__init__.py index cb2a86b23..4da7df4b0 100644 --- a/tethys_apps/__init__.py +++ b/tethys_apps/__init__.py @@ -7,20 +7,10 @@ * License: BSD 2-Clause ******************************************************************************** """ -import logging -import warnings # Load the custom app config default_app_config = 'tethys_apps.apps.TethysAppsConfig' -# Configure logging -tethys_log = logging.getLogger('tethys') -default_log_format = logging.Formatter('%(levelname)s:%(name)s:%(message)s') -default_log_handler = logging.StreamHandler() -default_log_handler.setFormatter(default_log_format) -tethys_log.addHandler(default_log_handler) -logging.captureWarnings(True) -warnings.filterwarnings(action='always', category=DeprecationWarning) diff --git a/tethys_apps/app_harvester.py b/tethys_apps/app_harvester.py index 9f68ce0a7..0206f0d8b 100644 --- a/tethys_apps/app_harvester.py +++ b/tethys_apps/app_harvester.py @@ -10,10 +10,17 @@ import os import inspect +import logging + +from django.db.utils import ProgrammingError +from django.core.exceptions import ObjectDoesNotExist from tethys_apps.base import TethysAppBase + from .terminal_colors import TerminalColors +tethys_log = logging.getLogger('tethys.' + __name__) + class SingletonAppHarvester(object): """ @@ -80,37 +87,74 @@ def _harvest_app_instances(self, app_packages_list): # Create the path to the app module in the custom app package app_module_name = '.'.join(['tethys_apps.tethysapp', app_package, 'app']) - # Import the app.py module from the custom app package programmatically - # (e.g.: apps.apps..app) - app_module = __import__(app_module_name, fromlist=['']) - - for name, obj in inspect.getmembers(app_module): - # Retrieve the members of the app_module and iterate through - # them to find the the class that inherits from AppBase. - try: - # issubclass() will fail if obj is not a class - if (issubclass(obj, TethysAppBase)) and (obj is not TethysAppBase): - # Assign a handle to the class - _appClass = getattr(app_module, name) - - # Instantiate app and validate - app_instance = _appClass() - validated_app_instance = self._validate_app(app_instance) - - # compile valid apps - if validated_app_instance: - valid_app_instance_list.append(validated_app_instance) - - # Notify user that the app has been loaded - loaded_apps.append(app_package) - - except TypeError: - '''DO NOTHING''' - except: - raise + try: + # Import the app.py module from the custom app package programmatically + # (e.g.: apps.apps..app) + app_module = __import__(app_module_name, fromlist=['']) + + + for name, obj in inspect.getmembers(app_module): + # Retrieve the members of the app_module and iterate through + # them to find the the class that inherits from AppBase. + try: + # issubclass() will fail if obj is not a class + if (issubclass(obj, TethysAppBase)) and (obj is not TethysAppBase): + # Assign a handle to the class + _appClass = getattr(app_module, name) + + # Instantiate app and validate + app_instance = _appClass() + validated_app_instance = self._validate_app(app_instance) + + # sync app with Tethys db + app_instance.sync_with_tethys_db() + + # validate app url patterns + app_instance.url_patterns + + # register app permissions + try: + app_instance.register_app_permissions() + except (ProgrammingError, ObjectDoesNotExist) as e: + tethys_log.error(e) + + # compile valid apps + if validated_app_instance: + valid_app_instance_list.append(validated_app_instance) + + # Notify user that the app has been loaded + loaded_apps.append(app_package) + + except TypeError: + '''DO NOTHING''' + except: + tethys_log.exception( + 'App {0} not loaded because of the following error:'.format(app_package)) + continue # Save valid apps self.apps = valid_app_instance_list + self.sync_tethys_app_db() + # Update user - print('Tethys Apps Loaded: {0}'.format(' '.join(loaded_apps))) + print(TerminalColors.BLUE + 'Tethys Apps Loaded: ' + + TerminalColors.ENDC + '{0}'.format(' '.join(loaded_apps)) + '\n') + + def sync_tethys_app_db(self): + """ + Sync installed apps with database. + """ + from tethys_apps.models import TethysApp + + try: + # Make pass to remove apps that were uninstalled + db_apps = TethysApp.objects.all() + installed_app_packages = [app.package for app in self.apps] + + for db_apps in db_apps: + if db_apps.package not in installed_app_packages: + db_apps.delete() + + except Exception as e: + tethys_log.error(e) \ No newline at end of file diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index d165e81b9..9cd10ea92 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -15,8 +15,10 @@ from django.core.exceptions import ObjectDoesNotExist from django.http import HttpRequest from django.utils.functional import SimpleLazyObject +from django.conf.urls import url from tethys_apps.base.testing.environment import is_testing_environment +from tethys_apps.base import permissions from .handoff import HandoffManager from .workspace import TethysWorkspace from ..exceptions import TethysAppSettingDoesNotExist, TethysAppSettingNotAssigned @@ -52,6 +54,9 @@ class TethysAppBase(object): enable_feedback = False feedback_emails = [] + def __init__(self): + self._url_patterns = None + def __unicode__(self): """ String representation @@ -64,6 +69,62 @@ def __repr__(self): """ return ''.format(self.name) + @property + def url_patterns(self): + """ + Generate the url pattern lists for app and namespace them accordingly. + """ + + if self._url_patterns is None: + + app_url_patterns = dict() + + if hasattr(self, 'url_maps'): + url_maps = self.url_maps() + else: + url_maps = [] + + for url_map in url_maps: + app_root = self.root_url + app_namespace = app_root.replace('-', '_') + + if app_namespace not in app_url_patterns: + app_url_patterns[app_namespace] = [] + + # Create django url object + if isinstance(url_map.controller, basestring): + controller_parts = url_map.controller.split('.') + module_name = '.'.join(controller_parts[:-1]) + function_name = controller_parts[-1] + try: + module = __import__(module_name, fromlist=[function_name]) + except: + error_msg = 'The following error occurred while trying to import the controller function ' \ + '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) + tethys_log.error(error_msg) + raise + try: + controller_function = getattr(module, function_name) + except AttributeError as e: + error_msg = 'The following error occurred while tyring to access the controller function ' \ + '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) + tethys_log.error(error_msg) + raise + else: + controller_function = url_map.controller + django_url = url(url_map.url, controller_function, name=url_map.name) + + # Append to namespace list + app_url_patterns[app_namespace].append(django_url) + self._url_patterns = app_url_patterns + + return self._url_patterns + + @property + def db_app(self): + from tethys_apps.models import TethysApp + return TethysApp.objects.get(package=self.package) + def url_maps(self): """ Override this method to define the URL Maps for your app. Your ``UrlMap`` objects must be created from a ``UrlMap`` class that is bound to the ``root_url`` of your app. Use the ``url_map_maker()`` function to create the bound ``UrlMap`` class. If you generate your app project from the scaffold, this will be done automatically. @@ -379,6 +440,129 @@ def permissions(self): """ return None + def register_app_permissions(self): + """ + Register and sync the app permissions. + """ + from guardian.shortcuts import assign_perm, remove_perm, get_perms + from django.contrib.contenttypes.models import ContentType + from django.contrib.auth.models import Permission, Group + from tethys_apps.models import TethysApp + + perms = self.permissions() + app_permissions = dict() + app_groups = dict() + + # Name spaced prefix for app permissions + # e.g. my_first_app:view_things + # e.g. my_first_app | View things + perm_codename_prefix = self.package + ':' + perm_name_prefix = self.package + ' | ' + + if perms is not None: + # Thing is either a Permission or a PermissionGroup object + + for thing in perms: + # Permission Case + if isinstance(thing, permissions.Permission): + # Name space the permissions and add it to the list + permission_codename = perm_codename_prefix + thing.name + permission_name = perm_name_prefix + thing.description + app_permissions[permission_codename] = permission_name + + # PermissionGroup Case + elif isinstance(thing, permissions.PermissionGroup): + # Record in dict of groups + group_permissions = [] + group_name = perm_codename_prefix + thing.name + + for perm in thing.permissions: + # Name space the permissions and add it to the list + permission_codename = perm_codename_prefix + perm.name + permission_name = perm_name_prefix + perm.description + app_permissions[permission_codename] = permission_name + group_permissions.append(permission_codename) + + # Store all groups for all apps + app_groups[group_name] = {'permissions': group_permissions, 'app_package': self.package} + + # Get the TethysApp content type + tethys_content_type = ContentType.objects.get( + app_label='tethys_apps', + model='tethysapp' + ) + + # Remove any permissions that no longer exist + db_app_permissions = Permission.objects.filter(content_type=tethys_content_type).all() + + for db_app_permission in db_app_permissions: + # Delete the permission if the permission is no longer required by an app + if db_app_permission.codename not in app_permissions: + db_app_permission.delete() + + # Create permissions that need to be created + for perm in app_permissions: + # Create permission if it doesn't exist + try: + # If permission exists, update it + p = Permission.objects.get(codename=perm) + + p.name = app_permissions[perm] + p.content_type = tethys_content_type + p.save() + + except Permission.DoesNotExist: + p = Permission( + name=app_permissions[perm], + codename=perm, + content_type=tethys_content_type + ) + p.save() + + # Remove any groups that no longer exist + db_groups = Group.objects.all() + db_apps = TethysApp.objects.all() + db_app_names = [db_app.package for db_app in db_apps] + + for db_group in db_groups: + db_group_name_parts = db_group.name.split(':') + + # Only perform maintenance on groups that belong to Tethys Apps + if (len(db_group_name_parts) > 1) and (db_group_name_parts[0] in db_app_names): + + # Delete groups that is no longer required by an app + if db_group.name not in app_groups: + db_group.delete() + + # Create groups that need to be created + for group in app_groups: + # Look up the app + db_app = TethysApp.objects.get(package=app_groups[group]['app_package']) + + # Create group if it doesn't exist + try: + # If it exists, update the permissions assigned to it + g = Group.objects.get(name=group) + + # Get the permissions for the group and remove all of them + perms = get_perms(g, db_app) + + for p in perms: + remove_perm(p, g, db_app) + + # Assign the permission to the group and the app instance + for p in app_groups[group]['permissions']: + assign_perm(p, g, db_app) + + except Group.DoesNotExist: + # Create a new group + g = Group(name=group) + g.save() + + # Assign the permission to the group and the app instance + for p in app_groups[group]['permissions']: + assign_perm(p, g, db_app) + def job_templates(self): """ Override this method to define job templates to easily create and submit jobs in your app. @@ -552,10 +736,11 @@ def get_custom_setting(cls, name): custom_settings = db_app.custom_settings try: custom_setting = custom_settings.get(name=name) + return custom_setting.get_value() except ObjectDoesNotExist: - raise TethysAppSettingDoesNotExist('CustomTethysAppSetting named "{0}" does not exist.'.format(name)) + raise TethysAppSettingDoesNotExist('CustomTethysAppSetting', name, cls.name) + - return custom_setting.get_value() @classmethod def get_dataset_service(cls, name, as_public_endpoint=False, as_endpoint=False, @@ -587,21 +772,11 @@ def get_dataset_service(cls, name, as_public_endpoint=False, as_endpoint=False, dataset_services_settings = db_app.dataset_services_settings try: - dataset_services_settings = dataset_services_settings.get(name=name) + dataset_services_setting = dataset_services_settings.get(name=name) + dataset_services_setting.get_value(as_public_endpoint=as_public_endpoint, as_endpoint=as_endpoint, + as_engine=as_engine) except ObjectDoesNotExist: - raise TethysAppSettingDoesNotExist('DatasetServiceSetting named "{0}" does not exist.'.format(name)) - - dataset_service = dataset_services_settings.dataset_service - - if not dataset_service: - return None - elif as_engine: - return dataset_service.get_engine() - elif as_endpoint: - return dataset_service.endpoint - elif as_public_endpoint: - return dataset_service.public_endpoint - return dataset_service + raise TethysAppSettingDoesNotExist('DatasetServiceSetting', name, cls.name) @classmethod def get_spatial_dataset_service(cls, name, as_public_endpoint=False, as_endpoint=False, as_wms=False, @@ -637,24 +812,10 @@ def get_spatial_dataset_service(cls, name, as_public_endpoint=False, as_endpoint try: spatial_dataset_service_setting = spatial_dataset_service_settings.get(name=name) + spatial_dataset_service_setting.get_value(as_public_endpoint=as_public_endpoint, as_endpoint=as_endpoint, + as_wms=as_wms, as_wfs=as_wfs, as_engine=as_engine) except ObjectDoesNotExist: - raise TethysAppSettingDoesNotExist('SpatialDatasetServiceSetting named "{0}" does not exist.'.format(name)) - - spatial_dataset_service = spatial_dataset_service_setting.spatial_dataset_service - - if not spatial_dataset_service: - return None - elif as_engine: - return spatial_dataset_service.get_engine() - elif as_wms: - return spatial_dataset_service.endpoint.split('/rest')[0] + '/wms' - elif as_wfs: - return spatial_dataset_service.endpoint.split('/rest')[0] + '/ows' - elif as_endpoint: - return spatial_dataset_service.endpoint - elif as_public_endpoint: - return spatial_dataset_service.public_endpoint - return spatial_dataset_service + raise TethysAppSettingDoesNotExist('SpatialDatasetServiceSetting', name, cls.name) @classmethod def get_web_processing_service(cls, name, as_public_endpoint=False, as_endpoint=False, as_engine=False): @@ -684,22 +845,13 @@ def get_web_processing_service(cls, name, as_public_endpoint=False, as_endpoint= wps_services_settings = db_app.wps_services_settings try: wps_service_setting = wps_services_settings.objects.get(name=name) + return wps_service_setting.get_value(as_public_endpoint=as_public_endpoint, + as_endpoint=as_endpoint, as_engine=as_engine) except ObjectDoesNotExist: - raise TethysAppSettingDoesNotExist('WebProcessingServiceSetting named "{0}" does not exist.'.format(name)) - wps_service = wps_service_setting.web_processing_service - - if not wps_service: - return None - elif as_engine: - return wps_service.get_engine() - elif as_endpoint: - return wps_service.endpoint - elif as_public_endpoint: - return wps_service.pubic_endpoint - return wps_service + raise TethysAppSettingDoesNotExist('WebProcessingServiceSetting', name, cls.name) @classmethod - def get_persistent_store_connection(cls, name, as_url=False, as_sessionmaker=False): + def get_persistent_store_connection(cls, name, as_url=False, as_sessionmaker=False, as_engine=True): """ Gets an SQLAlchemy Engine or URL object for the named persistent store connection. @@ -735,9 +887,9 @@ def get_persistent_store_connection(cls, name, as_url=False, as_sessionmaker=Fal try: ps_connection_setting = ps_connection_settings.get(name=name) - return ps_connection_setting.get_engine(as_url=as_url, as_sessionmaker=as_sessionmaker) + return ps_connection_setting.get_value(as_url=as_url, as_sessionmaker=as_sessionmaker, as_engine=as_engine) except ObjectDoesNotExist: - raise TethysAppSettingDoesNotExist('PersistentStoreConnectionSetting named "{0}" does not exist.'.format(name)) + raise TethysAppSettingDoesNotExist('PersistentStoreConnectionSetting', name, cls.name) except TethysAppSettingNotAssigned: cls._log_tethys_app_setting_not_assigned_errror('PersistentStoreConnectionSetting', name) @@ -750,7 +902,7 @@ def get_persistent_store_database(cls, name, as_url=False, as_sessionmaker=False name(string): Name of the PersistentStoreConnectionSetting as defined in app.py. as_url(bool): Return SQLAlchemy URL object instead of engine object if True. Defaults to False. as_sessionmaker(bool): Returns SessionMaker class bound to the engine if True. Defaults to False. - + Returns: sqlalchemy.Engine or sqlalchemy.URL: An SQLAlchemy Engine or URL object for the persistent store requested. @@ -777,11 +929,12 @@ def get_persistent_store_database(cls, name, as_url=False, as_sessionmaker=False try: ps_database_setting = ps_database_settings.get(name=name) - return ps_database_setting.get_engine(with_db=True, as_url=as_url, as_sessionmaker=as_sessionmaker) + return ps_database_setting.get_value(with_db=True, as_url=as_url, as_sessionmaker=as_sessionmaker, + as_engine=True) except ObjectDoesNotExist: - raise TethysAppSettingDoesNotExist('PersistentStoreDatabaseSetting named "{0}" does not exist.'.format(name)) + raise TethysAppSettingDoesNotExist('PersistentStoreDatabaseSetting', name, cls.name) except TethysAppSettingNotAssigned: - cls._log_tethys_app_setting_not_assigned_errror('PersistentStoreDatabaseSetting', name) + cls._log_tethys_app_setting_not_assigned_errror('PersistentStoreConnectionSetting', name) @classmethod def create_persistent_store(cls, db_name, connection_name, spatial=False, initializer='', refresh=False, @@ -829,8 +982,7 @@ def create_persistent_store(cls, db_name, connection_name, spatial=False, initia try: ps_connection_setting = ps_connection_settings.get(name=connection_name) except ObjectDoesNotExist: - raise TethysAppSettingDoesNotExist( - 'PersistentStoreConnectionSetting named "{0}" does not exist.'.format(connection_name)) + raise TethysAppSettingDoesNotExist('PersistentStoreConnectionSetting', connection_name, cls.name) ps_service = ps_connection_setting.persistent_store_service @@ -1010,6 +1162,83 @@ def persistent_store_exists(cls, name): ps_database_setting.persistent_store_database_exists() return True + def sync_with_tethys_db(self): + """ + Sync installed apps with database. + """ + from django.conf import settings + from tethys_apps.models import TethysApp + + try: + # Make pass to add apps to db that are newly installed + # Query to see if installed app is in the database + db_apps = TethysApp.objects.\ + filter(package__exact=self.package).all() + + # If the app is not in the database, then add it + if len(db_apps) == 0: + db_app = TethysApp( + name=self.name, + package=self.package, + description=self.description, + enable_feedback=self.enable_feedback, + feedback_emails=self.feedback_emails, + index=self.index, + icon=self.icon, + root_url=self.root_url, + color=self.color, + tags=self.tags + ) + db_app.save() + + # custom settings + db_app.add_settings(self.custom_settings()) + # dataset services settings + db_app.add_settings(self.dataset_service_settings()) + # spatial dataset services settings + db_app.add_settings(self.spatial_dataset_service_settings()) + # wps settings + db_app.add_settings(self.web_processing_service_settings()) + # persistent store settings + db_app.add_settings(self.persistent_store_settings()) + + db_app.save() + + # If the app is in the database, update developer-first attributes + elif len(db_apps) == 1: + db_app = db_apps[0] + db_app.index = self.index + db_app.icon = self.icon + db_app.root_url = self.root_url + db_app.color = self.color + db_app.save() + + if hasattr(settings, 'DEBUG') and settings.DEBUG: + db_app.name = self.name + db_app.description = self.description + db_app.tags = self.tags + db_app.enable_feedback = self.enable_feedback + db_app.feedback_emails = self.feedback_emails + db_app.save() + + # custom settings + db_app.add_settings(self.custom_settings()) + # dataset services settings + db_app.add_settings(self.dataset_service_settings()) + # spatial dataset services settings + db_app.add_settings(self.spatial_dataset_service_settings()) + # wps settings + db_app.add_settings(self.web_processing_service_settings()) + # persistent store settings + db_app.add_settings(self.persistent_store_settings()) + db_app.save() + + # More than one instance of the app in db... (what to do here?) + elif len(db_apps) >= 2: + pass + except Exception as e: + tethys_log.error(e) + @classmethod def _log_tethys_app_setting_not_assigned_error(cls, setting_type, setting_name): """ @@ -1027,4 +1256,3 @@ def _log_tethys_app_setting_not_assigned_error(cls, setting_type, setting_name): 'Please visit the setting page for the app {3} and assign all required settings.' .format(traceback.format_stack(limit=3)[0], setting_type, setting_name, cls.name) ) - diff --git a/tethys_apps/cli/gen_templates/settings b/tethys_apps/cli/gen_templates/settings index dacdadca8..d14e728fc 100644 --- a/tethys_apps/cli/gen_templates/settings +++ b/tethys_apps/cli/gen_templates/settings @@ -45,18 +45,31 @@ SESSION_SECURITY_EXPIRE_AFTER = 900 LOGGING = { 'version': 1, 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '%(levelname)s:%(name)s:%(message)s' + }, + 'simple': { + 'format': '%(levelname)s %(message)s' + }, + }, 'handlers': { - 'console': { + 'console_simple': { + 'class': 'logging.StreamHandler', + 'formatter': 'simple' + }, + 'console_verbose': { 'class': 'logging.StreamHandler', + 'formatter': 'verbose' }, }, 'loggers': { 'django': { - 'handlers': ['console'], + 'handlers': ['console_simple'], 'level': os.getenv('DJANGO_LOG_LEVEL', 'WARNING'), }, 'tethys': { - 'handlers': ['console'], + 'handlers': ['console_verbose'], 'level': 'INFO', } }, diff --git a/tethys_apps/exceptions.py b/tethys_apps/exceptions.py index a550498ac..512fae1bf 100644 --- a/tethys_apps/exceptions.py +++ b/tethys_apps/exceptions.py @@ -10,7 +10,10 @@ class TethysAppSettingDoesNotExist(Exception): - pass + def __init__(self, setting_type, setting_name, app_name, *args, **kwargs): + msg = 'A {0} named "{1}" does not exist in the {2} app.'\ + .format(setting_type, setting_name, app_name) + super(TethysAppSettingDoesNotExist, self).__init__(msg, *args, **kwargs) class TethysAppSettingNotAssigned(Exception): diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index d9b9b1451..c43aee672 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -10,6 +10,7 @@ import os import shutil import subprocess +import warnings from django.core.management.base import BaseCommand from tethys_apps.helpers import get_installed_tethys_apps @@ -35,7 +36,7 @@ def handle(self, *args, **options): app_name = app_name[prefix_length:] if app_name not in installed_apps: - self.stdout.write('WARNING: App with name "{0}" cannot be uninstalled, because it is not installed.'.format(app_name)) + warnings.warn('WARNING: App with name "{0}" cannot be uninstalled, because it is not installed.'.format(app_name)) exit(0) app_with_prefix = '{0}-{1}'.format(PREFIX, app_name) @@ -56,8 +57,11 @@ def handle(self, *args, **options): # Remove app from database from tethys_apps.models import TethysApp - db_app = TethysApp.objects.get(package=app_name) - db_app.delete() + try: + db_app = TethysApp.objects.get(package=app_name) + db_app.delete() + except TethysApp.DoesNotExist: + warnings.warn('WARNING: The app was not found in the database.') try: # Remove directory diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 0834765ff..3ee8e517f 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -111,6 +111,16 @@ def persistent_store_database_settings(self): return self.settings_set.exclude(persistentstoredatabasesetting__isnull=True) \ .select_subclasses('persistentstoredatabasesetting') + @property + def configured(self): + for setting in [s for s in self.settings if s.required]: + try: + if setting.get_value() is None: + return False + except TethysAppSettingNotAssigned: + return False + return True + class TethysAppSetting(models.Model): """ @@ -147,6 +157,9 @@ def initialize(self): self.initializer_function(self.initialized) self.initialized = True + def get_value(self, *args, **kwargs): + raise NotImplementedError() + class CustomSetting(TethysAppSetting): """ @@ -237,14 +250,18 @@ def get_value(self): Get the value, automatically casting it to the correct type. """ if self.value == '': - return None - elif self.type == self.TYPE_STRING: + return None # TODO Why don't we raise a NotAssigned error here? + + if self.type == self.TYPE_STRING: return self.value - elif self.type == self.TYPE_FLOAT: + + if self.type == self.TYPE_FLOAT: return float(self.value) - elif self.type == self.TYPE_INTEGER: + + if self.type == self.TYPE_INTEGER: return int(self.value) - elif self.type == self.TYPE_BOOLEAN: + + if self.type == self.TYPE_BOOLEAN: return self.value.lower() in self.TRUTHY_BOOL_STRINGS @@ -294,6 +311,22 @@ def clean(self): if not self.dataset_service and self.required: raise ValidationError('Required.') + def get_value(self, as_public_endpoint=False, as_endpoint=False, as_engine=False): + + if not self.dataset_service: + return None # TODO Why don't we raise a NotAssigned error here? + + # TODO order here manters. Is this the order we want? + if as_engine: + return self.dataset_service.get_engine() + + if as_endpoint: + return self.dataset_service.endpoint + + if as_public_endpoint: + return self.dataset_service.public_endpoint + + return self.dataset_service class SpatialDatasetServiceSetting(TethysAppSetting): """ @@ -333,6 +366,29 @@ def clean(self): if not self.spatial_dataset_service and self.required: raise ValidationError('Required.') + def get_value(self, as_public_endpoint=False, as_endpoint=False, as_wms=False, + as_wfs=False, as_engine=False): + + if not self.spatial_dataset_service: + return None # TODO Why don't we raise a NotAssigned error here? + + # TODO order here manters. Is this the order we want? + if as_engine: + return self.spatial_dataset_service.get_engine() + + if as_wms: + return self.spatial_dataset_service.endpoint.split('/rest')[0] + '/wms' + + if as_wfs: + return self.spatial_dataset_service.endpoint.split('/rest')[0] + '/ows' + + if as_endpoint: + return self.spatial_dataset_service.endpoint + + if as_public_endpoint: + return self.spatial_dataset_service.public_endpoint + + return self.spatial_dataset_service class WebProcessingServiceSetting(TethysAppSetting): """ @@ -365,6 +421,24 @@ def clean(self): if not self.web_processing_service and self.required: raise ValidationError('Required.') + def get_value(self, as_public_endpoint=False, as_endpoint=False, as_engine=False): + wps_service = self.web_processing_service + + if not wps_service: + return None # TODO Why don't we raise a NotAssigned error here? + + # TODO order here manters. Is this the order we want? + if as_engine: + return wps_service.get_engine() + + if as_endpoint: + return wps_service.endpoint + + if as_public_endpoint: + return wps_service.pubic_endpoint + + return wps_service + class PersistentStoreConnectionSetting(TethysAppSetting): """ @@ -397,7 +471,7 @@ def clean(self): if not self.persistent_store_service and self.required: raise ValidationError('Required.') - def get_engine(self, as_url=False, as_sessionmaker=False): + def get_value(self, as_url=False, as_sessionmaker=False, as_engine=False): """ Get the SQLAlchemy engine from the connected persistent store service """ @@ -408,6 +482,9 @@ def get_engine(self, as_url=False, as_sessionmaker=False): raise TethysAppSettingNotAssigned('Cannot create engine or url for PersistentStoreConnection "{0}" for app ' '"{1}": no PersistentStoreService found.'.format(self.name, self.tethys_app.package)) + # TODO order here manters. Is this the order we want? + if as_engine: + return ps_service.get_engine() if as_sessionmaker: return sessionmaker(bind=ps_service.get_engine()) @@ -415,7 +492,7 @@ def get_engine(self, as_url=False, as_sessionmaker=False): if as_url: return ps_service.get_url() - return ps_service.get_engine() + return ps_service class PersistentStoreDatabaseSetting(TethysAppSetting): @@ -482,7 +559,7 @@ def get_namespaced_persistent_store_name(self): return '_'.join((self.tethys_app.package, safe_name)) - def get_engine(self, with_db=False, as_url=False, as_sessionmaker=False): + def get_value(self, with_db=False, as_url=False, as_sessionmaker=False, as_engine=True): """ Get the SQLAlchemy engine from the connected persistent store service """ @@ -493,7 +570,7 @@ def get_engine(self, with_db=False, as_url=False, as_sessionmaker=False): raise TethysAppSettingNotAssigned('Cannot create engine or url for PersistentStoreDatabase "{0}" for app ' '"{1}": no PersistentStoreService found.'.format(self.name, self.tethys_app.package)) - + # TODO order here manters. Is this the order we want? if with_db: ps_service.database = self.get_namespaced_persistent_store_name() @@ -503,14 +580,17 @@ def get_engine(self, with_db=False, as_url=False, as_sessionmaker=False): if as_url: return ps_service.get_url() - return ps_service.get_engine() + if as_engine: + return ps_service.get_engine() + + return ps_service def persistent_store_database_exists(self): """ Returns True if the persistent store database exists. """ # Get the database engine - engine = self.get_engine() + engine = self.get_value(as_engine=True) namespaced_name = self.get_namespaced_persistent_store_name() # Cannot create databases in a transaction: connect and commit to close transaction @@ -548,7 +628,7 @@ def drop_persistent_store_database(self): )) # Get the database engine - engine = self.get_engine() + engine = self.get_value(as_engine=True) # Connection drop_connection = engine.connect() @@ -590,8 +670,8 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False log = logging.getLogger('tethys') # Connection engine - url = self.get_engine(as_url=True) - engine = self.get_engine() + url = self.get_value(as_url=True) + engine = self.get_value(as_engine=True) namespaced_ps_name = self.get_namespaced_persistent_store_name() db_exists = self.persistent_store_database_exists() @@ -641,7 +721,7 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False # -------------------------------------------------------------------------------------------------------------# if self.spatial: # Connect to new database - new_db_engine = self.get_engine(with_db=True) + new_db_engine = self.get_value(with_db=True, as_engine=True) new_db_connection = new_db_engine.connect() # Notify user @@ -673,9 +753,9 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False )) try: if force_first_time: - self.initializer_function(self.get_engine(with_db=True), True) + self.initializer_function(self.get_value(with_db=True, as_engine=True), True) else: - self.initializer_function(self.get_engine(with_db=True), not self.initialized) + self.initializer_function(self.get_value(with_db=True, as_engine=True), not self.initialized) except Exception as e: print(type(e)) raise PersistentStoreInitializerError(e) diff --git a/tethys_apps/static/tethys_apps/css/app_library.css b/tethys_apps/static/tethys_apps/css/app_library.css index 849ce94a0..3a615f83e 100644 --- a/tethys_apps/static/tethys_apps/css/app_library.css +++ b/tethys_apps/static/tethys_apps/css/app_library.css @@ -131,6 +131,9 @@ body { border-radius: 5px; box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1); } +#app-list .app-container.unconfigured { + background: #666666; +} #app-list .app-container .app-icon img { width: 170px; margin: 15px; @@ -147,6 +150,9 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +#app-list .app-container.unconfigured .app-title { + color: #ffffff; +} #app-list .app-container .app-help-icon { position: absolute; bottom: 2px; diff --git a/tethys_apps/templates/tethys_apps/app_library.html b/tethys_apps/templates/tethys_apps/app_library.html index 03728c638..c14a76daf 100644 --- a/tethys_apps/templates/tethys_apps/app_library.html +++ b/tethys_apps/templates/tethys_apps/app_library.html @@ -24,7 +24,7 @@
- {% if apps %} + {% if apps.configured or apps.unconfigured %}
@@ -45,7 +45,7 @@
- {% for app in apps %} + {% for app in apps.configured %} {% if app.show_in_apps_library and app.enabled %} {% else %}

There are no apps loaded.

diff --git a/tethys_apps/urls.py b/tethys_apps/urls.py index e71044ca6..f83f7bb21 100644 --- a/tethys_apps/urls.py +++ b/tethys_apps/urls.py @@ -7,15 +7,12 @@ * License: BSD 2-Clause ******************************************************************************** """ -from django.db.utils import ProgrammingError -from django.core.exceptions import ObjectDoesNotExist from django.conf.urls import url, include -from tethys_apps.utilities import generate_app_url_patterns, sync_tethys_app_db, register_app_permissions +from tethys_apps.utilities import get_app_url_patterns from tethys_apps.views import library, send_beta_feedback_email -from tethys_apps import tethys_log +import logging -# Sync the tethys apps database -sync_tethys_app_db() +tethys_log = logging.getLogger('tethys.' + __name__) urlpatterns = [ url(r'^$', library, name='app_library'), @@ -23,15 +20,15 @@ ] # Append the app urls urlpatterns -app_url_patterns = generate_app_url_patterns() +app_url_patterns = get_app_url_patterns() for namespace, urls in app_url_patterns.items(): root_pattern = r'^{0}/'.format(namespace.replace('_', '-')) urlpatterns.append(url(root_pattern, include(urls, namespace=namespace))) -# Register permissions here? -try: - register_app_permissions() -except (ProgrammingError, ObjectDoesNotExist) as e: - tethys_log.error(e) +# # Register permissions here? +# try: +# register_app_permissions() +# except (ProgrammingError, ObjectDoesNotExist) as e: +# tethys_log.error(e) diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 73370aa03..accc2b09e 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -9,23 +9,18 @@ """ import logging import os -import sys -import traceback from collections import OrderedDict as SortedDict -from django.conf.urls import url from django.contrib.staticfiles import utils from django.contrib.staticfiles.finders import BaseFinder from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.core.files.storage import FileSystemStorage from django.utils._os import safe_join -from past.builtins import basestring -from tethys_apps import tethys_log from tethys_apps.app_harvester import SingletonAppHarvester from tethys_apps.base import permissions from tethys_apps.models import TethysApp -log = logging.getLogger('tethys.tethys_apps.utilities') +tethys_log = logging.getLogger('tethys.' + __name__) def register_app_permissions(): @@ -156,7 +151,7 @@ def register_app_permissions(): assign_perm(p, g, db_app) -def generate_app_url_patterns(): +def get_app_url_patterns(): """ Generate the url pattern lists for each app and namespace them accordingly. """ @@ -167,46 +162,7 @@ def generate_app_url_patterns(): app_url_patterns = dict() for app in apps: - if hasattr(app, 'url_maps'): - url_maps = app.url_maps() - elif hasattr(app, 'controllers'): - url_maps = app.controllers() - else: - url_maps = None - - if url_maps: - for url_map in url_maps: - app_root = app.root_url - app_namespace = app_root.replace('-', '_') - - if app_namespace not in app_url_patterns: - app_url_patterns[app_namespace] = [] - - # Create django url object - if isinstance(url_map.controller, basestring): - controller_parts = url_map.controller.split('.') - module_name = '.'.join(controller_parts[:-1]) - function_name = controller_parts[-1] - try: - module = __import__(module_name, fromlist=[function_name]) - except ImportError: - error_msg = 'The following error occurred while trying to import the controller function ' \ - '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) - log.error(error_msg) - sys.exit(1) - try: - controller_function = getattr(module, function_name) - except AttributeError as e: - error_msg = 'The following error occurred while tyring to access the controller function ' \ - '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) - log.error(error_msg) - sys.exit(1) - else: - controller_function = url_map.controller - django_url = url(url_map.url, controller_function, name=url_map.name) - - # Append to namespace list - app_url_patterns[app_namespace].append(django_url) + app_url_patterns.update(app.url_patterns) return app_url_patterns @@ -294,98 +250,6 @@ def list(self, ignore_patterns): yield path, storage -def sync_tethys_app_db(): - """ - Sync installed apps with database. - """ - from django.conf import settings - - # Get the harvester - harvester = SingletonAppHarvester() - - try: - # Make pass to remove apps that were uninstalled - db_apps = TethysApp.objects.all() - installed_app_packages = [app.package for app in harvester.apps] - - for db_apps in db_apps: - if db_apps.package not in installed_app_packages: - db_apps.delete() - - # Make pass to add apps to db that are newly installed - installed_apps = harvester.apps - - for installed_app in installed_apps: - # Query to see if installed app is in the database - db_apps = TethysApp.objects.\ - filter(package__exact=installed_app.package).\ - all() - - # If the app is not in the database, then add it - if len(db_apps) == 0: - app = TethysApp( - name=installed_app.name, - package=installed_app.package, - description=installed_app.description, - enable_feedback=installed_app.enable_feedback, - feedback_emails=installed_app.feedback_emails, - index=installed_app.index, - icon=installed_app.icon, - root_url=installed_app.root_url, - color=installed_app.color, - tags=installed_app.tags - ) - app.save() - - # custom settings - app.add_settings(installed_app.custom_settings()) - # dataset services settings - app.add_settings(installed_app.dataset_service_settings()) - # spatial dataset services settings - app.add_settings(installed_app.spatial_dataset_service_settings()) - # wps settings - app.add_settings(installed_app.web_processing_service_settings()) - # persistent store settings - app.add_settings(installed_app.persistent_store_settings()) - - app.save() - - # If the app is in the database, update developer-first attributes - elif len(db_apps) == 1: - db_app = db_apps[0] - db_app.index = installed_app.index - db_app.icon = installed_app.icon - db_app.root_url = installed_app.root_url - db_app.color = installed_app.color - db_app.save() - - if hasattr(settings, 'DEBUG') and settings.DEBUG: - db_app.name = installed_app.name - db_app.description = installed_app.description - db_app.tags = installed_app.tags - db_app.enable_feedback = installed_app.enable_feedback - db_app.feedback_emails = installed_app.feedback_emails - db_app.save() - - # custom settings - db_app.add_settings(installed_app.custom_settings()) - # dataset services settings - db_app.add_settings(installed_app.dataset_service_settings()) - # spatial dataset services settings - db_app.add_settings(installed_app.spatial_dataset_service_settings()) - # wps settings - db_app.add_settings(installed_app.web_processing_service_settings()) - # persistent store settings - db_app.add_settings(installed_app.persistent_store_settings()) - db_app.save() - - # More than one instance of the app in db... (what to do here?) - elif len(db_apps) >= 2: - continue - except Exception as e: - log.error(e) - - def get_active_app(request=None, url=None): """ Get the active TethysApp object based on the request or URL. diff --git a/tethys_apps/views.py b/tethys_apps/views.py index 2ec5decd9..ed4c2facb 100644 --- a/tethys_apps/views.py +++ b/tethys_apps/views.py @@ -27,8 +27,18 @@ def library(request): # Retrieve the app harvester apps = TethysApp.objects.all() + configured_apps = list() + unconfigured_apps = list() + + for app in apps: + if app.configured: + configured_apps.append(app) + else: + if request.user.is_staff: + unconfigured_apps.append(app) + # Define the context object - context = {'apps': apps} + context = {'apps': {'configured': configured_apps, 'unconfigured': unconfigured_apps}} return render(request, 'tethys_apps/app_library.html', context) From 7731bfbe60a003f0e2b03f5fbb22cf8a0748cea2 Mon Sep 17 00:00:00 2001 From: Michael Souffront Date: Thu, 30 Nov 2017 10:43:34 -0700 Subject: [PATCH 092/215] Added documentation for SSL with Tethys Portal and Geosever (#324) * Added documentation for SSL implementation for Tethys Portal and Geoserver * updated https documentation --- .../production/images/geoserver_ssl.png | Bin 0 -> 230171 bytes docs/installation/production/installation.rst | 166 +++++++++++++++++- 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 docs/installation/production/images/geoserver_ssl.png diff --git a/docs/installation/production/images/geoserver_ssl.png b/docs/installation/production/images/geoserver_ssl.png new file mode 100644 index 0000000000000000000000000000000000000000..a078f11bdfb0a384645c437482ecf76fb1d3b03f GIT binary patch literal 230171 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

F! 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

T9Fu> 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%0DiTxaswO0H#`8#4gE*&p_MLG!%DKFH$HWAFzd9 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. -4. Install Apps +4. Setup SSL (https) on the Tethys and Geoserver (Recommended) +============================================================== + +SSL is the standard technology for establishing a secured connection between a web server and a browser. In order to create a secured connection, an SSL certificate and key are needed. An SSL certificate is simply a paragraph with letters and numbers that acts similar to a password. When users visit your website via https this certificate is verified and if it matches, then a connecton is established. An SSL certificate can be self-signed, or purchased from a Certificate Authority. Some of the top certificate authorities include: Digicert, VertiSign, GeoTrust, Comodo, Thawte, GoDaddy, and Nework Solutions. If your instance of Tethys is part of a larger organization, contact your IT to determine if an agreement with one of these authorities already exists. + +Once a certificate is obtained, it needs to be referenced in the Nginx configuration, which is the web server that Tethys uses in production. The configuration file can be found at: + +:: + + /home//tethys/src/tethys_portal/tethys_nginx.conf + +The file should look something like this: +:: + + # tethys_nginx.conf + + # the upstream component nginx needs to connect to + upstream django { + server unix://run/uwsgi/tethys.sock; # for a file socket + } + # configuration of the server + server { + # the port your site will be served on + listen 80; + # the domain name it will serve for + server_name ; # substitute your machine's IP address or FQDN + charset utf-8; + + # max upload size + client_max_body_size 75M; # adjust to taste + + # Tethys Workspaces + location /workspaces { + internal; + alias /home//tethys/workspaces; # your Tethys workspaces files - amend as required + } + + location /static { + alias /home//tethys/static; # your Tethys static files - amend as required + } + + # Finally, send all non-media requests to the Django server. + location / { + uwsgi_pass django; + include /etc/nginx/uwsgi_params; + } + } + +If you need your site to be accessible through both secured (https) and non-secured (http) connections, you will need a server block for each type of connection. Otherwise just edit the existing block. + +Make a copy of the existing non-secured server block and paste it below the original. Then modify it as shown below: + +:: + + server { + + listen 443; + + ssl on; + ssl_certificate /home//tethys/ssl/your_domain_name.pem; (or bundle.crt) + ssl_certificate_key /home//tethys/ssl/your_domain_name.key; + + + # the domain name it will serve for + server_name ; # substitute your machine's IP address or FQDN + charset utf-8; + + # max upload size + client_max_body_size 75M; # adjust to taste + + # Tethys Workspaces + location /workspaces { + internal; + alias /home//tethys/workspaces; # your Tethys workspaces files - amend as required + } + + location /static { + alias /home//tethys/static; # your Tethys static files - amend as required + } + + # Finally, send all non-media requests to the Django server. + location / { + uwsgi_pass django; + include /etc/nginx/uwsgi_params; + } + + +.. Note:: + + SSL works on port 443, hence the server block above listens on 443 instead of 80 + +Geoserver SSL +------------- + +A secured server can only communicate with other secured servers. Therefore to allow the secured Tethys Portal to communicate with Geoserver, the latter needs to be secured as well. To do this, add the following location at the end of your server block. +:: + + server { + + listen 443; + + ssl on; + ssl_certificate /home//tethys/ssl/your_domain_name.pem; (or bundle.crt) + ssl_certificate_key /home//tethys/ssl/your_domain_name.key; + + + # the domain name it will serve for + server_name ; # substitute your machine's IP address or FQDN + charset utf-8; + + # max upload size + client_max_body_size 75M; # adjust to taste + + # Tethys Workspaces + location /workspaces { + internal; + alias /home//tethys/workspaces; # your Tethys workspaces files - amend as required + } + + location /static { + alias /home//tethys/static; # your Tethys static files - amend as required + } + + # Finally, send all non-media requests to the Django server. + location / { + uwsgi_pass django; + include /etc/nginx/uwsgi_params; + } + + #Geoserver + location /geoserver { + proxy_pass http://127.0.0.1:8181/geoserver; + } + +Next, go to your Geoserver web interface (http://domain-name:8181/geoserver/web), sign in, and set the **Proxy Base URL** in Global settings to: +:: + + https:///geoserver + +.. image:: images/geoserver_ssl.png + :width: 600px + :align: center + +Finally, restart uWSGI and Nginx services to effect the changes:: + + $ sudo systemctl restart tethys.uwsgi.service + $ sudo systemctl restart nginx + +.. tip:: + + Use the alias `trestart` as a shortcut to doing the final step. + + +The portal should now be accessible from: https://domain-name + +Geoserver should now be accessible from: https://domain-name/geoserver + +.. Note:: + + Notice that the Geoserver port (8181) is not necessary once the proxy is configured + + +5. Install Apps =============== Download and install any apps that you want to host using this installation of Tethys Platform. For more information see: :doc:`./app_installation`. @@ -118,3 +280,5 @@ Download and install any apps that you want to host using this installation of T .. todo:: **Troubleshooting**: Here we try to provide some guidance on some of the most commonly encountered issues. If you are experiencing problems and can't find a solution here then please post a question on the `Tethys Platform Forum `_. + + From 8763570ece2cea4b759bf7ab2ca240eab34197d3 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Wed, 1 Nov 2017 12:32:39 -0600 Subject: [PATCH 093/215] Ignore swp files from vim --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index a570488c1..43e239725 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ docs/_build tethys_gizmos/static/tethys_gizmos/less/bower_components/* .coverage tests/coverage_html_report +.*.swp +.DS_Store From fedf0119821cac32e7fe801811f540695c34f083 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 09:18:18 -0600 Subject: [PATCH 094/215] Use salt for init (First Pass) --- docker/salt/setup/activate.jinja | 0 docker/salt/setup/init.sls | 116 +++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 docker/salt/setup/activate.jinja create mode 100644 docker/salt/setup/init.sls diff --git a/docker/salt/setup/activate.jinja b/docker/salt/setup/activate.jinja new file mode 100644 index 000000000..e69de29bb diff --git a/docker/salt/setup/init.sls b/docker/salt/setup/init.sls new file mode 100644 index 000000000..a91ea9dd6 --- /dev/null +++ b/docker/salt/setup/init.sls @@ -0,0 +1,116 @@ +{%- set ACTIVATE_DIR = salt['environ.get']('ACTIVATE_DIR') -%} +{%- set DEACTIVATE_DIR = salt['environ.get']('DEACTIVATE_DIR') -%} +{%- set ACTIVATE_SCRIPT = salt['environ.get']('ACTIVATE_SCRIPT') -%} +{%- set DEACTIVATE_SCRIPT = salt['environ.get']('DEACTIVATE_SCRIPT') -%} +{%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} +{%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} +{%- set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') -%} +{%- set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') -%} +{%- set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') -%} +{%- set TETHYSBUILD_PUBLIC_HOST = salt['environ.get']('TETHYSBUILD_PUBLIC_HOST') -%} +{%- set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') -%} +{%- set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') -%} +{%- set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') -%} +{%- set BLERG = salt['environ.get']('BLERG') -%} +{%- set BLERG = salt['environ.get']('BLERG') -%} +{%- set BLERG = salt['environ.get']('BLERG') -%} +{%- set BLERG = salt['environ.get']('BLERG') -%} + +ACTIVATE_DIR: + file.directory: + - name: {{ ACTIVATE_DIR }} + - makedirs: True + +DEACTIVATE_DIR: + file.directory: + - name: {{ DEACTIVATE_DIR }} + - makedirs: True + +~/.bashrc: + file.append: + - text: | + # Tethys Platform + alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} + +ACTIVATE_SCRIPT: + file.managed: + - name: {{ ACTIVATE_SCRIPT }} + - source: "salt://setup/files/activate.jinja" + - template: jinja + +DEACTIVATE_SCRIPT: + file.managed: + - name: {{ DEACTIVATE_SCRIPT }} + - source: "salt://setup/files/deactivate.jinja" + - template: jinja + +Generate Tethys Settings: + cmd.run: + - name | + tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite + +Edit Tethys Settings File (HOST): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "'HOST': '127.0.0.1'" + - repl: {{ TETHYSBUILD_DB_HOST }} + +Edit Tethys Settings File (HOME_PAGE): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "BYPASS_TETHYS_HOME_PAGE = False" + - repl: "BYPASS_TETHYS_HOME_PAGE = True" + +Edit Tethys Settings File (SESSION_WARN): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "SESSION_SECURITY_WARN_AFTER = 840" + - repl: "SESSION_SECURITY_WARN_AFTER = 25 * 60" + +Edit Tethys Settings File (SESSION_EXPIRE): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "SESSION_SECURITY_EXPIRE_AFTER = 900" + - repl: "SESSION_SECURITY_EXPIRE_AFTER = 30 * 60" + +Edit Tethys Settings File (SESSION_EXPIRE): + file.append: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - text: "PUBLIC_HOST = \"{{ TETHYSBUILD_PUBLIC_HOST }}\"" + +Generate NGINX Settings: + cmd.run: + - tethys gen nginx --overwrite + +Generate uwsgi Settings: + cmd.run: + - tethys gen uwsgi_settings --overwrite + +Generate uwsgi service: + cmd.run: + - tethys gen uwsgi_service --overwrite + file.managed: + - name: /var/log/uwsgi/tethys.log + - makedirs: True + +Prepare Database: + postgres_user.present: + - name: {{ TETHYS_DB_USERNAME }} + - password: {{ TETHYS_DB_PASSWORD }} + - login: True + postgres_database.present: + - name: {{ TETHYS_DB_USERNAME }} + cmd.run: + - name: tethys manage syncdb + +Create Super User: + cmd.run: + - name: | + echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')" | python manage.py shell + - cwd: {{ TETHYS_HOME }} + +Link NGINX Config: + file.symlink: + - name: /etc/nginx/sites-enabled + - target: ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + From d73161e23d314b37038cdb28ae2e453e35fc795b Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 09:18:54 -0600 Subject: [PATCH 095/215] Optimize Docker Build Process (First Pass) --- Dockerfile | 142 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 105 insertions(+), 37 deletions(-) diff --git a/Dockerfile b/Dockerfile index 32b502a38..073663979 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,42 +1,110 @@ # Use an official Python runtime as a parent image FROM python:2-slim-stretch -WORKDIR /usr/lib/tethys - -# Add files to docker image -ADD docker/install_tethys.sh /usr/lib/tethys/install_tethys.sh -ADD docker/run_tethys.sh /usr/lib/tethys/run_tethys.sh -ADD . /usr/lib/tethys/src - -# Arguments -# ARG TETHYSBUILD_BRANCH=release -# ARG TETHYSBUILD_PY_VERSION=2 -# ARG TETHYSBUILD_TETHYS_HOME=/usr/lib/tethys -# ARG TETHYSBUILD_CONDA_HOME=/usr/lib/tethys/miniconda -# ARG TETHYSBUILD_CONDA_ENV_NAME=tethys - -# Run Scripts to Get Files -RUN pwd \ - && apt-get update \ - && apt-get --assume-yes install wget bzip2 git nginx \ - && bash install_tethys.sh \ - --python-version 2 \ - --tethys-home /usr/lib/tethys \ - --conda-env-name tethys \ - && mkdir /usr/lib/tethys/workspaces - -VOLUME ["/usr/lib/tethys/workspaces", "/usr/lib/tethys/keys"] - -ADD docker/setup_tethys.sh /usr/lib/tethys/setup_tethys.sh -ADD aquaveo_static/images/aquaveo_favicon.ico /usr/lib/tethys/src/static/tethys_portal/images/default_favicon.png -ADD aquaveo_static/images/aquaveo_logo.png /usr/lib/tethys/src/static/tethys_portal/images/tethys-logo-75.png -ADD aquaveo_static/tethys_main.css /usr/lib/tethys/src/static/tethys_portal/css/tethys_main.css - -# Make port 8000 available to the outside world -EXPOSE 80 +##################### +# Default Variables # +##################### + +# Tethys +ENV TETHYS_HOME "/usr/lib/tethys" +ENV TETHYS_PORT 80 +ENV TETHYS_DB_USERNAME 'tethys_default' +ENV TETHYS_DB_PASSWORD 'pass' +ENV TETHYS_DB_HOST '172.17.0.1' +ENV TETHYS_DB_PORT 5432 +ENV TETHYS_SUPER_USER 'admin' +ENV TETHYS_SUPER_USER_EMAIL '' +ENV TETHYS_SUPER_USER_PASS 'pass' +ENV TETHYS_CONDA_HOME ${CONDA_HOME} +ENV TETHYS_CONDA_ENV_NAME ${CONDA_ENV_NAME} + +# Misc +ENV ALLOWED_HOST 127.0.0.1 +ENV BASH_PROFILE ".bashrc" +ENV CONDA_HOME "${TETHYS_HOME}/miniconda" +ENV CONDA_ENV_NAME tethys +ENV MINICONDA_URL "https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" +ENV PYTHON_VERSION 2 + +# Set paths for environment activate/deactivate scripts +ENV ACTIVATE_DIR "${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" +ENV DEACTIVATE_DIR "${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" +ENV ACTIVATE_SCRIPT "${ACTIVATE_DIR}/tethys-activate.sh" +ENV DEACTIVATE_SCRIPT "${DEACTIVATE_DIR}/tethys-deactivate.sh" + + +######### +# SETUP # +######### +RUN mkdir -p "${TETHYS_HOME}/src" + +# Speed up APT installs +RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup +RUN echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache + +# Install APT packages +RUN apt-get update && apt-get -y install wget \ + && wget -O - https://repo.saltstack.com/apt/debian/9/amd64/latest/SALTSTACK-GPG-KEY.pub | apt-key add - \ + && echo "deb http://repo.saltstack.com/apt/debian/9/amd64/latest stretch main" > /etc/apt/sources.list.d/saltstack.list +RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion +RUN rm -f /etc/nginx/sites-enabled/default -# Configure Tethys -ENV PATH ${TETHYSBUILD_CONDA_HOME:-/usr/lib/tethys/miniconda}/envs/tethys/bin:$PATH +# Install Miniconda +RUN wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" +RUN bash ${TETHYS_HOME}/miniconda.sh -b -p "${CONDA_HOME}" + +# Setup Conda Environment +ADD environment_py2.yml ${TETHYS_HOME}/src/ +WORKDIR ${TETHYS_HOME}/src +RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ENV_NAME}" -f "environment_py${PYTHON_VERSION}.yml" + +########### +# INSTALL # +########### +# ADD files from repo +ADD resources ${TETHYS_HOME}/src/ +ADD templates ${TETHYS_HOME}/src/ +ADD tethys_apps ${TETHYS_HOME}/src/ +ADD tethys_compute ${TETHYS_HOME}/src/ +ADD tethys_config ${TETHYS_HOME}/src/ +ADD tethys_gizmos ${TETHYS_HOME}/src/ +ADD tethys_portal ${TETHYS_HOME}/src/ +ADD tethys_sdk ${TETHYS_HOME}/src/ +ADD tethys_services ${TETHYS_HOME}/src/ +ADD *.py ${TETHYS_HOME}/src/ + +# Run Installer +RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ + ; python setup.py develop \ + ; conda install -c conda-forge uwsgi -y \ + ; mkdir ${TETHYS_HOME}/workspaces + +# Add static files +ADD static ${TETHYS_HOME}/src/ +ADD aquaveo_static/images/aquaveo_favicon.ico ${TETHYS_HOME}/src/static/tethys_portal/images/default_favicon.png +ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_portal/images/tethys-logo-75.png +ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css + +# Give NGINX Permission +RUN NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') \ + find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} + +############ +# CLEAN UP # +############ +RUN apt-get -y remove wget gcc \ + ; apt-get -y autoremove \ + ; apt-get -y clean + +######################### +# CONFIGURE ENVIRONMENT # +######################### +ENV PATH ${CONDA_HOME}/miniconda}/envs/tethys/bin:$PATH +VOLUME ["${TETHYS_HOME}/workspaces", "${TETHYS_HOME}/keys"] +EXPOSE 80 -# Install Tethys -CMD bash /usr/lib/tethys/run_tethys.sh +######## +# RUN! # +######## +CMD echo "Sleeping Forever" +CMD while [ 1 ]; do sleep 1; done From ae7a9a0777ff5330e13467c0937d506a7be7b726 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 11:05:24 -0600 Subject: [PATCH 096/215] Consolidate ENVs --- Dockerfile | 61 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/Dockerfile b/Dockerfile index 073663979..ab8fee60e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,31 +6,29 @@ FROM python:2-slim-stretch ##################### # Tethys -ENV TETHYS_HOME "/usr/lib/tethys" -ENV TETHYS_PORT 80 -ENV TETHYS_DB_USERNAME 'tethys_default' -ENV TETHYS_DB_PASSWORD 'pass' -ENV TETHYS_DB_HOST '172.17.0.1' -ENV TETHYS_DB_PORT 5432 -ENV TETHYS_SUPER_USER 'admin' -ENV TETHYS_SUPER_USER_EMAIL '' -ENV TETHYS_SUPER_USER_PASS 'pass' -ENV TETHYS_CONDA_HOME ${CONDA_HOME} -ENV TETHYS_CONDA_ENV_NAME ${CONDA_ENV_NAME} +ENV TETHYS_HOME="/usr/lib/tethys" \ + TETHYS_PORT=80 \ + TETHYS_DB_USERNAME='tethys_default' \ + TETHYS_DB_PASSWORD='pass' \ + TETHYS_DB_HOST='172.17.0.1' \ + TETHYS_DB_PORT=5432 \ + TETHYS_SUPER_USER='admin' \ + TETHYS_SUPER_USER_EMAIL='' \ + TETHYS_SUPER_USER_PASS='pass' \ + TETHYS_CONDA_HOME=${CONDA_HOME} \ + TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} # Misc -ENV ALLOWED_HOST 127.0.0.1 -ENV BASH_PROFILE ".bashrc" -ENV CONDA_HOME "${TETHYS_HOME}/miniconda" -ENV CONDA_ENV_NAME tethys -ENV MINICONDA_URL "https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" -ENV PYTHON_VERSION 2 - -# Set paths for environment activate/deactivate scripts -ENV ACTIVATE_DIR "${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" -ENV DEACTIVATE_DIR "${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" -ENV ACTIVATE_SCRIPT "${ACTIVATE_DIR}/tethys-activate.sh" -ENV DEACTIVATE_SCRIPT "${DEACTIVATE_DIR}/tethys-deactivate.sh" +ENV ALLOWED_HOST=127.0.0.1 \ + BASH_PROFILE=".bashrc" \ + CONDA_HOME="${TETHYS_HOME}/miniconda" \ + CONDA_ENV_NAME=tethys \ + MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" \ + PYTHON_VERSION=2 \ + ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" \ + DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" \ + ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" \ + DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" ######### @@ -56,7 +54,7 @@ RUN bash ${TETHYS_HOME}/miniconda.sh -b -p "${CONDA_HOME}" # Setup Conda Environment ADD environment_py2.yml ${TETHYS_HOME}/src/ WORKDIR ${TETHYS_HOME}/src -RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ENV_NAME}" -f "environment_py${PYTHON_VERSION}.yml" +RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ _NAME}" -f="environment_py${PYTHON_VERSION}.yml" \ ########### # INSTALL # @@ -74,7 +72,7 @@ ADD tethys_services ${TETHYS_HOME}/src/ ADD *.py ${TETHYS_HOME}/src/ # Run Installer -RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ +RUN . ${CONDA_HOME}/bin/activate ${CONDA_ _NAME}=\ \ ; python setup.py develop \ ; conda install -c conda-forge uwsgi -y \ ; mkdir ${TETHYS_HOME}/workspaces @@ -85,6 +83,15 @@ ADD aquaveo_static/images/aquaveo_favicon.ico ${TETHYS_HOME}/src/static/tethys_p ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_portal/images/tethys-logo-75.png ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css +# Generate Inital Settings Files +RUN . ${CONDA_HOME}/bin/activate ${CONDA_ _NAME}=\ \ + ; tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite \ + ; sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" ${TETHYS_HOME}/src/tethys_portal/settings.py \ + ; tethys gen nginx --overwrite \ + ; tethys gen uwsgi_settings --overwrite \ + ; tethys gen uwsgi_service --overwrite + + # Give NGINX Permission RUN NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') \ find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} @@ -97,9 +104,9 @@ RUN apt-get -y remove wget gcc \ ; apt-get -y clean ######################### -# CONFIGURE ENVIRONMENT # +# CONFIGURE ENVIRONMENT# ######################### -ENV PATH ${CONDA_HOME}/miniconda}/envs/tethys/bin:$PATH +ENV PATH ${CONDA_HOME}/miniconda}/envs/tethys/bin:$PATH VOLUME ["${TETHYS_HOME}/workspaces", "${TETHYS_HOME}/keys"] EXPOSE 80 From 209494fd0bd74cb472ff2d13fe425992638434c2 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 11:09:44 -0600 Subject: [PATCH 097/215] Fix Typos --- Dockerfile | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index ab8fee60e..82740677a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,10 +16,10 @@ ENV TETHYS_HOME="/usr/lib/tethys" \ TETHYS_SUPER_USER_EMAIL='' \ TETHYS_SUPER_USER_PASS='pass' \ TETHYS_CONDA_HOME=${CONDA_HOME} \ - TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} + TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} \ # Misc -ENV ALLOWED_HOST=127.0.0.1 \ + ALLOWED_HOST=127.0.0.1 \ BASH_PROFILE=".bashrc" \ CONDA_HOME="${TETHYS_HOME}/miniconda" \ CONDA_ENV_NAME=tethys \ @@ -54,7 +54,7 @@ RUN bash ${TETHYS_HOME}/miniconda.sh -b -p "${CONDA_HOME}" # Setup Conda Environment ADD environment_py2.yml ${TETHYS_HOME}/src/ WORKDIR ${TETHYS_HOME}/src -RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ _NAME}" -f="environment_py${PYTHON_VERSION}.yml" \ +RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ENV_NAME}" -f "environment_py${PYTHON_VERSION}.yml" ########### # INSTALL # @@ -72,7 +72,7 @@ ADD tethys_services ${TETHYS_HOME}/src/ ADD *.py ${TETHYS_HOME}/src/ # Run Installer -RUN . ${CONDA_HOME}/bin/activate ${CONDA_ _NAME}=\ \ +RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; python setup.py develop \ ; conda install -c conda-forge uwsgi -y \ ; mkdir ${TETHYS_HOME}/workspaces @@ -84,8 +84,8 @@ ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_port ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css # Generate Inital Settings Files -RUN . ${CONDA_HOME}/bin/activate ${CONDA_ _NAME}=\ \ - ; tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite \ +RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ + ; tethys gen settings --production --allowed-host ${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite \ ; sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" ${TETHYS_HOME}/src/tethys_portal/settings.py \ ; tethys gen nginx --overwrite \ ; tethys gen uwsgi_settings --overwrite \ @@ -106,7 +106,7 @@ RUN apt-get -y remove wget gcc \ ######################### # CONFIGURE ENVIRONMENT# ######################### -ENV PATH ${CONDA_HOME}/miniconda}/envs/tethys/bin:$PATH +ENV PATH ${CONDA_HOME}/miniconda/envs/tethys/bin:$PATH VOLUME ["${TETHYS_HOME}/workspaces", "${TETHYS_HOME}/keys"] EXPOSE 80 From 56e0f9dfe49c8156cd3894bc31903c81f9cf8a64 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 11:11:00 -0600 Subject: [PATCH 098/215] Merge some RUNs --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 82740677a..ae1b1a15f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,8 +37,8 @@ ENV TETHYS_HOME="/usr/lib/tethys" \ RUN mkdir -p "${TETHYS_HOME}/src" # Speed up APT installs -RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup -RUN echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache +RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ + ; echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache # Install APT packages RUN apt-get update && apt-get -y install wget \ @@ -48,8 +48,8 @@ RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion RUN rm -f /etc/nginx/sites-enabled/default # Install Miniconda -RUN wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" -RUN bash ${TETHYS_HOME}/miniconda.sh -b -p "${CONDA_HOME}" +RUN wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" \ + && bash ${TETHYS_HOME}/miniconda.sh -b -p "${CONDA_HOME}" # Setup Conda Environment ADD environment_py2.yml ${TETHYS_HOME}/src/ From a32ea244df649a345ee479edbf9acc97c4a6aef9 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 11:52:25 -0600 Subject: [PATCH 099/215] Use bash when activating miniconda --- Dockerfile | 55 ++++++++++++++++++++++++++---------------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/Dockerfile b/Dockerfile index ae1b1a15f..a1b0c1eb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,20 @@ # Use an official Python runtime as a parent image FROM python:2-slim-stretch -##################### -# Default Variables # -##################### - -# Tethys +############### +# ENVIRONMENT # +############### ENV TETHYS_HOME="/usr/lib/tethys" \ TETHYS_PORT=80 \ - TETHYS_DB_USERNAME='tethys_default' \ - TETHYS_DB_PASSWORD='pass' \ - TETHYS_DB_HOST='172.17.0.1' \ + TETHYS_DB_USERNAME="tethys_default" \ + TETHYS_DB_PASSWORD="pass" \ + TETHYS_DB_HOST="172.17.0.1" \ TETHYS_DB_PORT=5432 \ - TETHYS_SUPER_USER='admin' \ - TETHYS_SUPER_USER_EMAIL='' \ - TETHYS_SUPER_USER_PASS='pass' \ + TETHYS_SUPER_USER="admin" \ + TETHYS_SUPER_USER_EMAIL="" \ + TETHYS_SUPER_USER_PASS="pass" \ TETHYS_CONDA_HOME=${CONDA_HOME} \ TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} \ - -# Misc ALLOWED_HOST=127.0.0.1 \ BASH_PROFILE=".bashrc" \ CONDA_HOME="${TETHYS_HOME}/miniconda" \ @@ -60,22 +56,23 @@ RUN ${CONDA_HOME}/bin/conda env create -n "${CONDA_ENV_NAME}" -f "environment_py # INSTALL # ########### # ADD files from repo -ADD resources ${TETHYS_HOME}/src/ -ADD templates ${TETHYS_HOME}/src/ -ADD tethys_apps ${TETHYS_HOME}/src/ -ADD tethys_compute ${TETHYS_HOME}/src/ -ADD tethys_config ${TETHYS_HOME}/src/ -ADD tethys_gizmos ${TETHYS_HOME}/src/ -ADD tethys_portal ${TETHYS_HOME}/src/ -ADD tethys_sdk ${TETHYS_HOME}/src/ -ADD tethys_services ${TETHYS_HOME}/src/ +ADD resources ${TETHYS_HOME}/src/resources/ +ADD templates ${TETHYS_HOME}/src/templates/ +ADD tethys_apps ${TETHYS_HOME}/src/tethys_apps/ +ADD tethys_compute ${TETHYS_HOME}/src/tethys_compute/ +ADD tethys_config ${TETHYS_HOME}/src/tethys_config/ +ADD tethys_gizmos ${TETHYS_HOME}/src/tethys_gizmos/ +ADD tethys_portal ${TETHYS_HOME}/src/tethys_portal/ +ADD tethys_sdk ${TETHYS_HOME}/src/tethys_sdk/ +ADD tethys_services ${TETHYS_HOME}/src/tethys_services/ +ADD README.rst ${TETHYS_HOME}/src/ ADD *.py ${TETHYS_HOME}/src/ # Run Installer -RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ +RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; python setup.py develop \ ; conda install -c conda-forge uwsgi -y \ - ; mkdir ${TETHYS_HOME}/workspaces + ; mkdir ${TETHYS_HOME}/workspaces' # Add static files ADD static ${TETHYS_HOME}/src/ @@ -84,17 +81,17 @@ ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_port ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css # Generate Inital Settings Files -RUN . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ +RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; tethys gen settings --production --allowed-host ${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite \ - ; sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" ${TETHYS_HOME}/src/tethys_portal/settings.py \ + ; sed -i -e "s:#TETHYS_WORKSPACES_ROOT = .*$:TETHYS_WORKSPACES_ROOT = \"/usr/lib/tethys/workspaces\":" ${TETHYS_HOME}/src/tethys_portal/settings.py \ ; tethys gen nginx --overwrite \ ; tethys gen uwsgi_settings --overwrite \ - ; tethys gen uwsgi_service --overwrite + ; tethys gen uwsgi_service --overwrite' # Give NGINX Permission -RUN NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') \ - find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} +RUN export NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') \ + ; find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} ############ # CLEAN UP # From aea22a7da8c4cce7c1a3433a009e9148c502a27d Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 12:52:09 -0600 Subject: [PATCH 100/215] Break down some env vars --- docker/salt/setup/{ => files}/activate.jinja | 0 docker/salt/setup/init.sls | 30 ++++++++------------ 2 files changed, 12 insertions(+), 18 deletions(-) rename docker/salt/setup/{ => files}/activate.jinja (100%) diff --git a/docker/salt/setup/activate.jinja b/docker/salt/setup/files/activate.jinja similarity index 100% rename from docker/salt/setup/activate.jinja rename to docker/salt/setup/files/activate.jinja diff --git a/docker/salt/setup/init.sls b/docker/salt/setup/init.sls index a91ea9dd6..78aeb89c0 100644 --- a/docker/salt/setup/init.sls +++ b/docker/salt/setup/init.sls @@ -1,7 +1,5 @@ -{%- set ACTIVATE_DIR = salt['environ.get']('ACTIVATE_DIR') -%} -{%- set DEACTIVATE_DIR = salt['environ.get']('DEACTIVATE_DIR') -%} -{%- set ACTIVATE_SCRIPT = salt['environ.get']('ACTIVATE_SCRIPT') -%} -{%- set DEACTIVATE_SCRIPT = salt['environ.get']('DEACTIVATE_SCRIPT') -%} +{%- set ACTIVATE_DIR = salt['environ.get']('CONDA_HOME')/envs/salt['environ.get']('CONDA_ENV_NAME')/etc/conda/activate.d -%} +{%- set DEACTIVATE_DIR = salt['environ.get']('CONDA_HOME')/envs/salt['environ.get']('CONDA_ENV_NAME')/etc/conda/deactivate.d -%} {%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} {%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} {%- set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') -%} @@ -16,15 +14,23 @@ {%- set BLERG = salt['environ.get']('BLERG') -%} {%- set BLERG = salt['environ.get']('BLERG') -%} -ACTIVATE_DIR: +ACTIVATE: file.directory: - name: {{ ACTIVATE_DIR }} - makedirs: True + file.managed: + - name: {{ ACTIVATE_DIR/tethys-activate.sh }} + - source: "salt://setup/files/activate.jinja" + - template: jinja -DEACTIVATE_DIR: +DEACTIVATE: file.directory: - name: {{ DEACTIVATE_DIR }} - makedirs: True + file.managed: + - name: {{ DEACTIVATE_DIR/tethys-deactivate.sh }} + - source: "salt://setup/files/deactivate.jinja" + - template: jinja ~/.bashrc: file.append: @@ -32,18 +38,6 @@ DEACTIVATE_DIR: # Tethys Platform alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} -ACTIVATE_SCRIPT: - file.managed: - - name: {{ ACTIVATE_SCRIPT }} - - source: "salt://setup/files/activate.jinja" - - template: jinja - -DEACTIVATE_SCRIPT: - file.managed: - - name: {{ DEACTIVATE_SCRIPT }} - - source: "salt://setup/files/deactivate.jinja" - - template: jinja - Generate Tethys Settings: cmd.run: - name | From 1632ae95e5b7a18fcf5cf2ab2046f1d95dd55448 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 12:52:31 -0600 Subject: [PATCH 101/215] Split out ENV groups that depend on eachother --- Dockerfile | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index a1b0c1eb7..f6d53f3ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,25 +12,20 @@ ENV TETHYS_HOME="/usr/lib/tethys" \ TETHYS_DB_PORT=5432 \ TETHYS_SUPER_USER="admin" \ TETHYS_SUPER_USER_EMAIL="" \ - TETHYS_SUPER_USER_PASS="pass" \ - TETHYS_CONDA_HOME=${CONDA_HOME} \ - TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} \ - ALLOWED_HOST=127.0.0.1 \ + TETHYS_SUPER_USER_PASS="pass" +# Misc +ENV ALLOWED_HOST=127.0.0.1 \ BASH_PROFILE=".bashrc" \ CONDA_HOME="${TETHYS_HOME}/miniconda" \ CONDA_ENV_NAME=tethys \ MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" \ - PYTHON_VERSION=2 \ - ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" \ - DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" \ - ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" \ - DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" - + PYTHON_VERSION=2 ######### # SETUP # ######### RUN mkdir -p "${TETHYS_HOME}/src" +WORKDIR ${TETHYS_HOME} # Speed up APT installs RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ @@ -110,5 +105,6 @@ EXPOSE 80 ######## # RUN! # ######## +WORKDIR ${TETHYS_HOME} CMD echo "Sleeping Forever" CMD while [ 1 ]; do sleep 1; done From 0acdb90f192d90dca36a3040746f87a0878595d5 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 14:58:46 -0600 Subject: [PATCH 102/215] Add salt (Second Pass) --- Dockerfile | 11 ++++++-- docker/salt/config | 1 + docker/salt/setup/files/activate.jinja | 0 docker/salt/{setup => states}/init.sls | 37 +++++++++----------------- docker/salt/states/top.sls | 3 +++ 5 files changed, 25 insertions(+), 27 deletions(-) create mode 100644 docker/salt/config delete mode 100644 docker/salt/setup/files/activate.jinja rename docker/salt/{setup => states}/init.sls (75%) create mode 100644 docker/salt/states/top.sls diff --git a/Dockerfile b/Dockerfile index f6d53f3ad..f0e5b813b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -102,9 +102,16 @@ ENV PATH ${CONDA_HOME}/miniconda/envs/tethys/bin:$PATH VOLUME ["${TETHYS_HOME}/workspaces", "${TETHYS_HOME}/keys"] EXPOSE 80 + +############### +# SET UP SALT # +############### +ADD docker/salt/config /etc/salt/minion +ADD docker/salt/states /srv/salt/ + ######## # RUN! # ######## WORKDIR ${TETHYS_HOME} -CMD echo "Sleeping Forever" -CMD while [ 1 ]; do sleep 1; done +CMD salt-call --local state.apply +CMD tail -qF /var/log/nginx/* /var/log/uwsgi/* diff --git a/docker/salt/config b/docker/salt/config new file mode 100644 index 000000000..4e8636633 --- /dev/null +++ b/docker/salt/config @@ -0,0 +1 @@ +file_client: local diff --git a/docker/salt/setup/files/activate.jinja b/docker/salt/setup/files/activate.jinja deleted file mode 100644 index e69de29bb..000000000 diff --git a/docker/salt/setup/init.sls b/docker/salt/states/init.sls similarity index 75% rename from docker/salt/setup/init.sls rename to docker/salt/states/init.sls index 78aeb89c0..7bfc03727 100644 --- a/docker/salt/setup/init.sls +++ b/docker/salt/states/init.sls @@ -1,5 +1,3 @@ -{%- set ACTIVATE_DIR = salt['environ.get']('CONDA_HOME')/envs/salt['environ.get']('CONDA_ENV_NAME')/etc/conda/activate.d -%} -{%- set DEACTIVATE_DIR = salt['environ.get']('CONDA_HOME')/envs/salt['environ.get']('CONDA_ENV_NAME')/etc/conda/deactivate.d -%} {%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} {%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} {%- set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') -%} @@ -9,28 +7,6 @@ {%- set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') -%} {%- set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') -%} {%- set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') -%} -{%- set BLERG = salt['environ.get']('BLERG') -%} -{%- set BLERG = salt['environ.get']('BLERG') -%} -{%- set BLERG = salt['environ.get']('BLERG') -%} -{%- set BLERG = salt['environ.get']('BLERG') -%} - -ACTIVATE: - file.directory: - - name: {{ ACTIVATE_DIR }} - - makedirs: True - file.managed: - - name: {{ ACTIVATE_DIR/tethys-activate.sh }} - - source: "salt://setup/files/activate.jinja" - - template: jinja - -DEACTIVATE: - file.directory: - - name: {{ DEACTIVATE_DIR }} - - makedirs: True - file.managed: - - name: {{ DEACTIVATE_DIR/tethys-deactivate.sh }} - - source: "salt://setup/files/deactivate.jinja" - - template: jinja ~/.bashrc: file.append: @@ -100,7 +76,7 @@ Prepare Database: Create Super User: cmd.run: - name: | - echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')" | python manage.py shell + echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}') == 0)" | python manage.py shell - cwd: {{ TETHYS_HOME }} Link NGINX Config: @@ -108,3 +84,14 @@ Link NGINX Config: - name: /etc/nginx/sites-enabled - target: ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + +uwsgi: + cmd.run: + - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml /usr/lib/tethys/src/tethys_portal/tethys_uwsgi.yml --uid www-data --gid www-data + - bg: True + - ignore_timeout: True + +nginx: + service.running: + - name: nginx + diff --git a/docker/salt/states/top.sls b/docker/salt/states/top.sls new file mode 100644 index 000000000..a966bc0fb --- /dev/null +++ b/docker/salt/states/top.sls @@ -0,0 +1,3 @@ +base: + '*': + - init From 046c8d86f195df1c760b7232f35b030bea642f53 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 15:39:48 -0600 Subject: [PATCH 103/215] Fix salt parsing errors --- Dockerfile | 1 + docker/salt/states/init.sls | 70 +++++++++++++++++++------------------ 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/Dockerfile b/Dockerfile index f0e5b813b..2ee4f20c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ FROM python:2-slim-stretch ############### ENV TETHYS_HOME="/usr/lib/tethys" \ TETHYS_PORT=80 \ + TETHYS_PUBLIC_HOST="172.17.0.1" \ TETHYS_DB_USERNAME="tethys_default" \ TETHYS_DB_PASSWORD="pass" \ TETHYS_DB_HOST="172.17.0.1" \ diff --git a/docker/salt/states/init.sls b/docker/salt/states/init.sls index 7bfc03727..d6680c7c2 100644 --- a/docker/salt/states/init.sls +++ b/docker/salt/states/init.sls @@ -1,69 +1,70 @@ -{%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} -{%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} +{%- set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') -%} +{%- set CONDA_ENV_NAME = salt['environ.get']('CONDA_ENV_NAME') -%} +{%- set CONDA_HOME = salt['environ.get']('CONDA_HOME') -%} +{%- set TETHYS_BIN_DIR = [CONDA_HOME, "/envs/", CONDA_ENV_NAME, "/bin"]|join -%} +{%- set TETHYS_DB_HOST = salt['environ.get']('TETHYS_DB_HOST') -%} {%- set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') -%} {%- set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') -%} -{%- set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') -%} -{%- set TETHYSBUILD_PUBLIC_HOST = salt['environ.get']('TETHYSBUILD_PUBLIC_HOST') -%} +{%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} +{%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} +{%- set TETHYS_PUBLIC_HOST = salt['environ.get']('TETHYS_PUBLIC_HOST') -%} {%- set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') -%} -{%- set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') -%} {%- set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') -%} +{%- set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') -%} ~/.bashrc: file.append: - - text: | - # Tethys Platform - alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} + - text: alias t=. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} -Generate Tethys Settings: +Generate_Tethys_Settings: cmd.run: - - name | - tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite + - name: {{ TETHYS_BIN_DIR }}/tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite -Edit Tethys Settings File (HOST): +Edit_Tethys_Settings_File_(HOST): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "'HOST': '127.0.0.1'" - - repl: {{ TETHYSBUILD_DB_HOST }} + - repl: "'HOST': '{{ TETHYS_DB_HOST }}'" -Edit Tethys Settings File (HOME_PAGE): +Edit_Tethys_Settings_File_(HOME_PAGE): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "BYPASS_TETHYS_HOME_PAGE = False" - repl: "BYPASS_TETHYS_HOME_PAGE = True" -Edit Tethys Settings File (SESSION_WARN): +Edit_Tethys_Settings_File_(SESSION_WARN): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "SESSION_SECURITY_WARN_AFTER = 840" - repl: "SESSION_SECURITY_WARN_AFTER = 25 * 60" -Edit Tethys Settings File (SESSION_EXPIRE): +Edit_Tethys_Settings_File_(SESSION_EXPIRE): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "SESSION_SECURITY_EXPIRE_AFTER = 900" - repl: "SESSION_SECURITY_EXPIRE_AFTER = 30 * 60" -Edit Tethys Settings File (SESSION_EXPIRE): +Edit_Tethys_Settings_File_(PUBLIC_HOST): file.append: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - text: "PUBLIC_HOST = \"{{ TETHYSBUILD_PUBLIC_HOST }}\"" + - text: "PUBLIC_HOST = \"{{ TETHYS_PUBLIC_HOST }}\"" -Generate NGINX Settings: +Generate_NGINX_Settings: cmd.run: - - tethys gen nginx --overwrite + - name: {{ TETHYS_BIN_DIR }}/tethys gen nginx --overwrite -Generate uwsgi Settings: +Generate_uwsgi_Settings: cmd.run: - - tethys gen uwsgi_settings --overwrite + - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_settings --overwrite -Generate uwsgi service: +Generate_uwsgi_service: cmd.run: - - tethys gen uwsgi_service --overwrite + - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite file.managed: - name: /var/log/uwsgi/tethys.log - makedirs: True -Prepare Database: +Prepare_Database: postgres_user.present: - name: {{ TETHYS_DB_USERNAME }} - password: {{ TETHYS_DB_PASSWORD }} @@ -71,17 +72,16 @@ Prepare Database: postgres_database.present: - name: {{ TETHYS_DB_USERNAME }} cmd.run: - - name: tethys manage syncdb + - name: {{ TETHYS_BIN_DIR }}/tethys manage syncdb -Create Super User: +Create_Super_User: cmd.run: - - name: | - echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}') == 0)" | python manage.py shell - - cwd: {{ TETHYS_HOME }} + - name: echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}') == 0)" | {{TETHYS_BIN_DIR }}/python manage.py shell + - cwd: {{ TETHYS_HOME }}/src -Link NGINX Config: +Link_NGINX_Config: file.symlink: - - name: /etc/nginx/sites-enabled + - name: /etc/nginx/sites-enabled/tethys_nginx.conf - target: ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf @@ -92,6 +92,8 @@ uwsgi: - ignore_timeout: True nginx: - service.running: - - name: nginx + cmd.run: + - name: nginx -g 'daemon off' + - bg: True + - ignore_timeout: True From d81410ac5e99debdda93befc29726e77cdae46d5 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 2 Nov 2017 20:05:10 -0600 Subject: [PATCH 104/215] Add postgres config for salt --- Dockerfile | 6 ++++++ docker/salt/config | 2 ++ docker/salt/states/init.sls | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2ee4f20c8..50432a570 100644 --- a/Dockerfile +++ b/Dockerfile @@ -114,5 +114,11 @@ ADD docker/salt/states /srv/salt/ # RUN! # ######## WORKDIR ${TETHYS_HOME} +# Tell Salt how to connect to the DB +CMD echo "postgres.host: '${TETHYS_DB_HOST}'" >> /etc/salt/minion \ + ; echo "postgres.port: '${TETHYS_DB_PORT}'" >> /etc/salt/minion \ + ; echo "postgres.user: '${TETHYS_DB_USERNAME}'" >> /etc/salt/minion \ + ; echo "postgres.pass: '${TETHYS_DB_PASSWORD}'" >> /etc/salt/minion \ + ; echo "postgres.bins_dir: '${CONDA_HOME}/envs/${CONDA_ENV_NAME}/bin'" >> /etc/salt/minion CMD salt-call --local state.apply CMD tail -qF /var/log/nginx/* /var/log/uwsgi/* diff --git a/docker/salt/config b/docker/salt/config index 4e8636633..0927a07c7 100644 --- a/docker/salt/config +++ b/docker/salt/config @@ -1 +1,3 @@ + file_client: local +postgres.bins_dir: /usr/lib/tethys/miniconda/envs/tethys/bin diff --git a/docker/salt/states/init.sls b/docker/salt/states/init.sls index d6680c7c2..32346d788 100644 --- a/docker/salt/states/init.sls +++ b/docker/salt/states/init.sls @@ -76,7 +76,7 @@ Prepare_Database: Create_Super_User: cmd.run: - - name: echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}') == 0)" | {{TETHYS_BIN_DIR }}/python manage.py shell + - name: echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0)" | {{TETHYS_BIN_DIR }}/python manage.py shell - cwd: {{ TETHYS_HOME }}/src Link_NGINX_Config: From 907756651fc44af9bcd82e6aa783aa9d159e3a49 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 09:02:51 -0600 Subject: [PATCH 105/215] Move salt conf generation into docker runtime --- Dockerfile | 5 +++-- docker/salt/config | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 50432a570..8e538ba2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -114,8 +114,9 @@ ADD docker/salt/states /srv/salt/ # RUN! # ######## WORKDIR ${TETHYS_HOME} -# Tell Salt how to connect to the DB -CMD echo "postgres.host: '${TETHYS_DB_HOST}'" >> /etc/salt/minion \ +# Create Salt configuration based on ENVs +CMD echo "file_client: local" > /etc/salt/minion \ + ; echo "postgres.host: '${TETHYS_DB_HOST}'" >> /etc/salt/minion \ ; echo "postgres.port: '${TETHYS_DB_PORT}'" >> /etc/salt/minion \ ; echo "postgres.user: '${TETHYS_DB_USERNAME}'" >> /etc/salt/minion \ ; echo "postgres.pass: '${TETHYS_DB_PASSWORD}'" >> /etc/salt/minion \ diff --git a/docker/salt/config b/docker/salt/config index 0927a07c7..4e8636633 100644 --- a/docker/salt/config +++ b/docker/salt/config @@ -1,3 +1 @@ - file_client: local -postgres.bins_dir: /usr/lib/tethys/miniconda/envs/tethys/bin From c77a39b7d98ac09ea77badeb285756ac5cd3dcf1 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 09:04:12 -0600 Subject: [PATCH 106/215] Remove (now) unused config file --- docker/salt/config | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docker/salt/config diff --git a/docker/salt/config b/docker/salt/config deleted file mode 100644 index 4e8636633..000000000 --- a/docker/salt/config +++ /dev/null @@ -1 +0,0 @@ -file_client: local From 3927071e44c6612fdb51be466b7108a5818146ea Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 15:22:04 -0600 Subject: [PATCH 107/215] Move startup to external script and add HEALTHCHECK --- Dockerfile | 25 ++- docker/install_tethys.sh | 130 -------------- docker/run.sh | 34 ++++ docker/run_tethys.sh | 43 ----- docker/salt/{states => }/init.sls | 51 +++--- docker/salt/{states => }/top.sls | 0 docker/setup_tethys.sh | 275 ------------------------------ 7 files changed, 75 insertions(+), 483 deletions(-) delete mode 100644 docker/install_tethys.sh create mode 100644 docker/run.sh delete mode 100644 docker/run_tethys.sh rename docker/salt/{states => }/init.sls (54%) rename docker/salt/{states => }/top.sls (100%) delete mode 100644 docker/setup_tethys.sh diff --git a/Dockerfile b/Dockerfile index 8e538ba2f..7e562a7bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ RUN apt-get update && apt-get -y install wget \ && wget -O - https://repo.saltstack.com/apt/debian/9/amd64/latest/SALTSTACK-GPG-KEY.pub | apt-key add - \ && echo "deb http://repo.saltstack.com/apt/debian/9/amd64/latest stretch main" > /etc/apt/sources.list.d/saltstack.list -RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion +RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion procps RUN rm -f /etc/nginx/sites-enabled/default # Install Miniconda @@ -71,7 +71,7 @@ RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; mkdir ${TETHYS_HOME}/workspaces' # Add static files -ADD static ${TETHYS_HOME}/src/ +ADD static ${TETHYS_HOME}/src/static/ ADD aquaveo_static/images/aquaveo_favicon.ico ${TETHYS_HOME}/src/static/tethys_portal/images/default_favicon.png ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_portal/images/tethys-logo-75.png ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css @@ -104,22 +104,17 @@ VOLUME ["${TETHYS_HOME}/workspaces", "${TETHYS_HOME}/keys"] EXPOSE 80 -############### -# SET UP SALT # -############### -ADD docker/salt/config /etc/salt/minion -ADD docker/salt/states /srv/salt/ +###############* +# COPY IN SALT # +###############* +ADD docker/salt/ /srv/salt/ +ADD docker/run.sh ${TETHYS_HOME}/ ######## # RUN! # ######## WORKDIR ${TETHYS_HOME} # Create Salt configuration based on ENVs -CMD echo "file_client: local" > /etc/salt/minion \ - ; echo "postgres.host: '${TETHYS_DB_HOST}'" >> /etc/salt/minion \ - ; echo "postgres.port: '${TETHYS_DB_PORT}'" >> /etc/salt/minion \ - ; echo "postgres.user: '${TETHYS_DB_USERNAME}'" >> /etc/salt/minion \ - ; echo "postgres.pass: '${TETHYS_DB_PASSWORD}'" >> /etc/salt/minion \ - ; echo "postgres.bins_dir: '${CONDA_HOME}/envs/${CONDA_ENV_NAME}/bin'" >> /etc/salt/minion -CMD salt-call --local state.apply -CMD tail -qF /var/log/nginx/* /var/log/uwsgi/* +CMD bash run.sh +HEALTHCHECK --start-period=240s \ + CMD ps $(cat $(grep 'pid .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}')) > /dev/null && ps $(cat $(grep 'pidfile2: .*' src/tethys_portal/tethys_uwsgi.yml | awk '{print $1}')) > /dev/null; diff --git a/docker/install_tethys.sh b/docker/install_tethys.sh deleted file mode 100644 index 899d5becc..000000000 --- a/docker/install_tethys.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/bin/bash - -USAGE="USAGE: . install_tethys.sh [options]\n -\n -OPTIONS:\n - -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n - --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n - -x Flag to turn on shell command echoing.\n - -h, --help Print this help information.\n -" - -print_usage () -{ - echo -e ${USAGE} - exit -} -set -e # exit on error -LINUX_DISTRIBUTION=$(lsb_release -is) || LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") -# convert to lower case -LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} -MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" -BASH_PROFILE=".bashrc" -resolve_relative_path () -{ - local __path_var="$1" - eval $__path_var="'$(readlink -f $2)'" -} - -# Set default options -TETHYS_HOME=~/tethys -TETHYS_PORT=80 -CONDA_ENV_NAME='tethys' -PYTHON_VERSION='2' - - -# parse command line options -set_option_value () -{ - local __option_key="$1" - value="$2" - if [[ $value == -* ]] - then - print_usage - fi - eval $__option_key="$value" -} -while [[ $# -gt 0 ]] -do -key="$1" - -case $key in - -t|--tethys-home) - set_option_value TETHYS_HOME "$2" - shift # past argument - ;; - -n|--conda-env-name) - set_option_value CONDA_ENV_NAME "$2" - shift # past argument - ;; - --python-version) - set_option_value PYTHON_VERSION "$2" - shift # past argument - ;; - -x) - ECHO_COMMANDS="true" - ;; - -h|--help) - print_usage - ;; - *) # unknown option - echo Ignoring unrecognized option: $key - ;; -esac -shift # past argument or value -done - -# resolve relative paths -CONDA_HOME="${TETHYS_HOME}/miniconda" -resolve_relative_path TETHYS_HOME ${TETHYS_HOME} -resolve_relative_path CONDA_HOME ${CONDA_HOME} - - - -if [ -n "${ECHO_COMMANDS}" ] -then - set -x # echo commands as they are executed -fi - - -# Set paths for environment activate/deactivate scripts -ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" -DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" -ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" -DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" - - -echo "Starting Tethys Installation..." - -mkdir -p "${TETHYS_HOME}" - -# Install Miniconda -echo "Installing Miniconda..." -wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" || (echo -using curl instead; curl ${MINICONDA_URL} -o "${TETHYS_HOME}/miniconda.sh") -pushd ./ -cd "${TETHYS_HOME}" -bash miniconda.sh -b -p "${CONDA_HOME}" -popd - -export PATH="${CONDA_HOME}/bin:$PATH" - -cd "${TETHYS_HOME}/src" - -# create conda env and install Tethys -echo "Setting up the ${CONDA_ENV_NAME} environment..." -conda env create -n ${CONDA_ENV_NAME} -f "environment_py${PYTHON_VERSION}.yml" -. activate ${CONDA_ENV_NAME} - -python setup.py develop - -set +e # don't exit on error anymore - -# Rename some variables for reference after deactivating tethys environment. -TETHYS_CONDA_HOME=${CONDA_HOME} -TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} - -on_exit(){ - set +e - set +x -} -trap on_exit EXIT diff --git a/docker/run.sh b/docker/run.sh new file mode 100644 index 000000000..5c5763bbd --- /dev/null +++ b/docker/run.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +echo_status() { + local args="${@}" + tput setaf 4 + tput bold + echo -e "- $args" + tput sgr0 +} + +echo_status "Starting up..." + +# Set extra ENVs +export NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') +export NGINX_PIDFILE=$(grep 'pid .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') +export UWSGI_PIDFILE=$(grep 'pidfile2: .*' src/tethys_portal/tethys_uwsgi.yml | awk '{print $2}') + +# Create Salt Config +echo "file_client: local" > /etc/salt/minion +echo "postgres.host: '${TETHYS_DB_HOST}'" >> /etc/salt/minion +echo "postgres.port: '${TETHYS_DB_PORT}'" >> /etc/salt/minion +echo "postgres.user: '${TETHYS_DB_USERNAME}'" >> /etc/salt/minion +echo "postgres.pass: '${TETHYS_DB_PASSWORD}'" >> /etc/salt/minion +echo "postgres.bins_dir: '${CONDA_HOME}/envs/${CONDA_ENV_NAME}/bin'" >> /etc/salt/minion + +# Apply States +echo_status "Enforcing start state... (This might take a bit)" +salt-call --local state.apply +find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} +echo_status "Done!" + +# Watch Logs +echo_status "Watching logs" +tail -qF /var/log/nginx/* /var/log/uwsgi/* diff --git a/docker/run_tethys.sh b/docker/run_tethys.sh deleted file mode 100644 index 605c1d07b..000000000 --- a/docker/run_tethys.sh +++ /dev/null @@ -1,43 +0,0 @@ -if [ ! -f "/usr/lib/tethys/setup_complete" ] ; -then - - echo Starting TethysCore Setup - apt-get update && apt-get install -y gcc - bash setup_tethys.sh \ - --allowed-host ${TETHYSBUILD_ALLOWED_HOST:-127.0.0.1} \ - --python-version ${TETHYSBUILD_PY_VERSION:-2} \ - --db-username ${TETHYSBUILD_DB_USERNAME:-tethys_default} \ - --db-password ${TETHYSBUILD_DB_PASSWORD:-pass} \ - --db-host ${TETHYSBUILD_DB_HOST:-127.0.0.1} \ - --db-port ${TETHYSBUILD_DB_PORT:-5432} \ - --superuser ${TETHYSBUILD_SUPERUSER:-tethys_super} \ - --superuser-pass ${TETHYSBUILD_SUPERUSER_PASS:-admin} \ - --tethys-home ${TETHYSBUILD_TETHYS_HOME:-/usr/lib/tethys} \ - echo TethysCore Setup Complete - cd /var/www/tethys/apps - echo Setting Up CityWater - - echo "----------------------------------------------------------------------------------" - echo "Setting Up NGINX" - echo "----------------------------------------------------------------------------------" - NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') - NGINX_GROUP=${NGINX_USER} - NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') - - chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src - chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/static - - /bin/bash -c 'mkdir -p /run/uwsgi; chown www-data:www-data /run/uwsgi' - - touch /usr/lib/tethys/setup_complete - -fi - - -echo "----------------------------------------------------------------------------------" -echo Starting Server -echo "----------------------------------------------------------------------------------" -nohup /usr/lib/tethys/miniconda/envs/tethys/bin/uwsgi --yaml /usr/lib/tethys/src/tethys_portal/tethys_uwsgi.yml --uid www-data --gid www-data& -nginx -g 'daemon off;' - -. deactivate diff --git a/docker/salt/states/init.sls b/docker/salt/init.sls similarity index 54% rename from docker/salt/states/init.sls rename to docker/salt/init.sls index 32346d788..4dfbc2043 100644 --- a/docker/salt/states/init.sls +++ b/docker/salt/init.sls @@ -1,20 +1,21 @@ -{%- set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') -%} -{%- set CONDA_ENV_NAME = salt['environ.get']('CONDA_ENV_NAME') -%} -{%- set CONDA_HOME = salt['environ.get']('CONDA_HOME') -%} -{%- set TETHYS_BIN_DIR = [CONDA_HOME, "/envs/", CONDA_ENV_NAME, "/bin"]|join -%} -{%- set TETHYS_DB_HOST = salt['environ.get']('TETHYS_DB_HOST') -%} -{%- set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') -%} -{%- set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') -%} -{%- set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') -%} -{%- set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') -%} -{%- set TETHYS_PUBLIC_HOST = salt['environ.get']('TETHYS_PUBLIC_HOST') -%} -{%- set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') -%} -{%- set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') -%} -{%- set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') -%} +{% set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') %} +{% set CONDA_ENV_NAME = salt['environ.get']('CONDA_ENV_NAME') %} +{% set CONDA_HOME = salt['environ.get']('CONDA_HOME') %} +{% set NGINX_USER = salt['environ.get']('NGINX_USER') %} +{% set TETHYS_BIN_DIR = [CONDA_HOME, "/envs/", CONDA_ENV_NAME, "/bin"]|join %} +{% set TETHYS_DB_HOST = salt['environ.get']('TETHYS_DB_HOST') %} +{% set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') %} +{% set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') %} +{% set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') %} +{% set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') %} +{% set TETHYS_PUBLIC_HOST = salt['environ.get']('TETHYS_PUBLIC_HOST') %} +{% set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') %} +{% set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') %} +{% set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') %} ~/.bashrc: file.append: - - text: alias t=. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} + - text: "alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }}'" Generate_Tethys_Settings: cmd.run: @@ -60,8 +61,16 @@ Generate_uwsgi_Settings: Generate_uwsgi_service: cmd.run: - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite + +/run/uwsgi: + file.directory: + - user: {{ NGINX_USER }} + - makedirs: True + +/var/log/uwsgi/tethys.log: file.managed: - - name: /var/log/uwsgi/tethys.log + - user: {{ NGINX_USER }} + - replace: False - makedirs: True Prepare_Database: @@ -72,28 +81,30 @@ Prepare_Database: postgres_database.present: - name: {{ TETHYS_DB_USERNAME }} cmd.run: - - name: {{ TETHYS_BIN_DIR }}/tethys manage syncdb + - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys manage syncdb + - shell: /bin/bash Create_Super_User: cmd.run: - - name: echo "from django.contrib.auth.models import User; User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}') if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0)" | {{TETHYS_BIN_DIR }}/python manage.py shell + - name: > + echo "from django.contrib.auth.models import User; if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0): User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')" | {{TETHYS_BIN_DIR }}/python manage.py shell - cwd: {{ TETHYS_HOME }}/src Link_NGINX_Config: file.symlink: - name: /etc/nginx/sites-enabled/tethys_nginx.conf - - target: ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + - target: {{ TETHYS_HOME }}/src/tethys_portal/tethys_nginx.conf uwsgi: cmd.run: - - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml /usr/lib/tethys/src/tethys_portal/tethys_uwsgi.yml --uid www-data --gid www-data + - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} - bg: True - ignore_timeout: True nginx: cmd.run: - - name: nginx -g 'daemon off' + - name: nginx -g 'daemon off;' - bg: True - ignore_timeout: True diff --git a/docker/salt/states/top.sls b/docker/salt/top.sls similarity index 100% rename from docker/salt/states/top.sls rename to docker/salt/top.sls diff --git a/docker/setup_tethys.sh b/docker/setup_tethys.sh deleted file mode 100644 index bc4f5392c..000000000 --- a/docker/setup_tethys.sh +++ /dev/null @@ -1,275 +0,0 @@ -#!/bin/bash - -USAGE="USAGE: . install_tethys.sh [options]\n -\n -OPTIONS:\n - -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n - -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n - -p, --port Port on which to serve tethys. Default is 8000.\n - --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n - --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n - --db-password Password that the tethys database server will use. Default is 'pass'.\n - --db-port Port that the tethys database server will use. Default is 5436.\n - -S, --superuser Tethys super user name. Default is 'admin'.\n - -E, --superuser-email Tethys super user email. Default is ''.\n - -P, --superuser-pass Tethys super user password. Default is 'pass'.\n - -x Flag to turn on shell command echoing.\n - -h, --help Print this help information.\n -" - -print_usage () -{ - echo -e ${USAGE} - exit -} -set -e # exit on error - -# Set platform specific default options -LINUX_DISTRIBUTION=$(python -c "import platform; print(platform.linux_distribution(full_distribution_name=0)[0])") -# convert to lower case -echo "Linux Distribution: ${LINUX_DISTRIBUTION}" -LINUX_DISTRIBUTION=${LINUX_DISTRIBUTION,,} -MINICONDA_URL="https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" -BASH_PROFILE=".bashrc" -resolve_relative_path () -{ - local __path_var="$1" - eval $__path_var="'$(readlink -f $2)'" -} - - -# Set default options -ALLOWED_HOST='127.0.0.1' -TETHYS_HOME=~/tethys -TETHYS_PORT=80 -TETHYS_DB_USERNAME='tethys_default' -TETHYS_DB_PASSWORD='pass' -TETHYS_DB_HOST='172.17.0.1' -TETHYS_DB_PORT=5432 -CONDA_ENV_NAME='tethys' -PYTHON_VERSION='2' - -TETHYS_SUPER_USER='admin' -TETHYS_SUPER_USER_EMAIL='' -TETHYS_SUPER_USER_PASS='pass' - -# parse command line options -set_option_value () -{ - local __option_key="$1" - value="$2" - if [[ $value == -* ]] - then - print_usage - fi - eval $__option_key="$value" -} -while [[ $# -gt 0 ]] -do -key="$1" - -case $key in - -t|--tethys-home) - set_option_value TETHYS_HOME "$2" - shift # past argument - ;; - -a|--allowed-host) - set_option_value ALLOWED_HOST "$2" - shift # past argument - ;; - -p|--port) - set_option_value TETHYS_PORT "$2" - shift # past argument - ;; - --python-version) - set_option_value PYTHON_VERSION "$2" - shift # past argument - ;; - --db-username) - set_option_value TETHYS_DB_USERNAME "$2" - shift # past argument - ;; - --db-password) - set_option_value TETHYS_DB_PASSWORD "$2" - shift # past argument - ;; - --db-port) - set_option_value TETHYS_DB_PORT "$2" - shift # past argument - ;; - --db-host) - set_option_value TETHYS_DB_HOST "$2" - shift # past argument - ;; - -S|--superuser) - set_option_value TETHYS_SUPER_USER "$2" - shift # past argument - ;; - -E|--superuser-email) - set_option_value TETHYS_SUPER_USER_EMAIL "$2" - shift # past argument - ;; - -P|--superuser-pass) - set_option_value TETHYS_SUPER_USER_PASS "$2" - shift # past argument - ;; - -x) - ECHO_COMMANDS="true" - ;; - -h|--help) - print_usage - ;; - *) # unknown option - echo Ignoring unrecognized option: $key - ;; -esac -shift # past argument or value -done - -# resolve relative paths -CONDA_HOME="${TETHYS_HOME}/miniconda" -resolve_relative_path TETHYS_HOME ${TETHYS_HOME} -resolve_relative_path CONDA_HOME ${CONDA_HOME} - - -if [ -n "${ECHO_COMMANDS}" ] -then - set -x # echo commands as they are executed -fi - - -# Set paths for environment activate/deactivate scripts -ACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/activate.d" -DEACTIVATE_DIR="${CONDA_HOME}/envs/${CONDA_ENV_NAME}/etc/conda/deactivate.d" -ACTIVATE_SCRIPT="${ACTIVATE_DIR}/tethys-activate.sh" -DEACTIVATE_SCRIPT="${DEACTIVATE_DIR}/tethys-deactivate.sh" - - -# Rename some variables for reference after deactivating tethys environment. -TETHYS_CONDA_HOME=${CONDA_HOME} -TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} - - -# prompt for sudo -echo "Installing Tethys Production Server..." - -# NGINEX -rm /etc/nginx/sites-enabled/default -NGINX_SITES_DIR='sites-enabled' - -# Create environment activate/deactivate scripts -mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" - -echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" -echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" -echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" -echo "export CONDA_HOME='${CONDA_HOME}'" >> "${ACTIVATE_SCRIPT}" -echo "export CONDA_ENV_NAME='${CONDA_ENV_NAME}'" >> "${ACTIVATE_SCRIPT}" -echo "alias tethys_start_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" -l \"\${TETHYS_HOME}/psql/logfile\" start -o \"-p \${TETHYS_DB_PORT}\"'" >> "${ACTIVATE_SCRIPT}" -echo "alias tstartdb=tethys_start_db" >> "${ACTIVATE_SCRIPT}" -echo "alias tethys_stop_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" stop'" >> "${ACTIVATE_SCRIPT}" -echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" -echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" -echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" - -echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" -echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" -echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" -echo "unset CONDA_HOME" >> "${DEACTIVATE_SCRIPT}" -echo "unset CONDA_ENV_NAME" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tethys_start_db" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tstartdb" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tethys_stop_db" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" - -echo "# Tethys Platform" >> ~/${BASH_PROFILE} -echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} - -echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" -echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" -echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" -echo "alias tuo=tethys_user_own" >> "${ACTIVATE_SCRIPT}" -echo "alias tethys_server_own='sudo chown -R \${NGINX_USER}:\${NGINX_USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" -echo "alias tso=tethys_server_own" >> "${ACTIVATE_SCRIPT}" -echo "alias tethys_server_restart='tso; sudo systemctl restart tethys.uwsgi.service; sudo systemctl restart nginx'" >> "${ACTIVATE_SCRIPT}" -echo "alias tsr=tethys_server_restart" >> "${ACTIVATE_SCRIPT}" - -echo "unset NGINX_USER" >> "${DEACTIVATE_SCRIPT}" -echo "unset NGINX_HOME" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tethys_user_own" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tuo" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tethys_server_own" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tso" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tethys_server_restart" >> "${DEACTIVATE_SCRIPT}" -echo "unalias tsr" >> "${DEACTIVATE_SCRIPT}" - - -# ACTIVATE -. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} - -# INSTALL REQUIREMENTS -conda install -c conda-forge uwsgi -y - -# GEN SETTINGS -tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite -sed -i -e "s/'HOST': '127.0.0.1',/'HOST': '${TETHYSBUILD_DB_HOST}',/g" /usr/lib/tethys/src/tethys_portal/settings.py -sed -i -e 's/BYPASS_TETHYS_HOME_PAGE = False/BYPASS_TETHYS_HOME_PAGE = True/g' /usr/lib/tethys/src/tethys_portal/settings.py -sed -i -e "s/#TETHYS_WORKSPACES_ROOT = '\/var\/www\/tethys\/static\/workspaces'/TETHYS_WORKSPACES_ROOT = '\/usr\/lib\/tethys\/workspaces'/g" /usr/lib/tethys/src/tethys_portal/settings.py - -sed -i -e 's/SESSION_SECURITY_WARN_AFTER = 840/SESSION_SECURITY_WARN_AFTER = 25 * 60/g' /usr/lib/tethys/src/tethys_portal/settings.py -sed -i -e 's/SESSION_SECURITY_EXPIRE_AFTER = 900/SESSION_SECURITY_EXPIRE_AFTER = 30 * 60/g' /usr/lib/tethys/src/tethys_portal/settings.py -echo "PUBLIC_HOST = \"${TETHYSBUILD_PUBLIC_HOST}\"" >> /usr/lib/tethys/src/tethys_portal/settings.py - -# DB ROLLS/ETC -if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command ""; echo $?) -ne 0 ]]; then # Check if postgres has a password - echo "default postgres user has a password set, assuming database is setup correctly..." - tethys manage syncdb -else - if [[ $(psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "SELECT 1 FROM pg_roles WHERE rolname='${TETHYS_DB_USERNAME}'" | grep -q 1; echo $?) -ne 0 ]]; then - echo "Creating DB User and Password" - cd /usr/lib/tethys/src - psql -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" - createdb -U postgres -h ${TETHYS_DB_HOST} -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 - tethys manage syncdb - echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" - echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell - cd /usr/lib/tethys - else - tethys manage syncdb - fi -fi - -# NGINX AND UWSGI -tethys gen nginx --overwrite -tethys gen uwsgi_settings --overwrite -tethys gen uwsgi_service --overwrite -NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') -NGINX_GROUP=${NGINX_USER} -NGINX_HOME=$(grep ${NGINX_USER} /etc/passwd | awk -F':' '{print $6}') -chmod 705 ~ -mkdir /var/log/uwsgi -touch /var/log/uwsgi/tethys.log -ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ -chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log - -# STATIC FILES AND WORKSPACES -mkdir -p ${TETHYS_HOME}/static ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps -tethys manage collectall --noinput -chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME} - -# ECHOING -set +x - -# DEACTIVATE -. deactivate - - -# EXIT -on_exit(){ - set +e - set +x -} -trap on_exit EXIT - From 6da1860f2302c52ffb3934443dec3d84f4908ff3 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 15:35:36 -0600 Subject: [PATCH 108/215] Fix permissions --- Dockerfile | 2 +- docker/run.sh | 1 + docker/salt/init.sls | 5 +++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7e562a7bc..e60f510df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -117,4 +117,4 @@ WORKDIR ${TETHYS_HOME} # Create Salt configuration based on ENVs CMD bash run.sh HEALTHCHECK --start-period=240s \ - CMD ps $(cat $(grep 'pid .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}')) > /dev/null && ps $(cat $(grep 'pidfile2: .*' src/tethys_portal/tethys_uwsgi.yml | awk '{print $1}')) > /dev/null; + CMD ps $(cat $(grep 'pid .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}')) > /dev/null && ps $(cat $(grep 'pidfile2: .*' src/tethys_portal/tethys_uwsgi.yml | awk '{print $2}')) > /dev/null; diff --git a/docker/run.sh b/docker/run.sh index 5c5763bbd..5f491f879 100644 --- a/docker/run.sh +++ b/docker/run.sh @@ -26,6 +26,7 @@ echo "postgres.bins_dir: '${CONDA_HOME}/envs/${CONDA_ENV_NAME}/bin'" >> /etc/sal # Apply States echo_status "Enforcing start state... (This might take a bit)" salt-call --local state.apply +echo_status "Fixing permissions" find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} echo_status "Done!" diff --git a/docker/salt/init.sls b/docker/salt/init.sls index 4dfbc2043..04d3a6deb 100644 --- a/docker/salt/init.sls +++ b/docker/salt/init.sls @@ -62,9 +62,10 @@ Generate_uwsgi_service: cmd.run: - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite -/run/uwsgi: - file.directory: +/run/uwsgi/tethys.pid: + file.managed: - user: {{ NGINX_USER }} + - replace: False - makedirs: True /var/log/uwsgi/tethys.log: From fb27e88ef69ee8cfcf463d5412c544676c7623a9 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 15:50:10 -0600 Subject: [PATCH 109/215] Run collectall during build --- Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e60f510df..a1dd2f053 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,8 +67,8 @@ ADD *.py ${TETHYS_HOME}/src/ # Run Installer RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; python setup.py develop \ - ; conda install -c conda-forge uwsgi -y \ - ; mkdir ${TETHYS_HOME}/workspaces' + ; conda install -c conda-forge uwsgi -y' +RUN mkdir ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps ${TETHYS_HOME}/static # Add static files ADD static ${TETHYS_HOME}/src/static/ @@ -82,7 +82,8 @@ RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; sed -i -e "s:#TETHYS_WORKSPACES_ROOT = .*$:TETHYS_WORKSPACES_ROOT = \"/usr/lib/tethys/workspaces\":" ${TETHYS_HOME}/src/tethys_portal/settings.py \ ; tethys gen nginx --overwrite \ ; tethys gen uwsgi_settings --overwrite \ - ; tethys gen uwsgi_service --overwrite' + ; tethys gen uwsgi_service --overwrite \ + ; tethys manage collectall --noinput' # Give NGINX Permission From 7d1a9f000a24b9b52862973a55fdb883887ec5de Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 15:55:05 -0600 Subject: [PATCH 110/215] Use manage.py rather than tethys manage to avoid db conn --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a1dd2f053..6e031cb0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,7 +83,7 @@ RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ ; tethys gen nginx --overwrite \ ; tethys gen uwsgi_settings --overwrite \ ; tethys gen uwsgi_service --overwrite \ - ; tethys manage collectall --noinput' + ; python manage.py collectstatic' # Give NGINX Permission From cea1f138907f42301c92dc861b2e7ea87edab22e Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Fri, 3 Nov 2017 17:41:51 -0600 Subject: [PATCH 111/215] Fix python script in salt for creating superuser --- docker/salt/init.sls | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/salt/init.sls b/docker/salt/init.sls index 04d3a6deb..5da415290 100644 --- a/docker/salt/init.sls +++ b/docker/salt/init.sls @@ -87,9 +87,9 @@ Prepare_Database: Create_Super_User: cmd.run: - - name: > - echo "from django.contrib.auth.models import User; if (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0): User.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')" | {{TETHYS_BIN_DIR }}/python manage.py shell + - name: "{{TETHYS_BIN_DIR }}/python {{ TETHYS_HOME }}/src/manage.py shell -c \"from django.contrib.auth.models import User;\nif (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0):\n\tUser.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')\"" - cwd: {{ TETHYS_HOME }}/src + - shell: /bin/bash Link_NGINX_Config: file.symlink: From f1cee73e31b4f2ae648dbc0948539fb2269c6175 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Sun, 5 Nov 2017 13:25:53 -0700 Subject: [PATCH 112/215] Start updating Docker documentation Add Table of Environmental Variables --- docker/README.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/docker/README.md b/docker/README.md index b1a4daf70..d4c3ef98b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,7 +1,27 @@ -# Tethys Core Docker +# Docker Support Files -This project houses the docker file and scripts needed to make the tethyscore -docker. +This project houses the docker file and scripts needed to make usable docker image. + +### Environment + +| Argument | Description |Phase| Default | +|-----------------------|-----------------------------|-----|-------------------------| +|ALLOWED_HOST | Django Setting |Run |127.0.0.1 | +|BASH_PROFILE | Where to create aliases |Run |.bashrc | +|CONDA_HOME | Path to Conda Home Dir |Build|${TETHYS_HOME}/miniconda”| +|CONDA_ENV_NAME | Name of Conda environ |Build|tethys | +|MINICONDA_URL | URL of conda install script |Build|“https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh”| +|PYTHON_VERSION | Version of Python to use |Build|2 | +|TETHYS_HOME | Path to Tethys Home Dir |Build|/usr/lib/tethys | +|TETHYS_PORT | Port for external web access|Run |80 | +|TETHYS_PUBLIC_HOST | |? |172.17.0.1 | +|TETHYS_DB_USERNAME | Postgres connection username|Run |tethys_default | +|TETHYS_DB_PASSWORD | Postgres connection password|Run |pass | +|TETHYS_DB_HOST | Postgres connection address |Run |172.17.0.1 | +|TETHYS_DB_PORT | Postgres connection Port |Run |5432 | +|TETHYS_SUPER_USER | Default superuser username |Run |admin | +|TETHYS_SUPER_USER_EMAIL| Default superuser email |Run |“” | +|TETHYS_SUPER_USER_PASS | Default superuser password |Run |pass | ### Building the Docker To build the docker use the following commands in the terminal after From a041dd727b1d4bd6bf6f85cdd027b366ac2a6dad Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Mon, 6 Nov 2017 12:57:49 -0700 Subject: [PATCH 113/215] Rename salt state --- docker/salt/top.sls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/salt/top.sls b/docker/salt/top.sls index a966bc0fb..0a1370323 100644 --- a/docker/salt/top.sls +++ b/docker/salt/top.sls @@ -1,3 +1,3 @@ base: '*': - - init + - tethyscore From 873cfd1b081eb2c1632b00a8266a70a5cc0a8914 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Mon, 6 Nov 2017 13:00:50 -0700 Subject: [PATCH 114/215] Rename salt state --- docker/salt/init.sls => tethyscore.sls | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docker/salt/init.sls => tethyscore.sls (100%) diff --git a/docker/salt/init.sls b/tethyscore.sls similarity index 100% rename from docker/salt/init.sls rename to tethyscore.sls From 5755b2e9aa32e4fc515e1f472e22dae0ad502ba3 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Mon, 6 Nov 2017 13:07:38 -0700 Subject: [PATCH 115/215] Fix git wierdness --- docker/salt/tethyscore.sls | 111 +++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docker/salt/tethyscore.sls diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls new file mode 100644 index 000000000..5da415290 --- /dev/null +++ b/docker/salt/tethyscore.sls @@ -0,0 +1,111 @@ +{% set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') %} +{% set CONDA_ENV_NAME = salt['environ.get']('CONDA_ENV_NAME') %} +{% set CONDA_HOME = salt['environ.get']('CONDA_HOME') %} +{% set NGINX_USER = salt['environ.get']('NGINX_USER') %} +{% set TETHYS_BIN_DIR = [CONDA_HOME, "/envs/", CONDA_ENV_NAME, "/bin"]|join %} +{% set TETHYS_DB_HOST = salt['environ.get']('TETHYS_DB_HOST') %} +{% set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') %} +{% set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') %} +{% set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') %} +{% set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') %} +{% set TETHYS_PUBLIC_HOST = salt['environ.get']('TETHYS_PUBLIC_HOST') %} +{% set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') %} +{% set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') %} +{% set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') %} + +~/.bashrc: + file.append: + - text: "alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }}'" + +Generate_Tethys_Settings: + cmd.run: + - name: {{ TETHYS_BIN_DIR }}/tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite + +Edit_Tethys_Settings_File_(HOST): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "'HOST': '127.0.0.1'" + - repl: "'HOST': '{{ TETHYS_DB_HOST }}'" + +Edit_Tethys_Settings_File_(HOME_PAGE): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "BYPASS_TETHYS_HOME_PAGE = False" + - repl: "BYPASS_TETHYS_HOME_PAGE = True" + +Edit_Tethys_Settings_File_(SESSION_WARN): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "SESSION_SECURITY_WARN_AFTER = 840" + - repl: "SESSION_SECURITY_WARN_AFTER = 25 * 60" + +Edit_Tethys_Settings_File_(SESSION_EXPIRE): + file.replace: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - pattern: "SESSION_SECURITY_EXPIRE_AFTER = 900" + - repl: "SESSION_SECURITY_EXPIRE_AFTER = 30 * 60" + +Edit_Tethys_Settings_File_(PUBLIC_HOST): + file.append: + - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py + - text: "PUBLIC_HOST = \"{{ TETHYS_PUBLIC_HOST }}\"" + +Generate_NGINX_Settings: + cmd.run: + - name: {{ TETHYS_BIN_DIR }}/tethys gen nginx --overwrite + +Generate_uwsgi_Settings: + cmd.run: + - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_settings --overwrite + +Generate_uwsgi_service: + cmd.run: + - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite + +/run/uwsgi/tethys.pid: + file.managed: + - user: {{ NGINX_USER }} + - replace: False + - makedirs: True + +/var/log/uwsgi/tethys.log: + file.managed: + - user: {{ NGINX_USER }} + - replace: False + - makedirs: True + +Prepare_Database: + postgres_user.present: + - name: {{ TETHYS_DB_USERNAME }} + - password: {{ TETHYS_DB_PASSWORD }} + - login: True + postgres_database.present: + - name: {{ TETHYS_DB_USERNAME }} + cmd.run: + - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys manage syncdb + - shell: /bin/bash + +Create_Super_User: + cmd.run: + - name: "{{TETHYS_BIN_DIR }}/python {{ TETHYS_HOME }}/src/manage.py shell -c \"from django.contrib.auth.models import User;\nif (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0):\n\tUser.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')\"" + - cwd: {{ TETHYS_HOME }}/src + - shell: /bin/bash + +Link_NGINX_Config: + file.symlink: + - name: /etc/nginx/sites-enabled/tethys_nginx.conf + - target: {{ TETHYS_HOME }}/src/tethys_portal/tethys_nginx.conf + + +uwsgi: + cmd.run: + - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} + - bg: True + - ignore_timeout: True + +nginx: + cmd.run: + - name: nginx -g 'daemon off;' + - bg: True + - ignore_timeout: True + From 6f088c5fd12b0cef9537c598dde8c9e18438270f Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Mon, 6 Nov 2017 13:14:57 -0700 Subject: [PATCH 116/215] split salt states in two This will allow apps that derive from this docker to more easily inject states in between initialization and running --- docker/salt/run.sls | 14 +++++ docker/salt/tethyscore.sls | 11 ---- docker/salt/top.sls | 1 + tethyscore.sls | 111 ------------------------------------- 4 files changed, 15 insertions(+), 122 deletions(-) create mode 100644 docker/salt/run.sls delete mode 100644 tethyscore.sls diff --git a/docker/salt/run.sls b/docker/salt/run.sls new file mode 100644 index 000000000..c4edcf450 --- /dev/null +++ b/docker/salt/run.sls @@ -0,0 +1,14 @@ +{% set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') %} +{% set NGINX_USER = salt['environ.get']('NGINX_USER') %} + +uwsgi: + cmd.run: + - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} + - bg: True + - ignore_timeout: True + +nginx: + cmd.run: + - name: nginx -g 'daemon off;' + - bg: True + - ignore_timeout: True \ No newline at end of file diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls index 5da415290..639d24533 100644 --- a/docker/salt/tethyscore.sls +++ b/docker/salt/tethyscore.sls @@ -97,15 +97,4 @@ Link_NGINX_Config: - target: {{ TETHYS_HOME }}/src/tethys_portal/tethys_nginx.conf -uwsgi: - cmd.run: - - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} - - bg: True - - ignore_timeout: True - -nginx: - cmd.run: - - name: nginx -g 'daemon off;' - - bg: True - - ignore_timeout: True diff --git a/docker/salt/top.sls b/docker/salt/top.sls index 0a1370323..59329b5ce 100644 --- a/docker/salt/top.sls +++ b/docker/salt/top.sls @@ -1,3 +1,4 @@ base: '*': - tethyscore + - run diff --git a/tethyscore.sls b/tethyscore.sls deleted file mode 100644 index 5da415290..000000000 --- a/tethyscore.sls +++ /dev/null @@ -1,111 +0,0 @@ -{% set ALLOWED_HOST = salt['environ.get']('ALLOWED_HOST') %} -{% set CONDA_ENV_NAME = salt['environ.get']('CONDA_ENV_NAME') %} -{% set CONDA_HOME = salt['environ.get']('CONDA_HOME') %} -{% set NGINX_USER = salt['environ.get']('NGINX_USER') %} -{% set TETHYS_BIN_DIR = [CONDA_HOME, "/envs/", CONDA_ENV_NAME, "/bin"]|join %} -{% set TETHYS_DB_HOST = salt['environ.get']('TETHYS_DB_HOST') %} -{% set TETHYS_DB_PASSWORD = salt['environ.get']('TETHYS_DB_PASSWORD') %} -{% set TETHYS_DB_PORT = salt['environ.get']('TETHYS_DB_PORT') %} -{% set TETHYS_DB_USERNAME = salt['environ.get']('TETHYS_DB_USERNAME') %} -{% set TETHYS_HOME = salt['environ.get']('TETHYS_HOME') %} -{% set TETHYS_PUBLIC_HOST = salt['environ.get']('TETHYS_PUBLIC_HOST') %} -{% set TETHYS_SUPER_USER = salt['environ.get']('TETHYS_SUPER_USER') %} -{% set TETHYS_SUPER_USER_EMAIL = salt['environ.get']('TETHYS_SUPER_USER_EMAIL') %} -{% set TETHYS_SUPER_USER_PASS = salt['environ.get']('TETHYS_SUPER_USER_PASS') %} - -~/.bashrc: - file.append: - - text: "alias t='. {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }}'" - -Generate_Tethys_Settings: - cmd.run: - - name: {{ TETHYS_BIN_DIR }}/tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite - -Edit_Tethys_Settings_File_(HOST): - file.replace: - - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - pattern: "'HOST': '127.0.0.1'" - - repl: "'HOST': '{{ TETHYS_DB_HOST }}'" - -Edit_Tethys_Settings_File_(HOME_PAGE): - file.replace: - - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - pattern: "BYPASS_TETHYS_HOME_PAGE = False" - - repl: "BYPASS_TETHYS_HOME_PAGE = True" - -Edit_Tethys_Settings_File_(SESSION_WARN): - file.replace: - - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - pattern: "SESSION_SECURITY_WARN_AFTER = 840" - - repl: "SESSION_SECURITY_WARN_AFTER = 25 * 60" - -Edit_Tethys_Settings_File_(SESSION_EXPIRE): - file.replace: - - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - pattern: "SESSION_SECURITY_EXPIRE_AFTER = 900" - - repl: "SESSION_SECURITY_EXPIRE_AFTER = 30 * 60" - -Edit_Tethys_Settings_File_(PUBLIC_HOST): - file.append: - - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - - text: "PUBLIC_HOST = \"{{ TETHYS_PUBLIC_HOST }}\"" - -Generate_NGINX_Settings: - cmd.run: - - name: {{ TETHYS_BIN_DIR }}/tethys gen nginx --overwrite - -Generate_uwsgi_Settings: - cmd.run: - - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_settings --overwrite - -Generate_uwsgi_service: - cmd.run: - - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite - -/run/uwsgi/tethys.pid: - file.managed: - - user: {{ NGINX_USER }} - - replace: False - - makedirs: True - -/var/log/uwsgi/tethys.log: - file.managed: - - user: {{ NGINX_USER }} - - replace: False - - makedirs: True - -Prepare_Database: - postgres_user.present: - - name: {{ TETHYS_DB_USERNAME }} - - password: {{ TETHYS_DB_PASSWORD }} - - login: True - postgres_database.present: - - name: {{ TETHYS_DB_USERNAME }} - cmd.run: - - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys manage syncdb - - shell: /bin/bash - -Create_Super_User: - cmd.run: - - name: "{{TETHYS_BIN_DIR }}/python {{ TETHYS_HOME }}/src/manage.py shell -c \"from django.contrib.auth.models import User;\nif (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0):\n\tUser.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')\"" - - cwd: {{ TETHYS_HOME }}/src - - shell: /bin/bash - -Link_NGINX_Config: - file.symlink: - - name: /etc/nginx/sites-enabled/tethys_nginx.conf - - target: {{ TETHYS_HOME }}/src/tethys_portal/tethys_nginx.conf - - -uwsgi: - cmd.run: - - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} - - bg: True - - ignore_timeout: True - -nginx: - cmd.run: - - name: nginx -g 'daemon off;' - - bg: True - - ignore_timeout: True - From af1c9697d310f2b3ee008426a437c9a48de3f48d Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Tue, 7 Nov 2017 16:07:02 -0700 Subject: [PATCH 117/215] Add sync stores step Although tethys core itself does not have any stores, every app that is built on it does. --- docker/salt/tethyscore.sls | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls index 639d24533..c88cd9319 100644 --- a/docker/salt/tethyscore.sls +++ b/docker/salt/tethyscore.sls @@ -85,6 +85,10 @@ Prepare_Database: - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys manage syncdb - shell: /bin/bash +Sync_Stores: + cmd.run: + - name: {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys syncstores all + Create_Super_User: cmd.run: - name: "{{TETHYS_BIN_DIR }}/python {{ TETHYS_HOME }}/src/manage.py shell -c \"from django.contrib.auth.models import User;\nif (len(User.objects.filter(username='{{ TETHYS_SUPER_USER }}')) == 0):\n\tUser.objects.create_superuser('{{ TETHYS_SUPER_USER }}', '{{ TETHYS_SUPER_USER_EMAIL }}', '{{ TETHYS_SUPER_USER_PASS }}')\"" From 35139cc74264b07006342ce6e561a95f727acf23 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Tue, 7 Nov 2017 16:24:20 -0700 Subject: [PATCH 118/215] Add pv to default packages --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6e031cb0f..62ec1cec8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ RUN apt-get update && apt-get -y install wget \ && wget -O - https://repo.saltstack.com/apt/debian/9/amd64/latest/SALTSTACK-GPG-KEY.pub | apt-key add - \ && echo "deb http://repo.saltstack.com/apt/debian/9/amd64/latest stretch main" > /etc/apt/sources.list.d/saltstack.list -RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion procps +RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion procps pv RUN rm -f /etc/nginx/sites-enabled/default # Install Miniconda @@ -88,7 +88,7 @@ RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ # Give NGINX Permission RUN export NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' | awk -F';' '{print $1}') \ - ; find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | xargs -0 -I{} chown ${NGINX_USER}: {} + ; find ${TETHYS_HOME} ! -user ${NGINX_USER} -print0 | pv | xargs -0 -I{} chown ${NGINX_USER}: {} ############ # CLEAN UP # From 89f6c2d09d4ef059ee3a27c5a147f02e5fcd8072 Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Thu, 9 Nov 2017 10:30:28 -0700 Subject: [PATCH 119/215] Add missing . --- docker/salt/tethyscore.sls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls index c88cd9319..3f678245c 100644 --- a/docker/salt/tethyscore.sls +++ b/docker/salt/tethyscore.sls @@ -87,7 +87,7 @@ Prepare_Database: Sync_Stores: cmd.run: - - name: {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys syncstores all + - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys syncstores all Create_Super_User: cmd.run: From 36dae34a926da12c7ab32bed91346bb6f16540d8 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 27 Nov 2017 20:35:21 +0000 Subject: [PATCH 120/215] add shell --- docker/salt/tethyscore.sls | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls index 3f678245c..5e98d7b99 100644 --- a/docker/salt/tethyscore.sls +++ b/docker/salt/tethyscore.sls @@ -88,6 +88,7 @@ Prepare_Database: Sync_Stores: cmd.run: - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys syncstores all + - shell: /bin/bash Create_Super_User: cmd.run: From a1cd0bfb343be1639ac68b7326ae8183bd1b027d Mon Sep 17 00:00:00 2001 From: Chandler Scott Date: Tue, 28 Nov 2017 09:30:40 -0700 Subject: [PATCH 121/215] Add gnupg2 to install/cleanup list This allows verified downloads of saltstack, since we have to add a non default repo --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 62ec1cec8..26936193d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup \ ; echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache # Install APT packages -RUN apt-get update && apt-get -y install wget \ +RUN apt-get update && apt-get -y install wget gnupg2 \ && wget -O - https://repo.saltstack.com/apt/debian/9/amd64/latest/SALTSTACK-GPG-KEY.pub | apt-key add - \ && echo "deb http://repo.saltstack.com/apt/debian/9/amd64/latest stretch main" > /etc/apt/sources.list.d/saltstack.list RUN apt-get update && apt-get -y install bzip2 git nginx gcc salt-minion procps pv @@ -93,7 +93,7 @@ RUN export NGINX_USER=$(grep 'user .*;' /etc/nginx/nginx.conf | awk '{print $2}' ############ # CLEAN UP # ############ -RUN apt-get -y remove wget gcc \ +RUN apt-get -y remove wget gcc gnupg2 \ ; apt-get -y autoremove \ ; apt-get -y clean From a4f06739ce54b20487e6089a0eacffe7223c7990 Mon Sep 17 00:00:00 2001 From: nswain Date: Mon, 18 Dec 2017 11:45:15 -0700 Subject: [PATCH 122/215] added first time check to avoid running unecessary steps when restarting and overwriting changes to the settings.py file. Shawn thinks this message is too long for a commit message. --- docker/salt/tethyscore.sls | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docker/salt/tethyscore.sls b/docker/salt/tethyscore.sls index 5e98d7b99..ee2f5fc02 100644 --- a/docker/salt/tethyscore.sls +++ b/docker/salt/tethyscore.sls @@ -20,24 +20,28 @@ Generate_Tethys_Settings: cmd.run: - name: {{ TETHYS_BIN_DIR }}/tethys gen settings --production --allowed-host={{ ALLOWED_HOST }} --db-username {{ TETHYS_DB_USERNAME }} --db-password {{ TETHYS_DB_PASSWORD }} --db-port {{ TETHYS_DB_PORT }} --overwrite + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" Edit_Tethys_Settings_File_(HOST): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "'HOST': '127.0.0.1'" - repl: "'HOST': '{{ TETHYS_DB_HOST }}'" + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" Edit_Tethys_Settings_File_(HOME_PAGE): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "BYPASS_TETHYS_HOME_PAGE = False" - repl: "BYPASS_TETHYS_HOME_PAGE = True" + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" Edit_Tethys_Settings_File_(SESSION_WARN): file.replace: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - pattern: "SESSION_SECURITY_WARN_AFTER = 840" - repl: "SESSION_SECURITY_WARN_AFTER = 25 * 60" + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" Edit_Tethys_Settings_File_(SESSION_EXPIRE): file.replace: @@ -49,18 +53,22 @@ Edit_Tethys_Settings_File_(PUBLIC_HOST): file.append: - name: {{ TETHYS_HOME }}/src/tethys_portal/settings.py - text: "PUBLIC_HOST = \"{{ TETHYS_PUBLIC_HOST }}\"" + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" Generate_NGINX_Settings: cmd.run: - name: {{ TETHYS_BIN_DIR }}/tethys gen nginx --overwrite + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" Generate_uwsgi_Settings: cmd.run: - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_settings --overwrite + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" Generate_uwsgi_service: cmd.run: - name: {{ TETHYS_BIN_DIR }}/tethys gen uwsgi_service --overwrite + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" /run/uwsgi/tethys.pid: file.managed: @@ -79,16 +87,20 @@ Prepare_Database: - name: {{ TETHYS_DB_USERNAME }} - password: {{ TETHYS_DB_PASSWORD }} - login: True + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" postgres_database.present: - name: {{ TETHYS_DB_USERNAME }} + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" cmd.run: - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys manage syncdb - shell: /bin/bash + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" Sync_Stores: cmd.run: - name: . {{ CONDA_HOME }}/bin/activate {{ CONDA_ENV_NAME }} && {{ TETHYS_BIN_DIR }}/tethys syncstores all - shell: /bin/bash + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" Create_Super_User: cmd.run: @@ -100,6 +112,13 @@ Link_NGINX_Config: file.symlink: - name: /etc/nginx/sites-enabled/tethys_nginx.conf - target: {{ TETHYS_HOME }}/src/tethys_portal/tethys_nginx.conf + - unless: /bin/bash -c "[ -f "/usr/lib/tethys/setup_complete" ];" + +Flag_Complete_Tethys_Setup: + cmd.run: + - name: touch /usr/lib/tethys/setup_complete + - shell: /bin/bash + From 193913984215473cbaa835f883790c701555f5f0 Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 17 Jan 2018 10:48:41 -0700 Subject: [PATCH 123/215] Fix several bugs, deprecation warnings, etc. --- templates/tethys_portal/accounts/login.html | 2 +- tethys_apps/decorators.py | 2 +- tethys_gizmos/templatetags/tethys_gizmos.py | 9 +++++++-- tethys_portal/middleware.py | 6 +++--- tethys_portal/views/accounts.py | 6 +++--- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/templates/tethys_portal/accounts/login.html b/templates/tethys_portal/accounts/login.html index 1f01e54d6..386715385 100644 --- a/templates/tethys_portal/accounts/login.html +++ b/templates/tethys_portal/accounts/login.html @@ -65,7 +65,7 @@

Log In

{% if signup_enabled %} Don't have an account? Sign Up {% endif %} - Forget your password? + Forgot your password?
diff --git a/tethys_apps/decorators.py b/tethys_apps/decorators.py index 0e421bc60..fc4aee505 100644 --- a/tethys_apps/decorators.py +++ b/tethys_apps/decorators.py @@ -123,7 +123,7 @@ def _wrapped_controller(request, *args, **kwargs): if not pass_permission_test: if not raise_exception: # If user is authenticated... - if request.user.is_authenticated(): + if request.user.is_authenticated: # User feedback messages.add_message(request, messages.WARNING, message) diff --git a/tethys_gizmos/templatetags/tethys_gizmos.py b/tethys_gizmos/templatetags/tethys_gizmos.py index cf581d297..2efd5572d 100644 --- a/tethys_gizmos/templatetags/tethys_gizmos.py +++ b/tethys_gizmos/templatetags/tethys_gizmos.py @@ -180,8 +180,13 @@ def render(self, context): return t.render(resolved_options) except: - if settings.TEMPLATE_DEBUG: - raise + print'WHOOPS' + if hasattr(settings, 'TEMPLATES'): + for template_settings in settings.TEMPLATES: + if 'OPTIONS' in template_settings and \ + 'debug' in template_settings['OPTIONS'] and \ + template_settings['OPTIONS']['debug']: + raise return '' diff --git a/tethys_portal/middleware.py b/tethys_portal/middleware.py index 663d28786..8dfacd80d 100644 --- a/tethys_portal/middleware.py +++ b/tethys_portal/middleware.py @@ -17,7 +17,7 @@ class TethysSocialAuthExceptionMiddleware(SocialAuthExceptionMiddleware): def process_exception(self, request, exception): if hasattr(social_exceptions, exception.__class__.__name__): if isinstance(exception, social_exceptions.AuthCanceled): - if request.user.is_anonymous(): + if request.user.is_anonymous: return redirect('accounts:login') else: return redirect('user:settings', username=request.user.username) @@ -37,14 +37,14 @@ def process_exception(self, request, exception): messages.success(request, blurb) - if request.user.is_anonymous(): + if request.user.is_anonymous: return redirect('accounts:login') else: return redirect('user:settings', username=request.user.username) elif isinstance(exception, social_exceptions.NotAllowedToDisconnect): blurb = 'Unable to disconnect from this social account.' messages.success(request, blurb) - if request.user.is_anonymous(): + if request.user.is_anonymous: return redirect('accounts:login') else: return redirect('user:settings', username=request.user.username) \ No newline at end of file diff --git a/tethys_portal/views/accounts.py b/tethys_portal/views/accounts.py index c9be01af2..88d9439f3 100644 --- a/tethys_portal/views/accounts.py +++ b/tethys_portal/views/accounts.py @@ -21,7 +21,7 @@ def login_view(request): Handle login """ # Only allow users to access login page if they are not logged in - if not request.user.is_anonymous(): + if not request.user.is_anonymous: return redirect('user:profile', username=request.user.username) # Handle form @@ -79,7 +79,7 @@ def register(request): Handle new user registration """ # Only allow users to access register page if they are not logged in - if not request.user.is_anonymous(): + if not request.user.is_anonymous: return redirect('user:profile', username=request.user.username) # Disallow access to this page if open signup is disabled @@ -141,7 +141,7 @@ def logout_view(request): Handle logout """ # Present goodbye message and logout if not anonymous - if not request.user.is_anonymous(): + if not request.user.is_anonymous: name = request.user.first_name or request.user.username messages.success(request, 'Goodbye, {0}. Come back soon!'.format(name)) logout(request) From fe1140e7cd9fe86b3c0ce650558efd85bb5732ba Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 17 Jan 2018 18:12:07 -0700 Subject: [PATCH 124/215] Enable threading in Tethys Core run.sls. --- docker/salt/run.sls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/salt/run.sls b/docker/salt/run.sls index c4edcf450..53682587f 100644 --- a/docker/salt/run.sls +++ b/docker/salt/run.sls @@ -3,7 +3,7 @@ uwsgi: cmd.run: - - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} + - name: {{ TETHYS_HOME }}/miniconda/envs/tethys/bin/uwsgi --yaml {{ TETHYS_HOME}}/src/tethys_portal/tethys_uwsgi.yml --uid {{ NGINX_USER }} --gid {{ NGINX_USER }} --enable-threads - bg: True - ignore_timeout: True From b668673d8d57704e4025e1857b7579f5f6094af0 Mon Sep 17 00:00:00 2001 From: nswain Date: Fri, 2 Feb 2018 15:08:38 -0700 Subject: [PATCH 125/215] Removing services that don't exist returns exit code 0 instead of 1 now...since it is gone, which is what we wanted... --- tethys_apps/cli/services_commands.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index c4b0794ef..253ae5adb 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -81,11 +81,11 @@ def services_remove_persistent_command(args): else: with pretty_output(FG_RED) as p: p.write('Aborted. Persistent Store Service not removed.') - exit(1) + exit(0) except ObjectDoesNotExist: with pretty_output(FG_RED) as p: p.write('A Persistent Store Service with ID/Name "{0}" does not exist.'.format(persistent_service_id)) - exit(1) + exit(0) def services_create_spatial_command(args): @@ -167,11 +167,11 @@ def services_remove_spatial_command(args): else: with pretty_output(FG_RED) as p: p.write('Aborted. Spatial Dataset Service not removed.') - exit(1) + exit(0) except ObjectDoesNotExist: with pretty_output(FG_RED) as p: p.write('A Spatial Dataset Service with ID/Name "{0}" does not exist.'.format(spatial_service_id)) - exit(1) + exit(0) def services_list_command(args): From 506647deb30232a94790072b5ba27530e65bdb8e Mon Sep 17 00:00:00 2001 From: nswain Date: Fri, 2 Feb 2018 15:11:14 -0700 Subject: [PATCH 126/215] Removed dangling print statement. --- tethys_gizmos/templatetags/tethys_gizmos.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tethys_gizmos/templatetags/tethys_gizmos.py b/tethys_gizmos/templatetags/tethys_gizmos.py index 2efd5572d..1a0f58a5b 100644 --- a/tethys_gizmos/templatetags/tethys_gizmos.py +++ b/tethys_gizmos/templatetags/tethys_gizmos.py @@ -180,7 +180,6 @@ def render(self, context): return t.render(resolved_options) except: - print'WHOOPS' if hasattr(settings, 'TEMPLATES'): for template_settings in settings.TEMPLATES: if 'OPTIONS' in template_settings and \ From f7f492a13e85080bd411ca5444c3141920c3848d Mon Sep 17 00:00:00 2001 From: nswain Date: Mon, 5 Feb 2018 11:59:30 -0700 Subject: [PATCH 127/215] Changed return exit code to 0 for create scheduler and remove scheduler cli when scheduler already exists or doesn't exist, respectively. --- tethys_apps/cli/scheduler_commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tethys_apps/cli/scheduler_commands.py b/tethys_apps/cli/scheduler_commands.py index d6af5ab32..104971120 100644 --- a/tethys_apps/cli/scheduler_commands.py +++ b/tethys_apps/cli/scheduler_commands.py @@ -16,7 +16,7 @@ def scheduler_create_command(args): if existing_scheduler: with pretty_output(FG_RED) as p: p.write('A Scheduler with name "{}" already exists. Command aborted.'.format(name)) - exit(1) + exit(0) scheduler = Scheduler( name=name, @@ -66,7 +66,7 @@ def schedulers_remove_command(args): except ObjectDoesNotExist: with pretty_output(FG_RED) as p: p.write('Scheduler with name "{}" does not exist.\nCommand aborted.'.format(name)) - exit(1) + exit(0) if force: scheduler.delete() From 2102b2a085d94f9a02e12ed494909dd446f27b57 Mon Sep 17 00:00:00 2001 From: zhiyuli Date: Fri, 16 Feb 2018 10:00:45 -0700 Subject: [PATCH 128/215] commit --- tethys_portal/views/__init__.py | 3 ++- tethys_portal/views/receivers.py | 12 ++++++++++++ tethys_portal/views/user.py | 4 +++- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 tethys_portal/views/receivers.py diff --git a/tethys_portal/views/__init__.py b/tethys_portal/views/__init__.py index bbad4e69e..bc36761ad 100644 --- a/tethys_portal/views/__init__.py +++ b/tethys_portal/views/__init__.py @@ -6,4 +6,5 @@ * Copyright: (c) Brigham Young University 2014 * License: BSD 2-Clause ******************************************************************************** -""" \ No newline at end of file +""" +from .receivers import create_auth_token diff --git a/tethys_portal/views/receivers.py b/tethys_portal/views/receivers.py new file mode 100644 index 000000000..c23c252be --- /dev/null +++ b/tethys_portal/views/receivers.py @@ -0,0 +1,12 @@ +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver +from rest_framework.authtoken.models import Token + + +# create API Token for newly created django USER object +# see: http://www.django-rest-framework.org/api-guide/authentication/#tokenauthentication +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def create_auth_token(sender, instance=None, created=False, **kwargs): + if created: + Token.objects.create(user=instance) diff --git a/tethys_portal/views/user.py b/tethys_portal/views/user.py index 35098ace4..1ec150e60 100644 --- a/tethys_portal/views/user.py +++ b/tethys_portal/views/user.py @@ -69,8 +69,10 @@ def settings(request, username=None): form = UserSettingsForm(instance=request_user) # Create template context object + user_token, token_created = Token.objects.get_or_create(user=request_user) context = {'form': form, - 'context_user': request.user} + 'context_user': request.user, + 'user_token': user_token.key} return render(request, 'tethys_portal/user/settings.html', context) From 5dd841e21b89c16f6fd6b8b66aa3e921872de013 Mon Sep 17 00:00:00 2001 From: nswain Date: Tue, 20 Feb 2018 16:19:44 -0700 Subject: [PATCH 129/215] Added scaffold for Tethys Extensions and generalized scaffold command and scaffold template, added methods to harvester to handle harvesting extensions, defined Django data model for extensions, replaced django.core.urlresolvers with django.urls (per deprecation warnings) --- tethys_apps/apps.py | 6 +- tethys_apps/base/__init__.py | 2 +- tethys_apps/base/app_base.py | 103 ++++++++--- tethys_apps/cli/__init__.py | 6 +- tethys_apps/cli/scaffold_commands.py | 132 ++++++++------ .../app_templates/default/setup.py_tmpl | 6 +- .../default/tethysapp/+project+/app.py_tmpl | 2 +- .../tethysapp/+project+/tests/tests.py_tmpl | 16 +- .../extension_templates/default/.gitignore | 9 + .../{.gitadd => default/__init__.py} | 0 .../extension_templates/default/setup.py_tmpl | 28 +++ .../default/tethysext/+project+/__init__.py | 7 + .../default/tethysext/+project+/api.py | 19 ++ .../tethysext/+project+/controllers.py_tmpl | 74 ++++++++ .../default/tethysext/+project+/ext.py_tmpl | 12 ++ .../default/tethysext/+project+/handoff.py | 3 + .../default/tethysext/+project+/model.py | 1 + .../tethysext/+project+/public/css/main.css | 0 .../tethysext/+project+/public/js/main.js | 0 .../+project+/templates/+project+/.gitadd | 0 .../tethysext/+project+/tests/__init__.py | 0 .../tethysext/+project+/tests/tests.py_tmpl | 166 ++++++++++++++++++ .../default/tethysext/__init__.py | 7 + tethys_apps/decorators.py | 2 +- .../{app_harvester.py => harvester.py} | 87 ++++++++- tethys_apps/models.py | 21 +++ tethys_apps/utilities.py | 8 +- tethys_compute/job_manager.py | 2 +- tethys_compute/views.py | 2 +- tethys_gizmos/views/gizmo_showcase.py | 2 +- tethys_portal/tests.py | 2 +- tethys_portal/views/accounts.py | 2 +- tethys_sdk/base.py | 2 +- tethys_services/utilities.py | 2 +- 34 files changed, 623 insertions(+), 108 deletions(-) create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/.gitignore rename tethys_apps/cli/scaffold_templates/extension_templates/{.gitadd => default/__init__.py} (100%) create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/setup.py_tmpl create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/__init__.py create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/api.py create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/controllers.py_tmpl create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/ext.py_tmpl create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/handoff.py create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/model.py create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/public/css/main.css create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/public/js/main.js create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/+project+/.gitadd create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/__init__.py create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/__init__.py rename tethys_apps/{app_harvester.py => harvester.py} (54%) diff --git a/tethys_apps/apps.py b/tethys_apps/apps.py index 18608eada..eed7096f5 100644 --- a/tethys_apps/apps.py +++ b/tethys_apps/apps.py @@ -9,7 +9,7 @@ """ from django.apps import AppConfig -from tethys_apps.app_harvester import SingletonAppHarvester +from tethys_apps.harvester import SingletonHarvester class TethysAppsConfig(AppConfig): @@ -21,6 +21,8 @@ def ready(self): Startup method for Tethys Apps django app. """ # Perform App Harvesting - harvester = SingletonAppHarvester() + harvester = SingletonHarvester() + harvester.harvest_extensions() harvester.harvest_apps() + diff --git a/tethys_apps/base/__init__.py b/tethys_apps/base/__init__.py index cb5689677..1248d58f2 100644 --- a/tethys_apps/base/__init__.py +++ b/tethys_apps/base/__init__.py @@ -8,7 +8,7 @@ ******************************************************************************** """ # DO NOT ERASE -from tethys_apps.base.app_base import TethysAppBase +from tethys_apps.base.app_base import TethysAppBase, TethysExtensionBase from tethys_apps.base.controller import app_controller_maker from tethys_apps.base.url_map import url_map_maker from tethys_apps.base.workspace import TethysWorkspace diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index a2a526af5..b503085ed 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -23,45 +23,63 @@ tethys_log = logging.getLogger('tethys.app_base') -class TethysAppBase(object): +class TethysBase(object): """ - Base class used to define the app class for Tethys apps. - - Attributes: - name (string): Name of the app. - index (string): Lookup term for the index URL of the app. - icon (string): Location of the image to use for the app icon. - package (string): Name of the app package. - root_url (string): Root URL of the app. - color (string): App theme color as RGB hexadecimal. - description (string): Description of the app. - tag (string): A string for filtering apps. - enable_feedback (boolean): Shows feedback button on all app pages. - feedback_emails (list): A list of emails corresponding to where submitted feedback forms are sent. - + Abstract base class of app and extension classes. """ name = '' - index = '' - icon = '' package = '' root_url = '' - color = '' description = '' - tags = '' - enable_feedback = False - feedback_emails = [] + + def url_maps(self): + """ + Override this method to define the URL Maps for your app. Your ``UrlMap`` objects must be created from a ``UrlMap`` class that is bound to the ``root_url`` of your app. Use the ``url_map_maker()`` function to create the bound ``UrlMap`` class. If you generate your app project from the scaffold, this will be done automatically. + + Returns: + iterable: A list or tuple of ``UrlMap`` objects. + + **Example:** + + :: + + from tethys_sdk.base import url_map_maker + + class MyFirstApp(TethysAppBase): + + def url_maps(self): + \""" + Example url_maps method. + \""" + # Create UrlMap class that is bound to the root url. + UrlMap = url_map_maker(self.root_url) + + url_maps = (UrlMap(name='home', + url='my-first-app', + controller='my_first_app.controllers.home', + ), + ) + + return url_maps + """ + raise NotImplementedError() + +class TethysExtensionBase(TethysBase): + """ + Base class used to define the extension class for Tethys extensions. + """ def __unicode__(self): """ String representation """ - return ''.format(self.name) + return ''.format(self.name) def __repr__(self): """ String representation """ - return ''.format(self.name) + return ''.format(self.name) def url_maps(self): """ @@ -93,7 +111,44 @@ def url_maps(self): return url_maps """ - raise NotImplementedError() + + + +class TethysAppBase(TethysBase): + """ + Base class used to define the app class for Tethys apps. + + Attributes: + name (string): Name of the app. + index (string): Lookup term for the index URL of the app. + icon (string): Location of the image to use for the app icon. + package (string): Name of the app package. + root_url (string): Root URL of the app. + color (string): App theme color as RGB hexadecimal. + description (string): Description of the app. + tag (string): A string for filtering apps. + enable_feedback (boolean): Shows feedback button on all app pages. + feedback_emails (list): A list of emails corresponding to where submitted feedback forms are sent. + + """ + index = '' + icon = '' + color = '' + tags = '' + enable_feedback = False + feedback_emails = [] + + def __unicode__(self): + """ + String representation + """ + return ''.format(self.name) + + def __repr__(self): + """ + String representation + """ + return ''.format(self.name) def custom_settings(self): """ diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index 0c7a1f8d4..b12eb6cc7 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -202,13 +202,13 @@ def tethys_command(): scaffold_parser = subparsers.add_parser('scaffold', help='Create a new Tethys app project from a scaffold.') scaffold_parser.add_argument('name', help='The name of the new Tethys app project to create. Only lowercase ' 'letters, numbers, and underscores allowed.') - scaffold_parser.add_argument('-t', '--template', dest='template', help="Name of app template to use.") - scaffold_parser.add_argument('-e', '--extension', dest='extension', help="Name of extension template to use.") + scaffold_parser.add_argument('-t', '--template', dest='template', help="Name of template to use.") + scaffold_parser.add_argument('-e', '--extension', dest='extension', action="store_true") scaffold_parser.add_argument('-d', '--defaults', dest='use_defaults', action='store_true', help="Run command, accepting default values automatically.") scaffold_parser.add_argument('-o', '--overwrite', dest='overwrite', action="store_true", help="Attempt to overwrite project automatically if it already exists.") - scaffold_parser.set_defaults(func=scaffold_command, template='default', extension=None) + scaffold_parser.set_defaults(func=scaffold_command, template='default', extension=False) # Setup generate command gen_parser = subparsers.add_parser('gen', help='Aids the installation of Tethys by automating the ' diff --git a/tethys_apps/cli/scaffold_commands.py b/tethys_apps/cli/scaffold_commands.py index e007f8a00..e9f809616 100644 --- a/tethys_apps/cli/scaffold_commands.py +++ b/tethys_apps/cli/scaffold_commands.py @@ -118,8 +118,8 @@ def scaffold_command(args): if args.extension: is_extension = True - template_name = args.extension - template_root = os.path.join(EXTENSION_PATH, args.extension) + template_name = args.template + template_root = os.path.join(EXTENSION_PATH, args.template) else: template_name = args.template template_root = os.path.join(APP_PATH, args.template) @@ -170,64 +170,98 @@ def scaffold_command(args): split_project_name = project_name.split('_') title_case_project_name = [x.title() for x in split_project_name] default_proper_name = ' '.join(title_case_project_name) - app_class_name = ''.join(title_case_project_name) + class_name = ''.join(title_case_project_name) default_theme_color = get_random_color() print('Creating new Tethys project named "{0}".'.format(project_dir)) # Get metadata from user - metadata_input = ( - { - 'name': 'proper_name', - 'prompt': 'Proper name for the app (e.g.: "My First App")', - 'default': default_proper_name, - 'validator': proper_name_validator - }, - { - 'name': 'description', - 'prompt': 'Brief description of the app', - 'default': '', - 'validator': None - }, - { - 'name': 'color', - 'prompt': 'App theme color (e.g.: "#27AE60")', - 'default': default_theme_color, - 'validator': theme_color_validator - }, - { - 'name': 'tags', - 'prompt': 'Tags: Use commas to delineate tags and ' - 'quotes around each tag (e.g.: "Hydrology","Reference Timeseries")', - 'default': '', - 'validator': None - }, - { - 'name': 'author', - 'prompt': 'Author name', - 'default': '', - 'validator': None - }, - { - 'name': 'author_email', - 'prompt': 'Author email', - 'default': '', - 'validator': None - }, - { - 'name': 'license_name', - 'prompt': 'License name', - 'default': '', - 'validator': None - }, - ) + if not is_extension: + metadata_input = ( + { + 'name': 'proper_name', + 'prompt': 'Proper name for the app (e.g.: "My First App")', + 'default': default_proper_name, + 'validator': proper_name_validator + }, + { + 'name': 'description', + 'prompt': 'Brief description of the app', + 'default': '', + 'validator': None + }, + { + 'name': 'color', + 'prompt': 'App theme color (e.g.: "#27AE60")', + 'default': default_theme_color, + 'validator': theme_color_validator + }, + { + 'name': 'tags', + 'prompt': 'Tags: Use commas to delineate tags and ' + 'quotes around each tag (e.g.: "Hydrology","Reference Timeseries")', + 'default': '', + 'validator': None + }, + { + 'name': 'author', + 'prompt': 'Author name', + 'default': '', + 'validator': None + }, + { + 'name': 'author_email', + 'prompt': 'Author email', + 'default': '', + 'validator': None + }, + { + 'name': 'license_name', + 'prompt': 'License name', + 'default': '', + 'validator': None + }, + ) + else: + metadata_input = ( + { + 'name': 'proper_name', + 'prompt': 'Proper name for the extension (e.g.: "My First Extension")', + 'default': default_proper_name, + 'validator': proper_name_validator + }, + { + 'name': 'description', + 'prompt': 'Brief description of the extension', + 'default': '', + 'validator': None + }, + { + 'name': 'author', + 'prompt': 'Author name', + 'default': '', + 'validator': None + }, + { + 'name': 'author_email', + 'prompt': 'Author email', + 'default': '', + 'validator': None + }, + { + 'name': 'license_name', + 'prompt': 'License name', + 'default': '', + 'validator': None + }, + ) # Build up template context context = { 'project': project_name, 'project_dir': project_dir, 'project_url': project_name.replace('_', '-'), - 'app_class_name': app_class_name, + 'class_name': class_name, 'proper_name': default_proper_name, 'description': '', 'color': default_theme_color, diff --git a/tethys_apps/cli/scaffold_templates/app_templates/default/setup.py_tmpl b/tethys_apps/cli/scaffold_templates/app_templates/default/setup.py_tmpl index 25997cc2f..7207ada4f 100644 --- a/tethys_apps/cli/scaffold_templates/app_templates/default/setup.py_tmpl +++ b/tethys_apps/cli/scaffold_templates/app_templates/default/setup.py_tmpl @@ -3,13 +3,13 @@ import sys from setuptools import setup, find_packages from tethys_apps.app_installation import custom_develop_command, custom_install_command -### Apps Definition ### +# -- Apps Definition -- # app_package = '{{project}}' release_package = 'tethysapp-' + app_package -app_class = '{{project}}.app:{{app_class_name}}' +app_class = '{{project}}.app:{{class_name}}' app_package_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tethysapp', app_package) -### Python Dependencies ### +# -- Python Dependencies -- # dependencies = [] setup( diff --git a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/app.py_tmpl b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/app.py_tmpl index 081b22672..a35a150fb 100644 --- a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/app.py_tmpl +++ b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/app.py_tmpl @@ -1,7 +1,7 @@ from tethys_sdk.base import TethysAppBase, url_map_maker -class {{app_class_name}}(TethysAppBase): +class {{class_name}}(TethysAppBase): """ Tethys app class for {{proper_name}}. """ diff --git a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/tests/tests.py_tmpl b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/tests/tests.py_tmpl index b7d6a6bc1..369f8ad2e 100644 --- a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/tests/tests.py_tmpl +++ b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/tests/tests.py_tmpl @@ -4,7 +4,7 @@ from tethys_sdk.testing import TethysTestCase # Use if your app has persistent stores that will be tested against. # Your app class from app.py must be passed as an argument to the TethysTestCase functions to both # create and destroy the temporary persistent stores for your app used during testing -# from ..app import {{app_class_name}} +# from ..app import {{class_name}} # Use if you'd like a simplified way to test rendered HTML templates. # You likely need to install BeautifulSoup, as it is not included by default in Tethys Platform @@ -39,11 +39,11 @@ To run any tests: To run all tests in this file: Test command: "tethys test -f tethys_apps.tethysapp.{{project}}.tests.tests" - To run tests in the {{app_class_name}}TestCase class: - Test command: "tethys test -f tethys_apps.tethysapp.{{project}}.tests.tests.{{app_class_name}}TestCase" + To run tests in the {{class_name}}TestCase class: + Test command: "tethys test -f tethys_apps.tethysapp.{{project}}.tests.tests.{{class_name}}TestCase" - To run only the test_if_tethys_platform_is_great function in the {{app_class_name}}TestCase class: - Test command: "tethys test -f tethys_apps.tethysapp.{{project}}.tests.tests.{{app_class_name}}TestCase.test_if_tethys_platform_is_great" + To run only the test_if_tethys_platform_is_great function in the {{class_name}}TestCase class: + Test command: "tethys test -f tethys_apps.tethysapp.{{project}}.tests.tests.{{class_name}}TestCase.test_if_tethys_platform_is_great" To learn more about writing tests, see: https://docs.djangoproject.com/en/1.9/topics/testing/overview/#writing-tests @@ -51,7 +51,7 @@ To learn more about writing tests, see: """ -class {{app_class_name}}TestCase(TethysTestCase): +class {{class_name}}TestCase(TethysTestCase): """ In this class you may define as many functions as you'd like to test different aspects of your app. Each function must start with the word "test" for it to be recognized and executed during testing. @@ -65,7 +65,7 @@ class {{app_class_name}}TestCase(TethysTestCase): place that code here. For example, if you are testing against any persistent stores, you should call the test database creation function here, like so: - self.create_test_persistent_stores_for_app({{app_class_name}}) + self.create_test_persistent_stores_for_app({{class_name}}) If you are testing against a controller that check for certain user info, you can create a fake test user and get a test client, like so: @@ -95,7 +95,7 @@ class {{app_class_name}}TestCase(TethysTestCase): that took place before execution of the test functions. If you are testing against any persistent stores, you should call the test database destruction function from here, like so: - self.destroy_test_persistent_stores_for_app({{app_class_name}}) + self.destroy_test_persistent_stores_for_app({{class_name}}) NOTE: You do not have to set these functions up here, but if they are not placed here and are needed then they must be placed at the very end of your individual test functions. Also, if certain diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/.gitignore b/tethys_apps/cli/scaffold_templates/extension_templates/default/.gitignore new file mode 100644 index 000000000..b0419c5d9 --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/.gitignore @@ -0,0 +1,9 @@ +*.pydevproject +*.project +*.egg-info +*.class +*.pyo +*.pyc +*.db +*.sqlite +*.DS_Store \ No newline at end of file diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/.gitadd b/tethys_apps/cli/scaffold_templates/extension_templates/default/__init__.py similarity index 100% rename from tethys_apps/cli/scaffold_templates/extension_templates/.gitadd rename to tethys_apps/cli/scaffold_templates/extension_templates/default/__init__.py diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/setup.py_tmpl b/tethys_apps/cli/scaffold_templates/extension_templates/default/setup.py_tmpl new file mode 100644 index 000000000..1d52f6342 --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/setup.py_tmpl @@ -0,0 +1,28 @@ +import os +from setuptools import setup, find_packages + +# -- Extension Definition -- # +ext_package = '{{project}}' +release_package = 'tethysext-' + ext_package +ext_class = '{{project}}.ext:{{class_name}}' +ext_package_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tethysext', ext_package) + +# -- Python Dependencies -- # +dependencies = [] + +setup( + name=release_package, + version='0.0.0', + description='{{description|default:''}}', + long_description='', + keywords='', + author='{{author|default:''}}', + author_email='{{author_email|default:''}}', + url='', + license='{{license_name|default:''}}', + packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), + namespace_packages=['tethysext', 'tethysext.' + ext_package], + include_package_data=True, + zip_safe=False, + install_requires=dependencies, +) diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/__init__.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/__init__.py new file mode 100644 index 000000000..62a094218 --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/__init__.py @@ -0,0 +1,7 @@ +# this is a namespace package +try: + import pkg_resources + pkg_resources.declare_namespace(__name__) +except ImportError: + import pkgutil + __path__ = pkgutil.extend_path(__path__, __name__) \ No newline at end of file diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/api.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/api.py new file mode 100644 index 000000000..252434b11 --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/api.py @@ -0,0 +1,19 @@ +# Define your REST API endpoints here. +# In the comments below is an example. +# For more information, see: +# http://docs.tethysplatform.org/en/dev/tethys_sdk/rest_api.html +""" +from django.http import JsonResponse +from rest_framework.authentication import TokenAuthentication +from rest_framework.decorators import api_view, authentication_classes + +@api_view(['GET']) +@authentication_classes((TokenAuthentication,)) +def get_data(request): + ''' + API Controller for getting data + ''' + name = request.GET.get('name') + data = {"name": name} + return JsonResponse(data) +""" diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/controllers.py_tmpl b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/controllers.py_tmpl new file mode 100644 index 000000000..85bfe1a7b --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/controllers.py_tmpl @@ -0,0 +1,74 @@ +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from tethys_sdk.gizmos import Button + +@login_required() +def home(request): + """ + Controller for the app home page. + """ + save_button = Button( + display_text='', + name='save-button', + icon='glyphicon glyphicon-floppy-disk', + style='success', + attributes={ + 'data-toggle':'tooltip', + 'data-placement':'top', + 'title':'Save' + } + ) + + edit_button = Button( + display_text='', + name='edit-button', + icon='glyphicon glyphicon-edit', + style='warning', + attributes={ + 'data-toggle':'tooltip', + 'data-placement':'top', + 'title':'Edit' + } + ) + + remove_button = Button( + display_text='', + name='remove-button', + icon='glyphicon glyphicon-remove', + style='danger', + attributes={ + 'data-toggle':'tooltip', + 'data-placement':'top', + 'title':'Remove' + } + ) + + previous_button = Button( + display_text='Previous', + name='previous-button', + attributes={ + 'data-toggle':'tooltip', + 'data-placement':'top', + 'title':'Previous' + } + ) + + next_button = Button( + display_text='Next', + name='next-button', + attributes={ + 'data-toggle':'tooltip', + 'data-placement':'top', + 'title':'Next' + } + ) + + context = { + 'save_button': save_button, + 'edit_button': edit_button, + 'remove_button': remove_button, + 'previous_button': previous_button, + 'next_button': next_button + } + + return render(request, '{{project}}/home.html', context) \ No newline at end of file diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/ext.py_tmpl b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/ext.py_tmpl new file mode 100644 index 000000000..70c51c388 --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/ext.py_tmpl @@ -0,0 +1,12 @@ +from tethys_sdk.base import TethysExtensionBase + + +class {{class_name}}(TethysExtensionBase): + """ + Tethys app class for {{proper_name}}. + """ + + name = '{{proper_name}}' + package = '{{project}}' + root_url = '{{project_url}}' + description = '{{description|default:"Place a brief description of your app here."}}' diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/handoff.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/handoff.py new file mode 100644 index 000000000..ac0fa7b1b --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/handoff.py @@ -0,0 +1,3 @@ +# Define your handoff handlers here +# for more information, see: +# http://docs.tethysplatform.org/en/dev/tethys_sdk/handoff.html diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/model.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/model.py new file mode 100644 index 000000000..f8f1bf485 --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/model.py @@ -0,0 +1 @@ +# Put your persistent store models in this file \ No newline at end of file diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/public/css/main.css b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/public/css/main.css new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/public/js/main.js b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/public/js/main.js new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/+project+/.gitadd b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/+project+/.gitadd new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/__init__.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl new file mode 100644 index 000000000..97336a4c6 --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl @@ -0,0 +1,166 @@ +# Most of your test classes should inherit from TethysTestCase +from tethys_sdk.testing import TethysTestCase + +# Use if your app has persistent stores that will be tested against. +# Your app class from app.py must be passed as an argument to the TethysTestCase functions to both +# create and destroy the temporary persistent stores for your app used during testing +# from ..app import {{class_name}} + +# Use if you'd like a simplified way to test rendered HTML templates. +# You likely need to install BeautifulSoup, as it is not included by default in Tethys Platform +# 1. Open a terminal +# 2. Enter command ". /usr/lib/tethys/bin/activate" to activate the Tethys python environment +# 3. Enter command "pip install beautifulsoup4" +# For help, see https://www.crummy.com/software/BeautifulSoup/bs4/doc/ +# from bs4 import BeautifulSoup + +""" +To run any tests: + 1. Open a terminal + 2. Enter command ". /usr/lib/tethys/bin/activate" to activate the Tethys python environment + 3. In settings.py make sure that the tethys_default database user is set to tethys_super + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'tethys_default', + 'USER': 'tethys_super', + 'PASSWORD': 'pass', + 'HOST': '127.0.0.1', + 'PORT': '5435' + } + } + 4. Enter tethys test command. + The general form is: "tethys test -f tethys_apps.tethysext....." + See below for specific examples + + To run all tests across this app: + Test command: "tethys test -f tethys_apps.tethysext.{{project}}" + + To run all tests in this file: + Test command: "tethys test -f tethys_apps.tethysext.{{project}}.tests.tests" + + To run tests in the {{class_name}}TestCase class: + Test command: "tethys test -f tethys_apps.tethysext.{{project}}.tests.tests.{{class_name}}TestCase" + + To run only the test_if_tethys_platform_is_great function in the {{class_name}}TestCase class: + Test command: "tethys test -f tethys_apps.tethysext.{{project}}.tests.tests.{{class_name}}TestCase.test_if_tethys_platform_is_great" + +To learn more about writing tests, see: + https://docs.djangoproject.com/en/1.9/topics/testing/overview/#writing-tests + https://docs.python.org/2.7/library/unittest.html#module-unittest +""" + + +class {{class_name}}TestCase(TethysTestCase): + """ + In this class you may define as many functions as you'd like to test different aspects of your app. + Each function must start with the word "test" for it to be recognized and executed during testing. + You could also create multiple TethysTestCase classes within this or other python files to organize your tests. + """ + + def set_up(self): + """ + This function is not required, but can be used if any environmental setup needs to take place before + execution of each test function. Thus, if you have multiple test that require the same setup to run, + place that code here. For example, if you are testing against any persistent stores, you should call the + test database creation function here, like so: + + self.create_test_persistent_stores_for_app({{class_name}}) + + If you are testing against a controller that check for certain user info, you can create a fake test user and + get a test client, like so: + + #The test client simulates a browser that can navigate your app's url endpoints + self.c = self.get_test_client() + self.user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") + # To create a super_user, use "self.create_test_superuser(*params)" with the same params + + # To force a login for the test user + self.c.force_login(self.user) + + # If for some reason you do not want to force a login, you can use the following: + login_success = self.c.login(username="joe", password="secret") + + NOTE: You do not have place these functions here, but if they are not placed here and are needed + then they must be placed at the beginning of your individual test functions. Also, if a certain + setup does not apply to all of your functions, you should either place it directly in each + function it applies to, or maybe consider creating a new test file or test class to group similar + tests. + """ + pass + + def tear_down(self): + """ + This function is not required, but should be used if you need to tear down any environmental setup + that took place before execution of the test functions. If you are testing against any persistent + stores, you should call the test database destruction function from here, like so: + + self.destroy_test_persistent_stores_for_app({{class_name}}) + + NOTE: You do not have to set these functions up here, but if they are not placed here and are needed + then they must be placed at the very end of your individual test functions. Also, if certain + tearDown code does not apply to all of your functions, you should either place it directly in each + function it applies to, or maybe consider creating a new test file or test class to group similar + tests. + """ + pass + + def is_tethys_platform_great(self): + return True + + def test_if_tethys_platform_is_great(self): + """ + This is an example test function that can be modified to test a specific aspect of your app. + It is required that the function name begins with the word "test" or it will not be executed. + Generally, the code written here will consist of many assert methods. + A list of assert methods is included here for reference or to get you started: + assertEqual(a, b) a == b + assertNotEqual(a, b) a != b + assertTrue(x) bool(x) is True + assertFalse(x) bool(x) is False + assertIs(a, b) a is b + assertIsNot(a, b) a is not b + assertIsNone(x) x is None + assertIsNotNone(x) x is not None + assertIn(a, b) a in b + assertNotIn(a, b) a not in b + assertIsInstance(a, b) isinstance(a, b) + assertNotIsInstance(a, b) !isinstance(a, b) + Learn more about assert methods here: + https://docs.python.org/2.7/library/unittest.html#assert-methods + """ + + self.assertEqual(self.is_tethys_platform_great(), True) + self.assertNotEqual(self.is_tethys_platform_great(), False) + self.assertTrue(self.is_tethys_platform_great()) + self.assertFalse(not self.is_tethys_platform_great()) + self.assertIs(self.is_tethys_platform_great(), True) + self.assertIsNot(self.is_tethys_platform_great(), False) + + def test_home_controller(self): + """ + This is an example test function of how you might test a controller that returns an HTML template rendered + with context variables. + """ + + # If all test functions were testing controllers or required a test client for another reason, the following + # 3 lines of code could be placed once in the set_up function. Note that in that case, each variable should be + # prepended with "self." (i.e. self.c = ...) to make those variables "global" to this test class and able to be + # used in each separate test function. + c = self.get_test_client() + user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") + c.force_login(user) + + # Have the test client "browse" to your home page + response = c.get('/apps/{{project_url}}/') # The final '/' is essential for all pages/controllers + + # Test that the request processed correctly (with a 200 status code) + self.assertEqual(response.status_code, 200) + + ''' + NOTE: Next, you would likely test that your context variables returned as expected. That would look + something like the following: + + context = response.context + self.assertEqual(context['my_integer'], 10) + ''' diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/__init__.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/__init__.py new file mode 100644 index 000000000..62a094218 --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/__init__.py @@ -0,0 +1,7 @@ +# this is a namespace package +try: + import pkg_resources + pkg_resources.declare_namespace(__name__) +except ImportError: + import pkgutil + __path__ = pkgutil.extend_path(__path__, __name__) \ No newline at end of file diff --git a/tethys_apps/decorators.py b/tethys_apps/decorators.py index fc4aee505..83ba0c855 100644 --- a/tethys_apps/decorators.py +++ b/tethys_apps/decorators.py @@ -13,7 +13,7 @@ from urllib.parse import urlparse from django.contrib import messages -from django.core.urlresolvers import reverse +from django.urls import reverse from django.shortcuts import redirect from django.utils.functional import wraps from past.builtins import basestring diff --git a/tethys_apps/app_harvester.py b/tethys_apps/harvester.py similarity index 54% rename from tethys_apps/app_harvester.py rename to tethys_apps/harvester.py index 9f68ce0a7..943145df9 100644 --- a/tethys_apps/app_harvester.py +++ b/tethys_apps/harvester.py @@ -7,22 +7,41 @@ * License: BSD 2-Clause ******************************************************************************** """ +from __future__ import (absolute_import, division, + print_function, unicode_literals) +from builtins import * import os import inspect -from tethys_apps.base import TethysAppBase +from tethys_apps.base import TethysAppBase, TethysExtensionBase from .terminal_colors import TerminalColors -class SingletonAppHarvester(object): +class SingletonHarvester(object): """ Collects information for initiating apps """ - + extensions = [] apps = [] _instance = None + IGNORE_MODULES = ['__builtins__', '__file__', 'pkg_resources', '__package__', '__path__', '__name__', '__doc__'] + + def harvest_extensions(self): + """ + Searches for and loads Tethys extensions. + """ + print(TerminalColors.BLUE + 'Loading Tethys Extensions...' + TerminalColors.ENDC) + try: + import tethysext + tethys_extensions = {module_name: module_obj.__name__ + for module_name, module_obj in tethysext.__dict__.items() + if module_name not in self.IGNORE_MODULES} + self._harvest_extension_instances(tethys_extensions) + except: + '''DO NOTHING''' + def harvest_apps(self): """ Searches the apps package for apps @@ -42,10 +61,22 @@ def __new__(cls): Make App Harvester a Singleton """ if not cls._instance: - cls._instance = super(SingletonAppHarvester, cls).__new__(cls) + cls._instance = super(SingletonHarvester, cls).__new__(cls) return cls._instance + @staticmethod + def _validate_extension(extension): + """ + Validate the given extension. + Args: + extension(module_obj): ext module object of the Tethys extension. + + Returns: + module_obj or None: returns validated module object or None if not valid. + """ + return extension + @staticmethod def _validate_app(app): """ @@ -66,6 +97,52 @@ def _validate_app(app): return app + def _harvest_extension_instances(self, extension_packages): + """ + Locate the extension class, instantiate it, and save for later use. + + Arg: + extension_packages(dict): Dictionary where keys are the name of the extension and value is the extension package module object. + """ + valid_ext_instances = [] + loaded_extensions = [] + + for extension_name, extension_package in extension_packages.items(): + + # Import the "ext" module from the extension package + ext_module = __import__(extension_package + ".ext", fromlist=['']) + + # Retrieve the members of the ext_module and iterate through + # them to find the the class that inherits from TethysExtensionBase. + for name, obj in inspect.getmembers(ext_module): + try: + # issubclass() will fail if obj is not a class + if (issubclass(obj, TethysExtensionBase)) and (obj is not TethysExtensionBase): + # Assign a handle to the class + ExtensionClass = getattr(ext_module, name) + + # Instantiate app and validate + ext_instance = ExtensionClass() + validated_ext_instance = self._validate_extension(ext_instance) + + # compile valid apps + if validated_ext_instance: + valid_ext_instances.append(validated_ext_instance) + + # Notify user that the app has been loaded + loaded_extensions.append(extension_name) + + except TypeError: + '''DO NOTHING''' + except: + raise + + # Save valid apps + self.extensions = valid_ext_instances + + # Update user + print('Tethys Extensions Loaded: {0}'.format(', '.join(loaded_extensions))) + def _harvest_app_instances(self, app_packages_list): """ Search each app package for the app.py module. Find the AppBase class in the app.py @@ -113,4 +190,4 @@ def _harvest_app_instances(self, app_packages_list): self.apps = valid_app_instance_list # Update user - print('Tethys Apps Loaded: {0}'.format(' '.join(loaded_apps))) + print('Tethys Apps Loaded: {0}'.format(', '.join(loaded_apps))) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index ccdc33437..fd679bb01 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -113,6 +113,27 @@ def persistent_store_database_settings(self): .select_subclasses('persistentstoredatabasesetting') +class TethysExtension(models.Model): + """ + DB Model for Tethys Extension + """ + # The package is enforced to be unique by the file system + package = models.CharField(max_length=200, unique=True, default='') + + # Portal admin first attributes + name = models.CharField(max_length=200, default='') + description = models.TextField(max_length=1000, blank=True, default='') + + # Developer first attributes + root_url = models.CharField(max_length=200, default='') + + # Portal admin only attributes + enabled = models.BooleanField(default=True) + + def __unicode__(self): + return text(self.name) + + class TethysAppSetting(models.Model): """ DB Model for Tethys App Settings diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 2de4f34e9..82bfd93a2 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -21,7 +21,7 @@ from django.utils._os import safe_join from past.builtins import basestring from tethys_apps import tethys_log -from tethys_apps.app_harvester import SingletonAppHarvester +from tethys_apps.harvester import SingletonHarvester from tethys_apps.base import permissions from tethys_apps.models import TethysApp @@ -37,7 +37,7 @@ def register_app_permissions(): from django.contrib.auth.models import Permission, Group # Get the apps - harvester = SingletonAppHarvester() + harvester = SingletonHarvester() apps = harvester.apps all_app_permissions = {} all_groups = {} @@ -162,7 +162,7 @@ def generate_app_url_patterns(): """ # Get controllers list from app harvester - harvester = SingletonAppHarvester() + harvester = SingletonHarvester() apps = harvester.apps app_url_patterns = dict() @@ -301,7 +301,7 @@ def sync_tethys_app_db(): from django.conf import settings # Get the harvester - harvester = SingletonAppHarvester() + harvester = SingletonHarvester() try: # Make pass to remove apps that were uninstalled diff --git a/tethys_compute/job_manager.py b/tethys_compute/job_manager.py index 1f098c143..500ec53d4 100644 --- a/tethys_compute/job_manager.py +++ b/tethys_compute/job_manager.py @@ -12,7 +12,7 @@ import logging import warnings -from django.core.urlresolvers import reverse +from django.urls import reverse from tethys_compute.models import (TethysJob, BasicJob, diff --git a/tethys_compute/views.py b/tethys_compute/views.py index bb004b39e..1d8a85f6f 100644 --- a/tethys_compute/views.py +++ b/tethys_compute/views.py @@ -9,7 +9,7 @@ """ # from django.shortcuts import render, redirect, get_object_or_404, get_list_or_404 # from django.http import HttpResponse, HttpResponseServerError -# from django.core.urlresolvers import reverse +# from django.urls import reverse # from django.core.exceptions import PermissionDenied # # from tethyscluster.cli_api import TethysCluster diff --git a/tethys_gizmos/views/gizmo_showcase.py b/tethys_gizmos/views/gizmo_showcase.py index b4c5d3d1f..a55bde0c4 100644 --- a/tethys_gizmos/views/gizmo_showcase.py +++ b/tethys_gizmos/views/gizmo_showcase.py @@ -12,7 +12,7 @@ from datetime import datetime from django.contrib.auth.decorators import login_required from django.shortcuts import render, redirect -from django.core.urlresolvers import reverse +from django.urls import reverse from django.http import HttpResponse, JsonResponse from django.contrib import messages import plotly.graph_objs as go diff --git a/tethys_portal/tests.py b/tethys_portal/tests.py index f2b671161..f5e8869dc 100644 --- a/tethys_portal/tests.py +++ b/tethys_portal/tests.py @@ -7,7 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ -from django.core.urlresolvers import reverse +from django.urls import reverse from django.test import TestCase from django.test.client import Client from django.contrib.auth.models import User diff --git a/tethys_portal/views/accounts.py b/tethys_portal/views/accounts.py index 88d9439f3..fa6f6c7f0 100644 --- a/tethys_portal/views/accounts.py +++ b/tethys_portal/views/accounts.py @@ -8,7 +8,7 @@ ******************************************************************************** """ from django.conf import settings -from django.core.urlresolvers import reverse +from django.urls import reverse from django.shortcuts import render, redirect from django.contrib.auth import authenticate, login, logout from django.contrib.auth.views import password_reset, password_reset_confirm diff --git a/tethys_sdk/base.py b/tethys_sdk/base.py index 753c2a538..dba498234 100644 --- a/tethys_sdk/base.py +++ b/tethys_sdk/base.py @@ -8,5 +8,5 @@ ******************************************************************************** """ # DO NOT ERASE -from tethys_apps.base import TethysAppBase +from tethys_apps.base import TethysAppBase, TethysExtensionBase from tethys_apps.base.url_map import url_map_maker diff --git a/tethys_services/utilities.py b/tethys_services/utilities.py index a5ecb1fb7..cff7e32d4 100644 --- a/tethys_services/utilities.py +++ b/tethys_services/utilities.py @@ -15,7 +15,7 @@ from owslib.wps import WebProcessingService from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import reverse +from django.urls import reverse from django.shortcuts import redirect from social_core.exceptions import AuthAlreadyAssociated, AuthException From c68bdfc9356914a0d97d067dc97251b6148a94dc Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 21 Feb 2018 10:28:10 -0700 Subject: [PATCH 130/215] Added extensions to the admin portal, and generalized sync db to add extensions. --- tethys_apps/admin.py | 14 ++ tethys_apps/base/testing/testing.py | 4 +- tethys_apps/cli/manage_commands.py | 4 +- tethys_apps/models.py | 4 + tethys_apps/urls.py | 4 +- tethys_apps/utilities.py | 196 ++++++++++++++++++---------- 6 files changed, 149 insertions(+), 77 deletions(-) diff --git a/tethys_apps/admin.py b/tethys_apps/admin.py index f8d6e636e..59f86ba6e 100644 --- a/tethys_apps/admin.py +++ b/tethys_apps/admin.py @@ -10,6 +10,7 @@ from django.contrib import admin from guardian.admin import GuardedModelAdmin from tethys_apps.models import (TethysApp, + TethysExtension, CustomSetting, DatasetServiceSetting, SpatialDatasetServiceSetting, @@ -86,4 +87,17 @@ def has_delete_permission(self, request, obj=None): def has_add_permission(self, request): return False + + +class TethysExtensionAdmin(GuardedModelAdmin): + readonly_fields = ('package', 'name', 'description') + fields = ('package', 'name', 'description', 'enabled') + + def has_delete_permission(self, request, obj=None): + return False + + def has_add_permission(self, request): + return False + admin.site.register(TethysApp, TethysAppAdmin) +admin.site.register(TethysExtension, TethysExtensionAdmin) \ No newline at end of file diff --git a/tethys_apps/base/testing/testing.py b/tethys_apps/base/testing/testing.py index 4c4f7dccb..efc9cd345 100644 --- a/tethys_apps/base/testing/testing.py +++ b/tethys_apps/base/testing/testing.py @@ -14,8 +14,8 @@ class TethysTestCase(TestCase): def setUp(self): # Resets the apps database and app permissions (workaround since Django's testing framework refreshes the # core db after each individual test) - from tethys_apps.utilities import sync_tethys_app_db, register_app_permissions - sync_tethys_app_db() + from tethys_apps.utilities import sync_tethys_db, register_app_permissions + sync_tethys_db() register_app_permissions() self.set_up() diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index e228b6b37..ec05a3af3 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -104,8 +104,8 @@ def manage_command(args): elif args.command == MANAGE_CREATESUPERUSER: primary_process = ['python', manage_path, 'createsuperuser'] elif args.command == MANAGE_SYNCAPPS: - from tethys_apps.utilities import sync_tethys_app_db - sync_tethys_app_db() + from tethys_apps.utilities import sync_tethys_db + sync_tethys_db() if primary_process: run_process(primary_process) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index fd679bb01..846c8de19 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -130,6 +130,10 @@ class TethysExtension(models.Model): # Portal admin only attributes enabled = models.BooleanField(default=True) + class Meta: + verbose_name = 'Tethys Extension' + verbose_name_plural = 'Installed Extensions' + def __unicode__(self): return text(self.name) diff --git a/tethys_apps/urls.py b/tethys_apps/urls.py index e71044ca6..bab462669 100644 --- a/tethys_apps/urls.py +++ b/tethys_apps/urls.py @@ -10,12 +10,12 @@ from django.db.utils import ProgrammingError from django.core.exceptions import ObjectDoesNotExist from django.conf.urls import url, include -from tethys_apps.utilities import generate_app_url_patterns, sync_tethys_app_db, register_app_permissions +from tethys_apps.utilities import generate_app_url_patterns, sync_tethys_db, register_app_permissions from tethys_apps.views import library, send_beta_feedback_email from tethys_apps import tethys_log # Sync the tethys apps database -sync_tethys_app_db() +sync_tethys_db() urlpatterns = [ url(r'^$', library, name='app_library'), diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 82bfd93a2..a50a72fdb 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -23,7 +23,7 @@ from tethys_apps import tethys_log from tethys_apps.harvester import SingletonHarvester from tethys_apps.base import permissions -from tethys_apps.models import TethysApp +from tethys_apps.models import TethysApp, TethysExtension log = logging.getLogger('tethys.tethys_apps.utilities') @@ -294,12 +294,10 @@ def list(self, ignore_patterns): yield path, storage -def sync_tethys_app_db(): +def sync_tethys_db(): """ - Sync installed apps with database. + Sync installed apps and extensions with database. """ - from django.conf import settings - # Get the harvester harvester = SingletonHarvester() @@ -312,80 +310,136 @@ def sync_tethys_app_db(): if db_apps.package not in installed_app_packages: db_apps.delete() + # Make pass to remove extensions that were uninstalled + db_extensions = TethysExtension.objects.all() + installed_extension_packages = [extension.package for extension in harvester.extensions] + + for db_extensions in db_extensions: + if db_extensions.package not in installed_extension_packages: + db_extensions.delete() + # Make pass to add apps to db that are newly installed + installed_extensions = harvester.extensions installed_apps = harvester.apps + for installed_app in installed_apps: - # Query to see if installed app is in the database - db_apps = TethysApp.objects. \ - filter(package__exact=installed_app.package). \ - all() - - # If the app is not in the database, then add it - if len(db_apps) == 0: - app = TethysApp( - name=installed_app.name, - package=installed_app.package, - description=installed_app.description, - enable_feedback=installed_app.enable_feedback, - feedback_emails=installed_app.feedback_emails, - index=installed_app.index, - icon=installed_app.icon, - root_url=installed_app.root_url, - color=installed_app.color, - tags=installed_app.tags - ) - app.save() - - # custom settings - app.add_settings(installed_app.custom_settings()) - # dataset services settings - app.add_settings(installed_app.dataset_service_settings()) - # spatial dataset services settings - app.add_settings(installed_app.spatial_dataset_service_settings()) - # wps settings - app.add_settings(installed_app.web_processing_service_settings()) - # persistent store settings - app.add_settings(installed_app.persistent_store_settings()) - - app.save() - - # If the app is in the database, update developer-first attributes - elif len(db_apps) == 1: - db_app = db_apps[0] - db_app.index = installed_app.index - db_app.icon = installed_app.icon - db_app.root_url = installed_app.root_url - db_app.color = installed_app.color - db_app.save() - - if hasattr(settings, 'DEBUG') and settings.DEBUG: - db_app.name = installed_app.name - db_app.description = installed_app.description - db_app.tags = installed_app.tags - db_app.enable_feedback = installed_app.enable_feedback - db_app.feedback_emails = installed_app.feedback_emails - db_app.save() - - # custom settings - db_app.add_settings(installed_app.custom_settings()) - # dataset services settings - db_app.add_settings(installed_app.dataset_service_settings()) - # spatial dataset services settings - db_app.add_settings(installed_app.spatial_dataset_service_settings()) - # wps settings - db_app.add_settings(installed_app.web_processing_service_settings()) - # persistent store settings - db_app.add_settings(installed_app.persistent_store_settings()) - db_app.save() - - # More than one instance of the app in db... (what to do here?) - elif len(db_apps) >= 2: - continue + # map extension to db + map_app_to_db(installed_app) + + for installed_extension in installed_extensions: + # map extension to db + map_extension_to_db(installed_extension) except Exception as e: log.error(e) +def map_app_to_db(installed_app): + """ + Sync installed apps with database. + """ + from django.conf import settings + + # Query to see if installed app is in the database + db_apps = TethysApp.objects. \ + filter(package__exact=installed_app.package). \ + all() + + # If the app is not in the database, then add it + if len(db_apps) == 0: + app = TethysApp( + name=installed_app.name, + package=installed_app.package, + description=installed_app.description, + enable_feedback=installed_app.enable_feedback, + feedback_emails=installed_app.feedback_emails, + index=installed_app.index, + icon=installed_app.icon, + root_url=installed_app.root_url, + color=installed_app.color, + tags=installed_app.tags + ) + app.save() + + # custom settings + app.add_settings(installed_app.custom_settings()) + # dataset services settings + app.add_settings(installed_app.dataset_service_settings()) + # spatial dataset services settings + app.add_settings(installed_app.spatial_dataset_service_settings()) + # wps settings + app.add_settings(installed_app.web_processing_service_settings()) + # persistent store settings + app.add_settings(installed_app.persistent_store_settings()) + + app.save() + + # If the app is in the database, update developer-first attributes + elif len(db_apps) == 1: + db_app = db_apps[0] + db_app.index = installed_app.index + db_app.icon = installed_app.icon + db_app.root_url = installed_app.root_url + db_app.color = installed_app.color + db_app.save() + + if hasattr(settings, 'DEBUG') and settings.DEBUG: + db_app.name = installed_app.name + db_app.description = installed_app.description + db_app.tags = installed_app.tags + db_app.enable_feedback = installed_app.enable_feedback + db_app.feedback_emails = installed_app.feedback_emails + db_app.save() + + # custom settings + db_app.add_settings(installed_app.custom_settings()) + # dataset services settings + db_app.add_settings(installed_app.dataset_service_settings()) + # spatial dataset services settings + db_app.add_settings(installed_app.spatial_dataset_service_settings()) + # wps settings + db_app.add_settings(installed_app.web_processing_service_settings()) + # persistent store settings + db_app.add_settings(installed_app.persistent_store_settings()) + db_app.save() + + +def map_extension_to_db(installed_extension): + """ + A function to map extension to the db + + Args: + installed_extension(TethysExtension): extension to be mapped to db + """ + from django.conf import settings + + # Query to see if installed extension is in the database + db_extensions = TethysExtension.objects. \ + filter(package__exact=installed_extension.package). \ + all() + + # If the extension is not in the database, then add it + if len(db_extensions) == 0: + extension = TethysExtension( + name=installed_extension.name, + package=installed_extension.package, + description=installed_extension.description, + root_url=installed_extension.root_url, + ) + extension.save() + + # If the extension is in the database, update developer-first attributes + elif len(db_extensions) == 1: + db_extension = db_extensions[0] + db_extension.root_url = installed_extension.root_url + db_extension.save() + + if hasattr(settings, 'DEBUG') and settings.DEBUG: + db_extension.name = installed_extension.name + db_extension.description = installed_extension.description + db_extension.save() + + def get_active_app(request=None, url=None): """ Get the active TethysApp object based on the request or URL. From 35b769ef486a425e13b5d2741a3e801ccd263c0c Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 21 Feb 2018 10:40:57 -0700 Subject: [PATCH 131/215] Load url maps from extensions --- tethys_apps/urls.py | 4 ++-- tethys_apps/utilities.py | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/tethys_apps/urls.py b/tethys_apps/urls.py index bab462669..66ab778a0 100644 --- a/tethys_apps/urls.py +++ b/tethys_apps/urls.py @@ -10,7 +10,7 @@ from django.db.utils import ProgrammingError from django.core.exceptions import ObjectDoesNotExist from django.conf.urls import url, include -from tethys_apps.utilities import generate_app_url_patterns, sync_tethys_db, register_app_permissions +from tethys_apps.utilities import generate_url_patterns, sync_tethys_db, register_app_permissions from tethys_apps.views import library, send_beta_feedback_email from tethys_apps import tethys_log @@ -23,7 +23,7 @@ ] # Append the app urls urlpatterns -app_url_patterns = generate_app_url_patterns() +app_url_patterns = generate_url_patterns() for namespace, urls in app_url_patterns.items(): root_pattern = r'^{0}/'.format(namespace.replace('_', '-')) diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index a50a72fdb..12d2ce10a 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -156,31 +156,31 @@ def register_app_permissions(): assign_perm(p, g, db_app) -def generate_app_url_patterns(): +def generate_url_patterns(): """ Generate the url pattern lists for each app and namespace them accordingly. """ # Get controllers list from app harvester harvester = SingletonHarvester() - apps = harvester.apps - app_url_patterns = dict() - - for app in apps: - if hasattr(app, 'url_maps'): - url_maps = app.url_maps() - elif hasattr(app, 'controllers'): - url_maps = app.controllers() + apps_and_extensions = harvester.apps + harvester.extensions + url_patterns = dict() + + for app_or_extension in apps_and_extensions: + if hasattr(app_or_extension, 'url_maps'): + url_maps = app_or_extension.url_maps() + elif hasattr(app_or_extension, 'controllers'): + url_maps = app_or_extension.controllers() else: url_maps = None if url_maps: for url_map in url_maps: - app_root = app.root_url - app_namespace = app_root.replace('-', '_') + root_url = app_or_extension.root_url + namespace = root_url.replace('-', '_') - if app_namespace not in app_url_patterns: - app_url_patterns[app_namespace] = [] + if namespace not in url_patterns: + url_patterns[namespace] = [] # Create django url object if isinstance(url_map.controller, basestring): @@ -206,9 +206,9 @@ def generate_app_url_patterns(): django_url = url(url_map.url, controller_function, name=url_map.name) # Append to namespace list - app_url_patterns[app_namespace].append(django_url) + url_patterns[namespace].append(django_url) - return app_url_patterns + return url_patterns def get_directories_in_tethys_apps(directory_names, with_app_name=False): From 46b1549678fb5d5feccfb6f400a011a60120dca3 Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 21 Feb 2018 14:41:08 -0700 Subject: [PATCH 132/215] Load templates for extensions. --- tethys_apps/template_loaders.py | 6 ++--- tethys_apps/utilities.py | 43 +++++++++++++++++++-------------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/tethys_apps/template_loaders.py b/tethys_apps/template_loaders.py index 2b00caa0a..687afb482 100644 --- a/tethys_apps/template_loaders.py +++ b/tethys_apps/template_loaders.py @@ -15,7 +15,7 @@ from django.template.loaders.base import Loader as BaseLoader from django.utils._os import safe_join -from tethys_apps.utilities import get_directories_in_tethys_apps +from tethys_apps.utilities import get_directories_in_tethys class TethysAppsTemplateLoader(BaseLoader): @@ -42,7 +42,7 @@ def get_template_sources(self, template_name, template_dirs=None): one of the template_dirs it is excluded from the result set. """ if not template_dirs: - template_dirs = get_directories_in_tethys_apps(('templates',)) + template_dirs = get_directories_in_tethys(('templates',)) for template_dir in template_dirs: try: name = safe_join(template_dir, template_name) @@ -63,7 +63,7 @@ def get_template_sources(self, template_name, template_dirs=None): # Custom Django template loader for tethys apps # """ # # Search for the template in the list of template directories -# tethysapp_template_dirs = get_directories_in_tethys_apps(('templates',)) +# tethysapp_template_dirs = get_directories_in_tethys(('templates',)) # # template = None # diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 12d2ce10a..ac361f73c 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -211,30 +211,37 @@ def generate_url_patterns(): return url_patterns -def get_directories_in_tethys_apps(directory_names, with_app_name=False): +def get_directories_in_tethys(directory_names, with_app_name=False): # Determine the tethysapp directory tethysapp_dir = safe_join(os.path.abspath(os.path.dirname(__file__)), 'tethysapp') + tethys_dirs = [tethysapp_dir] + try: + import tethysext + tethys_dirs.append(os.path.abspath(os.path.dirname(tethysext.__file__))) + except ImportError: + pass - # Assemble a list of tethysapp directories - tethysapp_contents = os.listdir(tethysapp_dir) - tethysapp_match_dirs = [] + match_dirs = [] + for tethys_dir in tethys_dirs: + # Assemble a list of tethysapp directories + tethysdir_contents = os.listdir(tethys_dir) - for item in tethysapp_contents: - item_path = safe_join(tethysapp_dir, item) + for item in tethysdir_contents: + item_path = safe_join(tethys_dir, item) - # Check each directory combination - for directory_name in directory_names: - # Only check directories - if os.path.isdir(item_path): - match_dir = safe_join(item_path, directory_name) + # Check each directory combination + for directory_name in directory_names: + # Only check directories + if os.path.isdir(item_path): + match_dir = safe_join(item_path, directory_name) - if match_dir not in tethysapp_match_dirs and os.path.isdir(match_dir): - if not with_app_name: - tethysapp_match_dirs.append(match_dir) - else: - tethysapp_match_dirs.append((item, match_dir)) + if match_dir not in match_dirs and os.path.isdir(match_dir): + if not with_app_name: + match_dirs.append(match_dir) + else: + match_dirs.append((item, match_dir)) - return tethysapp_match_dirs + return match_dirs class TethysAppsStaticFinder(BaseFinder): @@ -245,7 +252,7 @@ class TethysAppsStaticFinder(BaseFinder): def __init__(self, apps=None, *args, **kwargs): # List of locations with static files - self.locations = get_directories_in_tethys_apps(('static', 'public'), with_app_name=True) + self.locations = get_directories_in_tethys(('static', 'public'), with_app_name=True) # Maps dir paths to an appropriate storage instance self.storages = SortedDict() From 1623c92c8d190bc2f45eac295b4b40a5acaee2a2 Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 22 Feb 2018 09:40:36 -0700 Subject: [PATCH 133/215] Template and static finders work with extensions installed with "develop" and "install" commands. --- tethys_apps/app_installation.py | 8 ++ tethys_apps/cli/gen_templates/settings | 4 +- .../extension_templates/default/setup.py_tmpl | 10 +- tethys_apps/harvester.py | 14 ++- tethys_apps/static_finders.py | 73 +++++++++++ tethys_apps/template_loaders.py | 29 +---- tethys_apps/utilities.py | 117 +++++------------- 7 files changed, 137 insertions(+), 118 deletions(-) create mode 100644 tethys_apps/static_finders.py diff --git a/tethys_apps/app_installation.py b/tethys_apps/app_installation.py index 6c40f1551..781ee988f 100644 --- a/tethys_apps/app_installation.py +++ b/tethys_apps/app_installation.py @@ -16,6 +16,14 @@ import ctypes +def find_resource_files(directory): + paths = [] + for (path, directories, filenames) in os.walk(directory): + for filename in filenames: + paths.append(os.path.join('..', path, filename)) + return paths + + def get_tethysapp_directory(): """ Return the absolute path to the tethysapp directory. diff --git a/tethys_apps/cli/gen_templates/settings b/tethys_apps/cli/gen_templates/settings index dacdadca8..922490429 100644 --- a/tethys_apps/cli/gen_templates/settings +++ b/tethys_apps/cli/gen_templates/settings @@ -199,7 +199,7 @@ TEMPLATES = [ 'loaders': [ 'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', - 'tethys_apps.template_loaders.TethysAppsTemplateLoader' + 'tethys_apps.template_loaders.TethysTemplateLoader' ], 'debug': DEBUG } @@ -216,7 +216,7 @@ STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'), ) STATICFILES_FINDERS = ( 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', - 'tethys_apps.utilities.TethysAppsStaticFinder' + 'tethys_apps.static_finders.TethysStaticFinder' ) # Uncomment the next line for production installation diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/setup.py_tmpl b/tethys_apps/cli/scaffold_templates/extension_templates/default/setup.py_tmpl index 1d52f6342..1095ef23f 100644 --- a/tethys_apps/cli/scaffold_templates/extension_templates/default/setup.py_tmpl +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/setup.py_tmpl @@ -1,5 +1,6 @@ import os from setuptools import setup, find_packages +from tethys_apps.app_installation import find_resource_files # -- Extension Definition -- # ext_package = '{{project}}' @@ -10,6 +11,10 @@ ext_package_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'teth # -- Python Dependencies -- # dependencies = [] +# -- Get Resource File -- # +resource_files = find_resource_files('tethysext/' + ext_package + '/templates') +resource_files += find_resource_files('tethysext/' + ext_package + '/public') + setup( name=release_package, version='0.0.0', @@ -20,7 +25,10 @@ setup( author_email='{{author_email|default:''}}', url='', license='{{license_name|default:''}}', - packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), + packages=find_packages( + exclude=['ez_setup', 'examples', 'tethysext/' + ext_package + '/tests', 'tethysext/' + ext_package + '/tests.*'] + ), + package_data={'': resource_files}, namespace_packages=['tethysext', 'tethysext.' + ext_package], include_package_data=True, zip_safe=False, diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index 943145df9..e3769bd85 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -13,6 +13,7 @@ import os import inspect +import pkgutil from tethys_apps.base import TethysAppBase, TethysExtensionBase from .terminal_colors import TerminalColors @@ -23,6 +24,7 @@ class SingletonHarvester(object): Collects information for initiating apps """ extensions = [] + extension_modules = [] apps = [] _instance = None @@ -32,12 +34,13 @@ def harvest_extensions(self): """ Searches for and loads Tethys extensions. """ - print(TerminalColors.BLUE + 'Loading Tethys Extensions...' + TerminalColors.ENDC) try: import tethysext - tethys_extensions = {module_name: module_obj.__name__ - for module_name, module_obj in tethysext.__dict__.items() - if module_name not in self.IGNORE_MODULES} + tethys_extensions = dict() + for _, modname, ispkg in pkgutil.iter_modules(tethysext.__path__): + if ispkg: + tethys_extensions[modname] = 'tethysext.{}'.format(modname) + self._harvest_extension_instances(tethys_extensions) except: '''DO NOTHING''' @@ -105,6 +108,7 @@ def _harvest_extension_instances(self, extension_packages): extension_packages(dict): Dictionary where keys are the name of the extension and value is the extension package module object. """ valid_ext_instances = [] + valid_extension_modules = [] loaded_extensions = [] for extension_name, extension_package in extension_packages.items(): @@ -128,6 +132,7 @@ def _harvest_extension_instances(self, extension_packages): # compile valid apps if validated_ext_instance: valid_ext_instances.append(validated_ext_instance) + valid_extension_modules.append(extension_package) # Notify user that the app has been loaded loaded_extensions.append(extension_name) @@ -139,6 +144,7 @@ def _harvest_extension_instances(self, extension_packages): # Save valid apps self.extensions = valid_ext_instances + self.extension_modules = valid_extension_modules # Update user print('Tethys Extensions Loaded: {0}'.format(', '.join(loaded_extensions))) diff --git a/tethys_apps/static_finders.py b/tethys_apps/static_finders.py new file mode 100644 index 000000000..cd9b53a7c --- /dev/null +++ b/tethys_apps/static_finders.py @@ -0,0 +1,73 @@ +""" +******************************************************************************** +* Name: static_finders.py +* Author: nswain +* Created On: February 21, 2018 +* Copyright: +* License: +******************************************************************************** +""" +import os +from collections import OrderedDict as SortedDict +from django.contrib.staticfiles import utils +from django.contrib.staticfiles.finders import BaseFinder +from django.core.files.storage import FileSystemStorage +from django.utils._os import safe_join +from tethys_apps.utilities import get_directories_in_tethys + + +class TethysStaticFinder(BaseFinder): + """ + A static files finder that looks in each app in the tethysapp directory for static files. + This finder search for static files in a directory called 'public' or 'static'. + """ + + def __init__(self, apps=None, *args, **kwargs): + # List of locations with static files + self.locations = get_directories_in_tethys(('static', 'public'), with_app_name=True) + + # Maps dir paths to an appropriate storage instance + self.storages = SortedDict() + + for prefix, root in self.locations: + filesystem_storage = FileSystemStorage(location=root) + filesystem_storage.prefix = prefix + self.storages[root] = filesystem_storage + + super(TethysStaticFinder, self).__init__(*args, **kwargs) + + def find(self, path, all=False): + """ + Looks for files in the Tethys apps static or public directories + """ + matches = [] + for prefix, root in self.locations: + matched_path = self.find_location(root, path, prefix) + if matched_path: + if not all: + return matched_path + matches.append(matched_path) + return matches + + def find_location(self, root, path, prefix=None): + """ + Finds a requested static file in a location, returning the found + absolute path (or ``None`` if no match). + """ + if prefix: + prefix = '%s%s' % (prefix, os.sep) + if not path.startswith(prefix): + return None + path = path[len(prefix):] + path = safe_join(root, path) + if os.path.exists(path): + return path + + def list(self, ignore_patterns): + """ + List all files in all locations. + """ + for prefix, root in self.locations: + storage = self.storages[root] + for path in utils.get_files(storage, ignore_patterns): + yield path, storage \ No newline at end of file diff --git a/tethys_apps/template_loaders.py b/tethys_apps/template_loaders.py index 687afb482..17d2ecceb 100644 --- a/tethys_apps/template_loaders.py +++ b/tethys_apps/template_loaders.py @@ -3,7 +3,7 @@ * Name: template_loaders.py * Author: swainn * Created On: December 14, 2015 -* Copyright: (c) Aquaveo 2015 +* Copyright: * License: ******************************************************************************** """ @@ -18,7 +18,7 @@ from tethys_apps.utilities import get_directories_in_tethys -class TethysAppsTemplateLoader(BaseLoader): +class TethysTemplateLoader(BaseLoader): """ Custom Django template loader for tethys apps """ @@ -56,28 +56,3 @@ def get_template_sources(self, template_name, template_dirs=None): template_name=template_name, loader=self, ) - -# -# def tethys_apps_template_loader(template_name, template_dirs=None): -# """ -# Custom Django template loader for tethys apps -# """ -# # Search for the template in the list of template directories -# tethysapp_template_dirs = get_directories_in_tethys(('templates',)) -# -# template = None -# -# for template_dir in tethysapp_template_dirs: -# template_path = safe_join(template_dir, template_name) -# -# try: -# template = open(template_path).read(), template_name -# break -# except IOError: -# pass -# -# # If the template is still None, raise the exception -# if not template: -# raise TemplateDoesNotExist(template_name) -# -# return template \ No newline at end of file diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index ac361f73c..4303d2f33 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -11,13 +11,9 @@ import os import sys import traceback -from collections import OrderedDict as SortedDict from django.conf.urls import url -from django.contrib.staticfiles import utils -from django.contrib.staticfiles.finders import BaseFinder from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned -from django.core.files.storage import FileSystemStorage from django.utils._os import safe_join from past.builtins import basestring from tethys_apps import tethys_log @@ -212,95 +208,48 @@ def generate_url_patterns(): def get_directories_in_tethys(directory_names, with_app_name=False): - # Determine the tethysapp directory + """ + # Locate given directories in tethys apps and extensions. + Args: + directory_names: directory to get path to. + with_app_name: inlcud the app name if True. + + Returns: + list: list of paths to directories in apps and extensions. + """ + # Determine the directories of tethys apps directory tethysapp_dir = safe_join(os.path.abspath(os.path.dirname(__file__)), 'tethysapp') - tethys_dirs = [tethysapp_dir] - try: - import tethysext - tethys_dirs.append(os.path.abspath(os.path.dirname(tethysext.__file__))) - except ImportError: - pass + tethysapp_contents = os.walk(tethysapp_dir).next()[1] + potential_dirs = [safe_join(tethysapp_dir, item) for item in tethysapp_contents] - match_dirs = [] - for tethys_dir in tethys_dirs: - # Assemble a list of tethysapp directories - tethysdir_contents = os.listdir(tethys_dir) - for item in tethysdir_contents: - item_path = safe_join(tethys_dir, item) + # Determine the directories of tethys extensions + harvester = SingletonHarvester() - # Check each directory combination - for directory_name in directory_names: - # Only check directories - if os.path.isdir(item_path): - match_dir = safe_join(item_path, directory_name) + for extension_module in harvester.extension_modules: + try: + extension_module = __import__(extension_module, fromlist=['']) + potential_dirs.append(extension_module.__path__[0]) + except (ImportError, AttributeError, IndexError): + pass - if match_dir not in match_dirs and os.path.isdir(match_dir): - if not with_app_name: - match_dirs.append(match_dir) - else: - match_dirs.append((item, match_dir)) + # Check each directory combination + match_dirs = [] + for potential_dir in potential_dirs: + for directory_name in directory_names: + # Only check directories + if os.path.isdir(potential_dir): + match_dir = safe_join(potential_dir, directory_name) + + if match_dir not in match_dirs and os.path.isdir(match_dir): + if not with_app_name: + match_dirs.append(match_dir) + else: + match_dirs.append((os.path.basename(potential_dir), match_dir)) return match_dirs -class TethysAppsStaticFinder(BaseFinder): - """ - A static files finder that looks in each app in the tethysapp directory for static files. - This finder search for static files in a directory called 'public' or 'static'. - """ - - def __init__(self, apps=None, *args, **kwargs): - # List of locations with static files - self.locations = get_directories_in_tethys(('static', 'public'), with_app_name=True) - - # Maps dir paths to an appropriate storage instance - self.storages = SortedDict() - - for prefix, root in self.locations: - filesystem_storage = FileSystemStorage(location=root) - filesystem_storage.prefix = prefix - self.storages[root] = filesystem_storage - - super(TethysAppsStaticFinder, self).__init__(*args, **kwargs) - - def find(self, path, all=False): - """ - Looks for files in the Tethys apps static or public directories - """ - matches = [] - for prefix, root in self.locations: - matched_path = self.find_location(root, path, prefix) - if matched_path: - if not all: - return matched_path - matches.append(matched_path) - return matches - - def find_location(self, root, path, prefix=None): - """ - Finds a requested static file in a location, returning the found - absolute path (or ``None`` if no match). - """ - if prefix: - prefix = '%s%s' % (prefix, os.sep) - if not path.startswith(prefix): - return None - path = path[len(prefix):] - path = safe_join(root, path) - if os.path.exists(path): - return path - - def list(self, ignore_patterns): - """ - List all files in all locations. - """ - for prefix, root in self.locations: - storage = self.storages[root] - for path in utils.get_files(storage, ignore_patterns): - yield path, storage - - def sync_tethys_db(): """ Sync installed apps and extensions with database. From 8a07841d5a02dd082ebc63e0e1874bbba89617c9 Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 22 Feb 2018 10:57:47 -0700 Subject: [PATCH 134/215] Updated the uninstall and collectstatic commands to work with the new extensions. --- tethys_apps/cli/__init__.py | 10 +++- tethys_apps/harvester.py | 7 +-- tethys_apps/helpers.py | 21 ++++++- .../management/commands/pre_collectstatic.py | 16 +++--- .../commands/tethys_app_uninstall.py | 56 +++++++++++-------- tethys_apps/utilities.py | 2 +- 6 files changed, 74 insertions(+), 38 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index b12eb6cc7..b1666c54c 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -44,8 +44,10 @@ def uninstall_command(args): """ # Get the path to manage.py manage_path = get_manage_path(args) - app_name = args.app - process = ['python', manage_path, 'tethys_app_uninstall', app_name] + item_name = args.app_or_extension + process = ['python', manage_path, 'tethys_app_uninstall', item_name] + if args.is_extension: + process.append('-e') try: subprocess.call(process) except KeyboardInterrupt: @@ -402,7 +404,9 @@ def tethys_command(): # Setup uninstall command uninstall_parser = subparsers.add_parser('uninstall', help='Uninstall an app.') - uninstall_parser.add_argument('app', help='Name of the app to uninstall.') + uninstall_parser.add_argument('app_or_extension', help='Name of the app or extension to uninstall.') + uninstall_parser.add_argument('-e', '--extension', dest='is_extension', default=False, action='store_true', + help='Flag to denote an extension is being uninstalled') uninstall_parser.set_defaults(func=uninstall_command) # Setup list command diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index e3769bd85..e1d5807bc 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -24,11 +24,10 @@ class SingletonHarvester(object): Collects information for initiating apps """ extensions = [] - extension_modules = [] + extension_modules = {} apps = [] _instance = None - IGNORE_MODULES = ['__builtins__', '__file__', 'pkg_resources', '__package__', '__path__', '__name__', '__doc__'] def harvest_extensions(self): """ @@ -108,7 +107,7 @@ def _harvest_extension_instances(self, extension_packages): extension_packages(dict): Dictionary where keys are the name of the extension and value is the extension package module object. """ valid_ext_instances = [] - valid_extension_modules = [] + valid_extension_modules = {} loaded_extensions = [] for extension_name, extension_package in extension_packages.items(): @@ -132,7 +131,7 @@ def _harvest_extension_instances(self, extension_packages): # compile valid apps if validated_ext_instance: valid_ext_instances.append(validated_ext_instance) - valid_extension_modules.append(extension_package) + valid_extension_modules[extension_name] = extension_package # Notify user that the app has been loaded loaded_extensions.append(extension_name) diff --git a/tethys_apps/helpers.py b/tethys_apps/helpers.py index ca95aa697..2dcf76903 100644 --- a/tethys_apps/helpers.py +++ b/tethys_apps/helpers.py @@ -8,6 +8,7 @@ ******************************************************************************** """ import os +from tethys_apps.harvester import SingletonHarvester def get_tethysapp_dir(): @@ -32,4 +33,22 @@ def get_installed_tethys_apps(): if os.path.isdir(item_path): tethys_apps[item] = item_path - return tethys_apps \ No newline at end of file + return tethys_apps + + +def get_installed_tethys_extensions(): + """ + Get a list of installed extensions + """ + harvester = SingletonHarvester() + install_extensions = harvester.extension_modules + extension_paths = {} + + for extension_name, extension_module in install_extensions.items(): + try: + extension = __import__(extension_module, fromlist=['']) + extension_paths[extension_name] = extension.__path__[0] + except (IndexError, ImportError): + '''DO NOTHING''' + + return extension_paths \ No newline at end of file diff --git a/tethys_apps/management/commands/pre_collectstatic.py b/tethys_apps/management/commands/pre_collectstatic.py index abf46fc13..df9e307d0 100644 --- a/tethys_apps/management/commands/pre_collectstatic.py +++ b/tethys_apps/management/commands/pre_collectstatic.py @@ -13,7 +13,7 @@ from django.core.management.base import BaseCommand from django.conf import settings -from tethys_apps.helpers import get_installed_tethys_apps +from tethys_apps.helpers import get_installed_tethys_apps, get_installed_tethys_extensions class Command(BaseCommand): @@ -35,16 +35,18 @@ def handle(self, *args, **options): static_root = settings.STATIC_ROOT # Get a list of installed apps - installed_apps = get_installed_tethys_apps() + installed_apps_and_extensions = get_installed_tethys_apps() + installed_apps_and_extensions.update(get_installed_tethys_extensions()) + # Provide feedback to user - print('INFO: Linking static and public directories of apps to "{0}".'.format(static_root)) + print('INFO: Linking static and public directories of apps and extensions to "{0}".'.format(static_root)) - for app, path in installed_apps.items(): + for item, path in installed_apps_and_extensions.items(): # Check for both variants of the static directory (public and static) public_path = os.path.join(path, 'public') static_path = os.path.join(path, 'static') - static_root_path = os.path.join(static_root, app) + static_root_path = os.path.join(static_root, item) # Clear out old symbolic links/directories if necessary try: @@ -61,9 +63,9 @@ def handle(self, *args, **options): # Create appropriate symbolic link if os.path.isdir(public_path): os.symlink(public_path, static_root_path) - print('INFO: Successfully linked public directory to STATIC_ROOT for app "{0}".'.format(app)) + print('INFO: Successfully linked public directory to STATIC_ROOT for app "{0}".'.format(item)) elif os.path.isdir(static_path): os.symlink(static_path, static_root_path) - print('INFO: Successfully linked static directory to STATIC_ROOT for app "{0}".'.format(app)) + print('INFO: Successfully linked static directory to STATIC_ROOT for app "{0}".'.format(item)) diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index d9b9b1451..32458d419 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -9,10 +9,11 @@ """ import os import shutil +import site import subprocess from django.core.management.base import BaseCommand -from tethys_apps.helpers import get_installed_tethys_apps +from tethys_apps.helpers import get_installed_tethys_apps, get_installed_tethys_extensions class Command(BaseCommand): @@ -20,58 +21,69 @@ class Command(BaseCommand): Command class that handles the uninstall command for uninstall Tethys apps. """ def add_arguments(self, parser): - parser.add_argument('app_name', nargs='+', type=str) + parser.add_argument('app_or_extension', nargs='+', type=str) + parser.add_argument('-e', '--extension', dest='is_extension', default=False, action='store_true') def handle(self, *args, **options): """ Remove the app from disk and in the database """ - PREFIX = 'tethysapp' - app_name = options['app_name'][0] - installed_apps = get_installed_tethys_apps() + app_or_extension = "App" if not options['is_extension'] else 'Extension' + PREFIX = 'tethysapp' if not options['is_extension'] else 'tethysext' + item_name = options['app_or_extension'][0] + installed_items = get_installed_tethys_apps() + installed_items.update(get_installed_tethys_extensions()) - if PREFIX in app_name: + if PREFIX in item_name: prefix_length = len(PREFIX) + 1 - app_name = app_name[prefix_length:] + item_name = item_name[prefix_length:] - if app_name not in installed_apps: - self.stdout.write('WARNING: App with name "{0}" cannot be uninstalled, because it is not installed.'.format(app_name)) + if item_name not in installed_items: + self.stdout.write('WARNING: {} with name "{}" cannot be uninstalled, because it is not installed.'.format(app_or_extension, item_name)) exit(0) - app_with_prefix = '{0}-{1}'.format(PREFIX, app_name) + item_with_prefix = '{0}-{1}'.format(PREFIX, item_name) # Confirm valid_inputs = ('y', 'n', 'yes', 'no') no_inputs = ('n', 'no') - overwrite_input = raw_input('Are you sure you want to uninstall "{0}"? (y/n): '.format(app_with_prefix)).lower() + overwrite_input = raw_input('Are you sure you want to uninstall "{0}"? (y/n): '.format(item_with_prefix)).lower() while overwrite_input not in valid_inputs: overwrite_input = raw_input('Invalid option. Are you sure you want to ' - 'uninstall "{0}"? (y/n): '.format(app_with_prefix)).lower() + 'uninstall "{0}"? (y/n): '.format(item_with_prefix)).lower() if overwrite_input in no_inputs: self.stdout.write('Uninstall cancelled by user.') exit(0) # Remove app from database - from tethys_apps.models import TethysApp - db_app = TethysApp.objects.get(package=app_name) + from tethys_apps.models import TethysApp, TethysExtension + TethysModel = TethysApp if not options['is_extension'] else TethysExtension + db_app = TethysModel.objects.get(package=item_name) db_app.delete() - try: - # Remove directory - shutil.rmtree(installed_apps[app_name]) - except OSError: - # Remove symbolic link - os.remove(installed_apps[app_name]) + if not options['is_extension']: + try: + # Remove directory + shutil.rmtree(installed_items[item_name]) + except OSError: + # Remove symbolic link + os.remove(installed_items[item_name]) # Uninstall using pip - process = ['pip', 'uninstall', '-y', '{0}-{1}'.format(PREFIX, app_name)] + process = ['pip', 'uninstall', '-y', '{0}-{1}'.format(PREFIX, item_name)] try: subprocess.Popen(process, stderr=subprocess.STDOUT, stdout=subprocess.PIPE).communicate()[0] except KeyboardInterrupt: pass - self.stdout.write('App "{0}" successfully uninstalled.'.format(app_with_prefix)) + for site_package in site.getsitepackages(): + try: + os.remove(os.path.join(site_package, "{}-{}-nspkg.pth".format(PREFIX, item_name))) + except: + continue + + self.stdout.write('{} "{}" successfully uninstalled.'.format(app_or_extension, item_with_prefix)) diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 4303d2f33..7a858fe5d 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -226,7 +226,7 @@ def get_directories_in_tethys(directory_names, with_app_name=False): # Determine the directories of tethys extensions harvester = SingletonHarvester() - for extension_module in harvester.extension_modules: + for _, extension_module in harvester.extension_modules.items(): try: extension_module = __import__(extension_module, fromlist=['']) potential_dirs.append(extension_module.__path__[0]) From 3c2297dd0d0b3e9f6a6722530c77039cc0592866 Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 22 Feb 2018 12:29:33 -0700 Subject: [PATCH 135/215] Renamed syncapp command to sync and generalized to sync extensions. Updated list command to list extensions and apps. Updated docs. --- docs/tethys_sdk/tethys_cli.rst | 20 ++++++++++------ tethys_apps/cli/__init__.py | 24 ++++++++++++------- tethys_apps/cli/manage_commands.py | 4 ++-- .../app_templates/default/__init__.py | 0 .../extension_templates/default/__init__.py | 0 5 files changed, 31 insertions(+), 17 deletions(-) delete mode 100644 tethys_apps/cli/scaffold_templates/app_templates/default/__init__.py delete mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/__init__.py diff --git a/docs/tethys_sdk/tethys_cli.rst b/docs/tethys_sdk/tethys_cli.rst index a15035fa4..3cfe1de0a 100644 --- a/docs/tethys_sdk/tethys_cli.rst +++ b/docs/tethys_sdk/tethys_cli.rst @@ -85,12 +85,12 @@ This command contains several subcommands that are used to help manage Tethys Pl **Arguments:** -* **subcommand**: The management command to run. Either "start", "syncdb", or "collectstatic". +* **subcommand**: The management command to run. * *start*: Start the Django development server. Wrapper for ``manage.py runserver``. * *syncdb*: Initialize the database during installation. Wrapper for ``manage.py syncdb``. - * *syncapps*: Sync installed apps with the TethysApp database. - * *collectstatic*: Link app static/public directories to STATIC_ROOT directory and then run Django's collectstatic command. Preprocessor and wrapper for ``manage.py collectstatic``. + * *sync*: Sync installed apps and extensions with the TethysApp database. + * *collectstatic*: Link app and extension static/public directories to STATIC_ROOT directory and then run Django's collectstatic command. Preprocessor and wrapper for ``manage.py collectstatic``. * *collectworkspaces*: Link app workspace directories to TETHYS_WORKSPACES_ROOT directory. * *collectall*: Convenience command for running both *collectstatic* and *collectworkspaces*. * *superuser*: Create a new superuser/website admin for your Tethys Portal. @@ -112,7 +112,7 @@ This command contains several subcommands that are used to help manage Tethys Pl $ tethys manage syncdb # Sync installed apps with the TethysApp database. - $ tethys manage syncapps + $ tethys manage sync # Collect static files $ tethys manage collectstatic @@ -172,7 +172,7 @@ Management command for Persistent Stores. To learn more about persistent stores list ---- -Use this command to list all installed apps. +Use this command to list all installed apps and extensions. **Examples:** @@ -183,11 +183,14 @@ Use this command to list all installed apps. uninstall --------------- -Use this command to uninstall apps. +Use this command to uninstall apps and extensions. **Arguments:** -* **app**: Name the app to uninstall. +* **name**: Name the app or extension to uninstall. + +**Optional Arguments:** +* **-e, --extension**: Flag used to indicate that the item being uninstalled is an extension. **Examples:** @@ -196,6 +199,9 @@ Use this command to uninstall apps. # Uninstall my_first_app $ tethys uninstall my_first_app + # Uninstall extension + $ tethys uninstall -e my_extension + .. _tethys_cli_docker: docker [options] diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index b1666c54c..fe9d3e5ef 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -21,7 +21,7 @@ from .gen_commands import GEN_SETTINGS_OPTION, GEN_APACHE_OPTION, generate_command from .manage_commands import (manage_command, get_manage_path, run_process, MANAGE_START, MANAGE_SYNCDB, - MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_SYNCAPPS, + MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_SYNC, MANAGE_COLLECT, MANAGE_CREATESUPERUSER, TETHYS_SRC_DIRECTORY) from .services_commands import (SERVICES_CREATE, SERVICES_CREATE_PERSISTENT, SERVICES_CREATE_SPATIAL, SERVICES_LINK, services_create_persistent_command, services_create_spatial_command, @@ -32,7 +32,7 @@ app_settings_remove_command) from .scheduler_commands import scheduler_create_command, schedulers_list_command, schedulers_remove_command from .gen_commands import VALID_GEN_OBJECTS, generate_command -from tethys_apps.helpers import get_installed_tethys_apps +from tethys_apps.helpers import get_installed_tethys_apps, get_installed_tethys_extensions # Module level variables PREFIX = 'tethysapp' @@ -54,14 +54,22 @@ def uninstall_command(args): pass -def list_apps_command(args): +def list_command(args): """ List installed apps. """ installed_apps = get_installed_tethys_apps() + installed_extensions = get_installed_tethys_extensions() - for app in installed_apps: - print(app) + if installed_apps: + print('Apps:') + for item in installed_apps: + print(' {}'.format(item)) + + if installed_extensions: + print('Extensions:') + for item in installed_extensions: + print(' {}'.format(item)) def docker_command(args): @@ -236,7 +244,7 @@ def tethys_command(): manage_parser = subparsers.add_parser('manage', help='Management commands for Tethys Platform.') manage_parser.add_argument('command', help='Management command to run.', choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, - MANAGE_COLLECT, MANAGE_CREATESUPERUSER, MANAGE_SYNCAPPS]) + MANAGE_COLLECT, MANAGE_CREATESUPERUSER, MANAGE_SYNC]) manage_parser.add_argument('-m', '--manage', help='Absolute path to manage.py for Tethys Platform installation.') manage_parser.add_argument('-p', '--port', type=str, help='Host and/or port on which to bind the development server.') manage_parser.add_argument('--noinput', action='store_true', help='Pass the --noinput argument to the manage.py command.') @@ -410,8 +418,8 @@ def tethys_command(): uninstall_parser.set_defaults(func=uninstall_command) # Setup list command - list_parser = subparsers.add_parser('list', help='List installed apps.') - list_parser.set_defaults(func=list_apps_command) + list_parser = subparsers.add_parser('list', help='List installed apps and extensions.') + list_parser.set_defaults(func=list_command) # Sync stores command syncstores_parser = subparsers.add_parser('syncstores', help='Management command for App Persistent Stores.') diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index ec05a3af3..e211853bf 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -23,7 +23,7 @@ MANAGE_COLLECTWORKSPACES = 'collectworkspaces' MANAGE_COLLECT = 'collectall' MANAGE_CREATESUPERUSER = 'createsuperuser' -MANAGE_SYNCAPPS = 'syncapps' +MANAGE_SYNC = 'sync' def get_manage_path(args): @@ -103,7 +103,7 @@ def manage_command(args): elif args.command == MANAGE_CREATESUPERUSER: primary_process = ['python', manage_path, 'createsuperuser'] - elif args.command == MANAGE_SYNCAPPS: + elif args.command == MANAGE_SYNC: from tethys_apps.utilities import sync_tethys_db sync_tethys_db() diff --git a/tethys_apps/cli/scaffold_templates/app_templates/default/__init__.py b/tethys_apps/cli/scaffold_templates/app_templates/default/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/__init__.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/__init__.py deleted file mode 100644 index e69de29bb..000000000 From 5b495b5e7c27d91f118188b65fbd01412e49ef61 Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 22 Feb 2018 14:47:50 -0700 Subject: [PATCH 136/215] Added support for gizmos in extensions. --- .../+project+/.gitadd => gizmos/__init__.py} | 0 .../+project+/templates/+project+/.gitkeep | 0 .../+project+/templates/gizmos/.gitkeep | 0 tethys_gizmos/templatetags/tethys_gizmos.py | 42 +++++++++++++++---- tethys_sdk/gizmos.py | 3 +- 5 files changed, 37 insertions(+), 8 deletions(-) rename tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/{templates/+project+/.gitadd => gizmos/__init__.py} (100%) create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/+project+/.gitkeep create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/gizmos/.gitkeep diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/+project+/.gitadd b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/gizmos/__init__.py similarity index 100% rename from tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/+project+/.gitadd rename to tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/gizmos/__init__.py diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/+project+/.gitkeep b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/+project+/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/gizmos/.gitkeep b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/templates/gizmos/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_gizmos/templatetags/tethys_gizmos.py b/tethys_gizmos/templatetags/tethys_gizmos.py index 1a0f58a5b..9deb964ad 100644 --- a/tethys_gizmos/templatetags/tethys_gizmos.py +++ b/tethys_gizmos/templatetags/tethys_gizmos.py @@ -10,6 +10,7 @@ import os import json import time +import inspect from datetime import datetime from django.conf import settings from django import template @@ -19,16 +20,36 @@ from django.core.serializers.json import DjangoJSONEncoder from plotly.offline.offline import get_plotlyjs +from tethys_apps.harvester import SingletonHarvester from ..gizmo_options.base import TethysGizmoOptions import tethys_sdk.gizmos -# MAP THE GIZMO NAME TO GIZMO OBJECT +GIZMO_NAME_PROPERTY = 'gizmo_name' GIZMO_NAME_MAP = {} +EXTENSION_PATH_MAP = {} + +# Add gizmos to GIZMO_NAME_MAP for name, cls in tethys_sdk.gizmos.__dict__.items(): - if(hasattr(cls, "gizmo_name")): + if inspect.isclass(cls) and issubclass(cls, TethysGizmoOptions) and hasattr(cls, GIZMO_NAME_PROPERTY): GIZMO_NAME_MAP[cls.gizmo_name] = cls + +# Add extension gizmos to the GIZMO_NAME_MAP +harvester = SingletonHarvester() +extension_modules = harvester.extension_modules + +for module_name, extension_module in extension_modules.items(): + try: + gizmo_module = __import__('{}.gizmos'.format(extension_module), fromlist=['']) + for name, cls in gizmo_module.__dict__.items(): + if inspect.isclass(cls) and issubclass(cls, TethysGizmoOptions) and hasattr(cls, GIZMO_NAME_PROPERTY): + GIZMO_NAME_MAP[cls.gizmo_name] = cls + gizmo_module_path = gizmo_module.__path__[0] + EXTENSION_PATH_MAP[cls.gizmo_name] = os.path.abspath(os.path.dirname(gizmo_module_path)) + except ImportError: + continue + register = template.Library() CSS_OUTPUT_TYPE = 'css' @@ -161,18 +182,25 @@ def render(self, context): try: if self.gizmo_name is None or self.gizmo_name not in GIZMO_NAME_MAP: - if hasattr(resolved_options, "gizmo_name"): + if hasattr(resolved_options, GIZMO_NAME_PROPERTY): self._load_gizmo_name(resolved_options.gizmo_name) else: raise TemplateSyntaxError('A valid gizmo name is required for this input format.') self._load_gizmos_rendered(context) - # Determine path to gizmo template - gizmo_templates_root = os.path.join('tethys_gizmos', 'gizmos') + + # Derive path to gizmo template + if self.gizmo_name not in EXTENSION_PATH_MAP: + # Determine path to gizmo template + gizmo_templates_root = os.path.join('tethys_gizmos', 'gizmos') + + else: + gizmo_templates_root = os.path.join(EXTENSION_PATH_MAP[self.gizmo_name], 'templates', 'gizmos') + gizmo_file_name = '{0}.html'.format(self.gizmo_name) template_name = os.path.join(gizmo_templates_root, gizmo_file_name) - # reset gizmo_name incase Node is rendered with different options + # reset gizmo_name in case Node is rendered with different options self._load_gizmo_name(None) # Retrieve the gizmo template and render @@ -307,7 +335,7 @@ def render(self, context): for dict_element in context: for key in dict_element: resolved_options = template.Variable(key).resolve(context) - if hasattr(resolved_options, "gizmo_name"): + if hasattr(resolved_options, GIZMO_NAME_PROPERTY): if resolved_options.gizmo_name not in context['gizmos_rendered']: context['gizmos_rendered'].append(resolved_options.gizmo_name) diff --git a/tethys_sdk/gizmos.py b/tethys_sdk/gizmos.py index 58357f139..e10e877f2 100644 --- a/tethys_sdk/gizmos.py +++ b/tethys_sdk/gizmos.py @@ -8,4 +8,5 @@ ******************************************************************************** """ # DO NOT ERASE -from tethys_gizmos.gizmo_options import * \ No newline at end of file +from tethys_gizmos.gizmo_options import * +from tethys_gizmos.gizmo_options.base import TethysGizmoOptions, SecondaryGizmoOptions \ No newline at end of file From 171ba59bef60035c0e41c202d137ac492550a52a Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 22 Feb 2018 16:05:59 -0700 Subject: [PATCH 137/215] Added documentation for Tethys Extensions. --- docs/tethys_sdk.rst | 8 +- docs/tethys_sdk/extensions.rst | 17 ++ docs/tethys_sdk/extensions/custom_gizmos.rst | 5 + docs/tethys_sdk/extensions/models.rst | 5 + docs/tethys_sdk/extensions/scaffold.rst | 50 ++++++ docs/tethys_sdk/extensions/structure.rst | 37 ++++ .../extensions/templates_and_static_files.rst | 5 + docs/tethys_sdk/extensions/url_maps.rst | 5 + docs/tethys_sdk/tethys_cli.rst | 8 +- .../default/tethysext/+project+/api.py | 19 -- .../default/tethysext/+project+/handoff.py | 3 - .../tethysext/+project+/tests/__init__.py | 0 .../tethysext/+project+/tests/tests.py_tmpl | 166 ------------------ tethys_apps/harvester.py | 1 + 14 files changed, 132 insertions(+), 197 deletions(-) create mode 100644 docs/tethys_sdk/extensions.rst create mode 100644 docs/tethys_sdk/extensions/custom_gizmos.rst create mode 100644 docs/tethys_sdk/extensions/models.rst create mode 100644 docs/tethys_sdk/extensions/scaffold.rst create mode 100644 docs/tethys_sdk/extensions/structure.rst create mode 100644 docs/tethys_sdk/extensions/templates_and_static_files.rst create mode 100644 docs/tethys_sdk/extensions/url_maps.rst delete mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/api.py delete mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/handoff.py delete mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/__init__.py delete mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl diff --git a/docs/tethys_sdk.rst b/docs/tethys_sdk.rst index c07f2f064..ed6618ec3 100644 --- a/docs/tethys_sdk.rst +++ b/docs/tethys_sdk.rst @@ -2,12 +2,9 @@ Software Development Kit ************************ -**Last Updated:** May 2017 +**Last Updated:** February 22, 2018 -The Tethys Platform provides a Python Software Development Kit (SDK) to make it easier to incorporate the functionality -of the various supporting software packages into apps. The SDK is includes an Application Programming Interface (API) -for each of the major software components of Tethys Platform. This section contains the documentation for each API that -is included in the SDK: +The Tethys Platform provides a Python Software Development Kit (SDK) to make it easier to incorporate the functionality of the various supported software packages into apps. The SDK is includes an Application Programming Interface (API) for each of the major software components of Tethys Platform. This section contains the documentation for each API that is included in the SDK: .. toctree:: :maxdepth: 2 @@ -25,3 +22,4 @@ is included in the SDK: tethys_sdk/permissions tethys_sdk/tethys_cli tethys_sdk/testing + tethys_sdk/extensions diff --git a/docs/tethys_sdk/extensions.rst b/docs/tethys_sdk/extensions.rst new file mode 100644 index 000000000..a784dff29 --- /dev/null +++ b/docs/tethys_sdk/extensions.rst @@ -0,0 +1,17 @@ +***************** +Tethys Extensions +***************** + +**Last Updated:** February 22, 2018 + +Tethys Extensions provide a way for app developers to extend Tethys Platform and modularize functionality that is used accross multiple apps. For example, models, templates and static resources that are used by multiple apps could be created in a Tethys Extension and imported/referenced in the apps that use that functionality. Developers can also create custom gizmos using Tethys Extensions. This section will provide an overview of how to develop Tethys Extensions and use them in apps. + +.. toctree:: + :maxdepth: 2 + + extensions/scaffold + extensions/structure + extensions/models + extensions/url_maps + extensions/templates_and_static_files + extensions/custom_gizmos diff --git a/docs/tethys_sdk/extensions/custom_gizmos.rst b/docs/tethys_sdk/extensions/custom_gizmos.rst new file mode 100644 index 000000000..cedc25768 --- /dev/null +++ b/docs/tethys_sdk/extensions/custom_gizmos.rst @@ -0,0 +1,5 @@ +************* +Custom Gizmos +************* + +**Last Updated:** February 22, 2018 \ No newline at end of file diff --git a/docs/tethys_sdk/extensions/models.rst b/docs/tethys_sdk/extensions/models.rst new file mode 100644 index 000000000..7dcc32db5 --- /dev/null +++ b/docs/tethys_sdk/extensions/models.rst @@ -0,0 +1,5 @@ +****** +Models +****** + +**Last Updated:** February 22, 2018 \ No newline at end of file diff --git a/docs/tethys_sdk/extensions/scaffold.rst b/docs/tethys_sdk/extensions/scaffold.rst new file mode 100644 index 000000000..2787698aa --- /dev/null +++ b/docs/tethys_sdk/extensions/scaffold.rst @@ -0,0 +1,50 @@ +************************* +Scaffold and Installation +************************* + +**Last Updated:** February 22, 2018 + +Scaffolding an Extension +------------------------ + +Scaffolding Tethys Extensions is done in the same way scaffolding of apps is performed. Just specify the extension option when scaffolding: + +:: + + $ tethys scaffold -e my_first_extension + +Installing an Extension +----------------------- + +This will create a new directory called ``tethysext-my_first_extension``. To install the extension for development into your Tethys Portal: + +:: + + $ cd tethysext-my_first_extension + $ python setup.py develop + +Alternatively, to install the extension on a production Tethys Portal: + +:: + + $ cd tethysext-my_first_extension + $ python setup.py install + +If the installation was successful, you should see something similar to this when Tethys Platform loads: + +:: + + Loading Tethys Extensions... + Tethys Extensions Loaded: my_first_extension + +You can also confirm the installation of an extension by navigating to the *Site Admin* page and selecting the ``Installed Extensions`` link under the ``Tethys Apps`` heading. + +Uninstalling an Extension +------------------------- + +An extension can be easily uninstalled using the ``uninstall`` command provided in the Tethys CLI: + +:: + + $ tethys uninstall -e my_first_extension + diff --git a/docs/tethys_sdk/extensions/structure.rst b/docs/tethys_sdk/extensions/structure.rst new file mode 100644 index 000000000..6159fa981 --- /dev/null +++ b/docs/tethys_sdk/extensions/structure.rst @@ -0,0 +1,37 @@ +************************ +Extension File Structure +************************ + +**Last Updated:** February 22, 2018 + +The Tethys Extension file structure mimics that of Tethys Apps. Like apps, extensions have the ``templates`` and ``public`` directories and the ``controllers.py`` and ``model.py`` modules. These modules and directories are used in the same way as they are in apps. + +There are some notable differences between apps and extensions, however. Rather than an ``app.py`` module, the configuration file for extensions is called ``ext.py``. Like the ``app.py``, the ``ext.py`` module contains a class that is used to configure the extension. Extensions also contain additional packages and directories such as the ``gizmos`` package and ``templates/gizmos`` directory. + +.. note:: + + Although extensions and apps are similar, extension classes do not support as many operations as the app classes. For example, you cannot specify any service settings (persistent store, spatial dataset, etc.) for extensions, nor can you perform the syncstores on an extension. The capabilities of extensions will certainly grow over time, but some limitations are deliberate. + +The sturcture of a freshly scaffolded extension should looks something like this: + +:: + + tethysext-my_first_extension/ + |-- tethysext/ + | |-- my_first_extension/ + | | |-- gizmos/ + | | |-- public/ + | | | |-- js/ + | | | | |-- main.js + | | | |-- css/ + | | | | |-- main.css + | | |-- templates/ + | | | |-- my_first_extension/ + | | | |-- gizmos/ + | | |-- __init__.py + | | |-- contollers.py + | | |-- ext.py + | | |-- model.py + | |-- __init__.py + |-- .gitignore + |-- setup.py \ No newline at end of file diff --git a/docs/tethys_sdk/extensions/templates_and_static_files.rst b/docs/tethys_sdk/extensions/templates_and_static_files.rst new file mode 100644 index 000000000..b566ffa6a --- /dev/null +++ b/docs/tethys_sdk/extensions/templates_and_static_files.rst @@ -0,0 +1,5 @@ +************************** +Templates and Static Files +************************** + +**Last Updated:** February 22, 2018 \ No newline at end of file diff --git a/docs/tethys_sdk/extensions/url_maps.rst b/docs/tethys_sdk/extensions/url_maps.rst new file mode 100644 index 000000000..e0b2402e8 --- /dev/null +++ b/docs/tethys_sdk/extensions/url_maps.rst @@ -0,0 +1,5 @@ +******* +UrlMaps +******* + +**Last Updated:** February 22, 2018 \ No newline at end of file diff --git a/docs/tethys_sdk/tethys_cli.rst b/docs/tethys_sdk/tethys_cli.rst index 3cfe1de0a..d821a386a 100644 --- a/docs/tethys_sdk/tethys_cli.rst +++ b/docs/tethys_sdk/tethys_cli.rst @@ -300,7 +300,7 @@ Management commands for running tests for Tethys Platform and Tethys Apps. See : .. _tethys_cli_app_settings: app_settings ---------------- +----------------------- This command is used to list the Persistent Store and Spatial Dataset Settings that an app has requested. @@ -322,7 +322,7 @@ This command is used to list the Persistent Store and Spatial Dataset Settings t .. _tethys_cli_services: services [ | options] ---------------- +------------------------------------------------- This command is used to interact with Tethys Services from the command line, rather than the App Admin interface. @@ -386,7 +386,7 @@ This command is used to interact with Tethys Services from the command line, rat .. _tethys_cli_link: link ---------------- +-------------------------------------------------- This command is used to link a Tethys Service with a TethysApp Setting @@ -411,7 +411,7 @@ This command is used to link a Tethys Service with a TethysApp Setting .. _tethys_cli_schedulers: schedulers ---------------- +----------------------- This command is used to interact with Schedulers from the command line, rather than through the App Admin interface diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/api.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/api.py deleted file mode 100644 index 252434b11..000000000 --- a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/api.py +++ /dev/null @@ -1,19 +0,0 @@ -# Define your REST API endpoints here. -# In the comments below is an example. -# For more information, see: -# http://docs.tethysplatform.org/en/dev/tethys_sdk/rest_api.html -""" -from django.http import JsonResponse -from rest_framework.authentication import TokenAuthentication -from rest_framework.decorators import api_view, authentication_classes - -@api_view(['GET']) -@authentication_classes((TokenAuthentication,)) -def get_data(request): - ''' - API Controller for getting data - ''' - name = request.GET.get('name') - data = {"name": name} - return JsonResponse(data) -""" diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/handoff.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/handoff.py deleted file mode 100644 index ac0fa7b1b..000000000 --- a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/handoff.py +++ /dev/null @@ -1,3 +0,0 @@ -# Define your handoff handlers here -# for more information, see: -# http://docs.tethysplatform.org/en/dev/tethys_sdk/handoff.html diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/__init__.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl deleted file mode 100644 index 97336a4c6..000000000 --- a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl +++ /dev/null @@ -1,166 +0,0 @@ -# Most of your test classes should inherit from TethysTestCase -from tethys_sdk.testing import TethysTestCase - -# Use if your app has persistent stores that will be tested against. -# Your app class from app.py must be passed as an argument to the TethysTestCase functions to both -# create and destroy the temporary persistent stores for your app used during testing -# from ..app import {{class_name}} - -# Use if you'd like a simplified way to test rendered HTML templates. -# You likely need to install BeautifulSoup, as it is not included by default in Tethys Platform -# 1. Open a terminal -# 2. Enter command ". /usr/lib/tethys/bin/activate" to activate the Tethys python environment -# 3. Enter command "pip install beautifulsoup4" -# For help, see https://www.crummy.com/software/BeautifulSoup/bs4/doc/ -# from bs4 import BeautifulSoup - -""" -To run any tests: - 1. Open a terminal - 2. Enter command ". /usr/lib/tethys/bin/activate" to activate the Tethys python environment - 3. In settings.py make sure that the tethys_default database user is set to tethys_super - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'tethys_default', - 'USER': 'tethys_super', - 'PASSWORD': 'pass', - 'HOST': '127.0.0.1', - 'PORT': '5435' - } - } - 4. Enter tethys test command. - The general form is: "tethys test -f tethys_apps.tethysext....." - See below for specific examples - - To run all tests across this app: - Test command: "tethys test -f tethys_apps.tethysext.{{project}}" - - To run all tests in this file: - Test command: "tethys test -f tethys_apps.tethysext.{{project}}.tests.tests" - - To run tests in the {{class_name}}TestCase class: - Test command: "tethys test -f tethys_apps.tethysext.{{project}}.tests.tests.{{class_name}}TestCase" - - To run only the test_if_tethys_platform_is_great function in the {{class_name}}TestCase class: - Test command: "tethys test -f tethys_apps.tethysext.{{project}}.tests.tests.{{class_name}}TestCase.test_if_tethys_platform_is_great" - -To learn more about writing tests, see: - https://docs.djangoproject.com/en/1.9/topics/testing/overview/#writing-tests - https://docs.python.org/2.7/library/unittest.html#module-unittest -""" - - -class {{class_name}}TestCase(TethysTestCase): - """ - In this class you may define as many functions as you'd like to test different aspects of your app. - Each function must start with the word "test" for it to be recognized and executed during testing. - You could also create multiple TethysTestCase classes within this or other python files to organize your tests. - """ - - def set_up(self): - """ - This function is not required, but can be used if any environmental setup needs to take place before - execution of each test function. Thus, if you have multiple test that require the same setup to run, - place that code here. For example, if you are testing against any persistent stores, you should call the - test database creation function here, like so: - - self.create_test_persistent_stores_for_app({{class_name}}) - - If you are testing against a controller that check for certain user info, you can create a fake test user and - get a test client, like so: - - #The test client simulates a browser that can navigate your app's url endpoints - self.c = self.get_test_client() - self.user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") - # To create a super_user, use "self.create_test_superuser(*params)" with the same params - - # To force a login for the test user - self.c.force_login(self.user) - - # If for some reason you do not want to force a login, you can use the following: - login_success = self.c.login(username="joe", password="secret") - - NOTE: You do not have place these functions here, but if they are not placed here and are needed - then they must be placed at the beginning of your individual test functions. Also, if a certain - setup does not apply to all of your functions, you should either place it directly in each - function it applies to, or maybe consider creating a new test file or test class to group similar - tests. - """ - pass - - def tear_down(self): - """ - This function is not required, but should be used if you need to tear down any environmental setup - that took place before execution of the test functions. If you are testing against any persistent - stores, you should call the test database destruction function from here, like so: - - self.destroy_test_persistent_stores_for_app({{class_name}}) - - NOTE: You do not have to set these functions up here, but if they are not placed here and are needed - then they must be placed at the very end of your individual test functions. Also, if certain - tearDown code does not apply to all of your functions, you should either place it directly in each - function it applies to, or maybe consider creating a new test file or test class to group similar - tests. - """ - pass - - def is_tethys_platform_great(self): - return True - - def test_if_tethys_platform_is_great(self): - """ - This is an example test function that can be modified to test a specific aspect of your app. - It is required that the function name begins with the word "test" or it will not be executed. - Generally, the code written here will consist of many assert methods. - A list of assert methods is included here for reference or to get you started: - assertEqual(a, b) a == b - assertNotEqual(a, b) a != b - assertTrue(x) bool(x) is True - assertFalse(x) bool(x) is False - assertIs(a, b) a is b - assertIsNot(a, b) a is not b - assertIsNone(x) x is None - assertIsNotNone(x) x is not None - assertIn(a, b) a in b - assertNotIn(a, b) a not in b - assertIsInstance(a, b) isinstance(a, b) - assertNotIsInstance(a, b) !isinstance(a, b) - Learn more about assert methods here: - https://docs.python.org/2.7/library/unittest.html#assert-methods - """ - - self.assertEqual(self.is_tethys_platform_great(), True) - self.assertNotEqual(self.is_tethys_platform_great(), False) - self.assertTrue(self.is_tethys_platform_great()) - self.assertFalse(not self.is_tethys_platform_great()) - self.assertIs(self.is_tethys_platform_great(), True) - self.assertIsNot(self.is_tethys_platform_great(), False) - - def test_home_controller(self): - """ - This is an example test function of how you might test a controller that returns an HTML template rendered - with context variables. - """ - - # If all test functions were testing controllers or required a test client for another reason, the following - # 3 lines of code could be placed once in the set_up function. Note that in that case, each variable should be - # prepended with "self." (i.e. self.c = ...) to make those variables "global" to this test class and able to be - # used in each separate test function. - c = self.get_test_client() - user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") - c.force_login(user) - - # Have the test client "browse" to your home page - response = c.get('/apps/{{project_url}}/') # The final '/' is essential for all pages/controllers - - # Test that the request processed correctly (with a 200 status code) - self.assertEqual(response.status_code, 200) - - ''' - NOTE: Next, you would likely test that your context variables returned as expected. That would look - something like the following: - - context = response.context - self.assertEqual(context['my_integer'], 10) - ''' diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index e1d5807bc..076e20216 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -35,6 +35,7 @@ def harvest_extensions(self): """ try: import tethysext + print(TerminalColors.BLUE + 'Loading Tethys Extensions...' + TerminalColors.ENDC) tethys_extensions = dict() for _, modname, ispkg in pkgutil.iter_modules(tethysext.__path__): if ispkg: From 18b1f3f84e0fcec135214d2b325a52f45e983a12 Mon Sep 17 00:00:00 2001 From: nswain Date: Fri, 23 Feb 2018 13:09:47 -0700 Subject: [PATCH 138/215] Finished documentation for Tethys Extensions. --- docs/tethys_sdk/extensions.rst | 2 +- docs/tethys_sdk/extensions/custom_gizmos.rst | 262 +++++++++++++++++- docs/tethys_sdk/extensions/structure.rst | 1 + .../extensions/templates_and_static_files.rst | 25 +- 4 files changed, 287 insertions(+), 3 deletions(-) diff --git a/docs/tethys_sdk/extensions.rst b/docs/tethys_sdk/extensions.rst index a784dff29..37b08b03a 100644 --- a/docs/tethys_sdk/extensions.rst +++ b/docs/tethys_sdk/extensions.rst @@ -11,7 +11,7 @@ Tethys Extensions provide a way for app developers to extend Tethys Platform and extensions/scaffold extensions/structure + extensions/templates_and_static_files extensions/models extensions/url_maps - extensions/templates_and_static_files extensions/custom_gizmos diff --git a/docs/tethys_sdk/extensions/custom_gizmos.rst b/docs/tethys_sdk/extensions/custom_gizmos.rst index cedc25768..0051002a7 100644 --- a/docs/tethys_sdk/extensions/custom_gizmos.rst +++ b/docs/tethys_sdk/extensions/custom_gizmos.rst @@ -2,4 +2,264 @@ Custom Gizmos ************* -**Last Updated:** February 22, 2018 \ No newline at end of file +**Last Updated:** February 22, 2018 + +Tethys Extensions can be used to create custom gizmos, which can then be used by any app in portals where the extension is installed. This document will provide a brief overview of how to create a gizmo. + +Anatomy of a Gizmo +------------------ + +Gizmos are essentially mini-templates that can be embedded in other templates using the ``gizmo`` tag. They are composed of three primary components: + + #. Gizmo Options Class + #. Gizmo Template + #. JavaScript and CSS Dependencies + +Each component will be briefly introduced. To illustrate, we will show how a simplified version of the ``SelectInput`` gizmo could be implemented as a custom Gizmo in an extension. + +Gizmo Organization +------------------ + +The files used to define custom gizmos must be organized in a specific way in your app extension. Each gizmo options class must be located in its own python module and the file should be located in the ``gizmos`` package of your extension. The template for the gizmo must an HTML file located within the ``templates/gizmos/`` folder of your extension. + +Gizmo files must follow a specific naming convention: the python module containing the gizmo options class and the name of the gizmo template must have the same name as the gizmo. For example, if the name of the gizmo you are creating is ``custom_select_input`` then the name of the gizmo template would be ``custom_select_input.html`` and the name of the gizmo options module would be ``custom_select_input.py``. + +JavaScript and CSS dependencies should be stored in the ``public`` directory of your extension as usual or be publicly available from a CDN or similar. Dependencies stored locally can be organized however you prefer within the ``public`` directory. + +Finally, you must import the gizmo options class in the ``gizmos/__init__.py`` module. Only Gizmos imported here will be accessible. For the custom select input example, the file structure would look something like this: + +:: + + tethysext-my_first_extension/ + |-- tethysext/ + | |-- my_first_extension/ + | | |-- gizmos/ + | | | |-- custom_select_input.py + | | |-- public/ + | | | |-- gizmos/ + | | | | |-- custom_select_input/ + | | | | | |-- custom_select_input.css + | | | | | |-- custom_select_input.js + | | |-- templates/ + | | | |-- gizmos/ + | | | | |-- custom_select_input.html + +.. important:: + + Gizmo names must be globally unique within a portal. If a portal has two extensions that implement gizmos with the same name, they will conflict and likely not work properly. + + +Gizmo Options Class +------------------- + +A gizmo options class is a class that inherits from the ``TethysGizmoOptions`` base class. It can be thought of as the context for the gizmo template. Any property or attribute of the gizmo options class will be available as a variable in the Gizmo Template. + +For the custom select input gizmo, create a new python module in the ``gizmos`` package called ``custom_select_input.py`` and add the following contents: + +:: + + from tethys_sdk.gizmos import TethysGizmoOptions + + + class CustomSelectInput(TethysGizmoOptions): + """ + Custom select input gizmo. + """ + gizmo_name = 'custom_select_input' + + def __init__(self, name, display_text='', options=(), initial=(), multiselect=False, + disabled=False, error='', **kwargs): + """ + constructor + """ + # Initialize parent + super(TethysGizmoOptions, self).__init__(**kwargs) + + # Initialize Attributes + self.name = name + self.display_text = display_text + self.options = options + self.initial = initial + self.multiselect = multiselect + self.disabled = disabled + self.error = error + +It is important that ``gizmo_name`` property is the same as the name of the python module and template for the gizmo. Also, it is important to include ``**kwargs`` as an argument to your contstructor and use it to initialize the parent ``TethysGizmoOptions`` object. This will catch any of the parameters that are common to all gizmos like ``attributes`` and ``classes``. + +After defining the gizmo options class, import it in the ``gizmos/__init__.py`` module: + +:: + + from custom_select_input import CustomSelectInput + + +Gizmo Template +-------------- + +Gizmo templates are similar to the templates used for Tethys apps, but much simpler. + +For the custom select input gizmo, create a new template in the ``templates/gizmos/`` directory with the same name as your gizmo, ``custom_select_input.html``, with the following contents: + +.. code-block:: django + + {% load staticfiles %} + +
+ {% if display_text %} + + {% endif %} + + {% if error %} +

{{ error }}

+ {% endif %} +
+ +The variables in this template are defined by the attributes of the gizmo options object. Notice how the ``classes`` and ``attributes`` variables are handled. It is a good idea to handle these variables for each of your gizmos, because most gizmos support them and developers will expect them. + + +JavaScript and CSS Dependencies +------------------------------- + +Some gizmos have JavaScript and/or CSS dependencies. The ``TethysGizmoOptions`` base class provides methods for specifying different types of dependencies: + +* ``get_vendor_js``: For vendor/3rd party javascript. +* ``get_vendor_css``: For vendor/3rd party css. +* ``get_gizmo_js``: For your custom javascript. +* ``get_gizmo_css``: For your custom css. +* ``get_tethys_gizmos_js``: For global gizmo javascript. Changing this could cause other gizmos to stop working. Best not to mess with it unless you know what you are doing. +* ``get_tethys_gizmos_css``: For global gizmo css. Changing this could cause other gizmos to stop working. Best not to mess with it unless you know what you are doing. + +.. note:: + Tethys provides ``Twitter Bootstrap`` and ``jQuery``, so you don't need to include these as gizmo dependencies. + +The custom select input depends on the select2 libraries and some custom javascript and css. Create ``custom_select_input.js`` and ``custom_select_input.css`` in the ``public/gizmos/custom_select_input/`` directory, creating the directory as well. Add the following contents to each file: + +Add this content to the ``custom_select_input.css`` file: + +.. code-block:: css + + .select2 { + width: 100%; + } + +Add this content to the ``custom_select_input.js`` file: + +.. code-block:: javascript + + $(document).ready(function() { + $('.select2').select2(); + }); + + +Modify the gizmo options class to include these dependencies: + +:: + + from tethys_sdk.gizmos import TethysGizmoOptions + + + class CustomSelectInput(TethysGizmoOptions): + """ + Custom select input gizmo. + """ + gizmo_name = 'custom_select_input' + + def __init__(self, name, display_text='', options=(), initial=(), multiselect=False, + disabled=False, error='', **kwargs): + """ + constructor + """ + # Initialize parent + super(TethysGizmoOptions, self).__init__(**kwargs) + + # Initialize Attributes + self.name = name + self.display_text = display_text + self.options = options + self.initial = initial + self.multiselect = multiselect + self.disabled = disabled + self.error = error + + @staticmethod + def get_vendor_js(): + """ + JavaScript vendor libraries. + """ + return ('https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.6-rc.0/js/select2.min.js',) + + @staticmethod + def get_vendor_css(): + """ + CSS vendor libraries. + """ + return ('https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.6-rc.0/css/select2.min.css',) + + @staticmethod + def get_gizmo_js(): + """ + JavaScript specific to gizmo. + """ + return ('my_first_extension/gizmos/custom_select_input/custom_select_input.js',) + + @staticmethod + def get_gizmo_css(): + """ + CSS specific to gizmo . + """ + return ('my_first_extension/gizmos/custom_select_input/custom_select_input.css',) + +Using a Custom Gizmo +-------------------- + +To use a custom gizmo in an app, import the gizmo options object from the extension and create a new instance fo the gizmo in the app controller. Then use it with the ``gizmo`` template tag as normal. + + +Import and create a new instance of the gizmo in your controller: + +:: + + from tethysext.my_first_extension.gizmos import CustomSelectInput + + + def my_app_controller(request): + """ + Example controller using extension gizmo + """ + my_select = CustomSelectInput( + name = 'my_select', + display_text = 'Select One:', + options = (('Option 1', '1'), ('Option 2', '2'), ('Option 3', '3')), + initial = ('2') + ) + + context = { + 'my_select': my_select, + } + return render(request, 'my_first_app/a_template.html', context) + +Then use the gizmo as usual in ``a_template.html``: + +.. code-block::django + + {% load tethys_gizmos %} + + {% gizmo my_select %} + diff --git a/docs/tethys_sdk/extensions/structure.rst b/docs/tethys_sdk/extensions/structure.rst index 6159fa981..02f7dd8fb 100644 --- a/docs/tethys_sdk/extensions/structure.rst +++ b/docs/tethys_sdk/extensions/structure.rst @@ -20,6 +20,7 @@ The sturcture of a freshly scaffolded extension should looks something like this |-- tethysext/ | |-- my_first_extension/ | | |-- gizmos/ + | | | |-- __init__.py | | |-- public/ | | | |-- js/ | | | | |-- main.js diff --git a/docs/tethys_sdk/extensions/templates_and_static_files.rst b/docs/tethys_sdk/extensions/templates_and_static_files.rst index b566ffa6a..7bff8f622 100644 --- a/docs/tethys_sdk/extensions/templates_and_static_files.rst +++ b/docs/tethys_sdk/extensions/templates_and_static_files.rst @@ -2,4 +2,27 @@ Templates and Static Files ************************** -**Last Updated:** February 22, 2018 \ No newline at end of file +**Last Updated:** February 22, 2018 + +Templates and static files in extensions can be used in other apps. The advantage to using templates and static files from extensions in your apps is that when you update the template or static file in the extension, all the apps that use them will automatically be updated. Just as with apps, store the templates in the ``templates`` directory and store the static files (css, js, images, etc.) in the ``public`` directory. Then reference the template or static file in your app's controllers and templates using the namespaced path. + +For example, to use an extension template in one of your app's controllers: + +:: + + def my_controller(request): + """ + A controller in my app, not the extension. + """ + ... + return render(request, 'my_first_extension/a_template.html', context) + +You can reference static files in your app's templates using the ``static`` tag, just as you would any other static resource: + +.. code-block:: html + + {% load staticfiles %} + + + + \ No newline at end of file From 912a56b40273d9159dc6e75e740d4a2b56c1b0b9 Mon Sep 17 00:00:00 2001 From: nswain Date: Fri, 23 Feb 2018 16:07:00 -0700 Subject: [PATCH 139/215] Finished documentation for Tethys Extensions. --- docs/tethys_sdk/extensions.rst | 2 +- docs/tethys_sdk/extensions/custom_gizmos.rst | 4 +- docs/tethys_sdk/extensions/models.rst | 63 +++++++++++++++++++- docs/tethys_sdk/extensions/url_maps.rst | 40 +++++++++++-- 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/docs/tethys_sdk/extensions.rst b/docs/tethys_sdk/extensions.rst index 37b08b03a..a1e071f6c 100644 --- a/docs/tethys_sdk/extensions.rst +++ b/docs/tethys_sdk/extensions.rst @@ -12,6 +12,6 @@ Tethys Extensions provide a way for app developers to extend Tethys Platform and extensions/scaffold extensions/structure extensions/templates_and_static_files - extensions/models extensions/url_maps + extensions/models extensions/custom_gizmos diff --git a/docs/tethys_sdk/extensions/custom_gizmos.rst b/docs/tethys_sdk/extensions/custom_gizmos.rst index 0051002a7..7d5eac350 100644 --- a/docs/tethys_sdk/extensions/custom_gizmos.rst +++ b/docs/tethys_sdk/extensions/custom_gizmos.rst @@ -73,7 +73,7 @@ For the custom select input gizmo, create a new python module in the ``gizmos`` constructor """ # Initialize parent - super(TethysGizmoOptions, self).__init__(**kwargs) + super(CustomSelectInput, self).__init__(**kwargs) # Initialize Attributes self.name = name @@ -187,7 +187,7 @@ Modify the gizmo options class to include these dependencies: constructor """ # Initialize parent - super(TethysGizmoOptions, self).__init__(**kwargs) + super(CustomSelectInput, self).__init__(**kwargs) # Initialize Attributes self.name = name diff --git a/docs/tethys_sdk/extensions/models.rst b/docs/tethys_sdk/extensions/models.rst index 7dcc32db5..d7bdff37a 100644 --- a/docs/tethys_sdk/extensions/models.rst +++ b/docs/tethys_sdk/extensions/models.rst @@ -2,4 +2,65 @@ Models ****** -**Last Updated:** February 22, 2018 \ No newline at end of file +**Last Updated:** February 22, 2018 + +Extensions are not able to be linked to databases, but they can be used to store SQLAlchemy models that are used by multiple apps. Define the SQLAlchemy model as you would normally: + +:: + + import datetime + from sqlalchemy import Column + from sqlalchemy.types import Integer, String, DateTime + from sqlalchemy.ext.declarative import declarative_base + + + MyFirstExtensionBase = declarative_base() + + + class Project(MyFirstExtensionBase): + """ + SQLAlchemy interface for projects table + """ + __tablename__ = 'projects' + + id = Column(Integer, autoincrement=True, primary_key=True) + name = Column(String) + description = Column(String) + date_created = Column(DateTime, default=datetime.datetime.utcnow) + + +To initialize the tables using a model defined in an extension, import the declarative base from the extension in the initializer function for the persistent store database you'd like to initialize: + +:: + + from tethyext.my_first_extension.models import MyFirstExtensionBase + + + def init_primary_db(engine, first_time): + """ + Initializer for the primary database. + """ + # Create all the tables + MyFirstExtensionBase.metadata.create_all(engine) + +To use the extension models to query the database, import them from the extension and use like usual: + +:: + + from tethysapp.my_first_app.app import MyFirstApp as app + from tethysext.my_first_extension.models import Project + + + def my_controller(request, project_id): + """ + My app controller. + """ + SessionMaker = app.get_persistent_store_database('primary_db', as_sessionmaker=True) + session = SessionMaker() + project = session.query(Project).get(project_id) + + context = { + 'project': project + } + + return render(request, 'my_first_app/some_template.html', context) \ No newline at end of file diff --git a/docs/tethys_sdk/extensions/url_maps.rst b/docs/tethys_sdk/extensions/url_maps.rst index e0b2402e8..eb57f757e 100644 --- a/docs/tethys_sdk/extensions/url_maps.rst +++ b/docs/tethys_sdk/extensions/url_maps.rst @@ -1,5 +1,37 @@ -******* -UrlMaps -******* +*********************** +UrlMaps and Controllers +*********************** + +**Last Updated:** February 22, 2018 + +Although ``UrlMaps`` and controllers defined in extensions are loaded, it is not recommended that you use them to load normal html pages. Rather, use ``UrlMaps`` in extensions to define REST endpoints that handle any dynamic calls used by your custom gizmos and templates. ``UrlMaps`` are defined in extensions in the ``ext.py`` in the same way that they are defined in apps: + +:: + + from tethys_sdk.base import TethysExtensionBase + from tethys_sdk.base import url_map_maker + + + class MyFirstExtension(TethysExtensionBase): + """ + Tethys extension class for My First Extension. + """ + name = 'My First Extension' + package = 'my_first_extension' + root_url = 'my-first-extension' + description = 'This is my first extension.' + + def url_maps(): + """ + Map controllers to URLs. + """ + UrlMap = url_map_maker(self.root_url) + + return ( + UrlMap( + name='get_data', + url='my-first-extension/rest/get-data', + controller='my_first_extension.controllers.get_data + ), + ) -**Last Updated:** February 22, 2018 \ No newline at end of file From de77029908c1997035811c9f8a13bd4e7d1887b6 Mon Sep 17 00:00:00 2001 From: nswain Date: Fri, 23 Feb 2018 17:15:29 -0700 Subject: [PATCH 140/215] Generalized url loading to load urls from apps and extensions separately so that the appropriate namespace could be applied to the controllers (tethysext vs tethysapp). Loading extension urls with "extensions" as the root of the url. --- tethys_apps/base/url_map.py | 2 +- tethys_apps/urls.py | 8 ++- tethys_apps/utilities.py | 111 ++++++++++++++++++++++-------------- tethys_portal/urls.py | 3 + 4 files changed, 79 insertions(+), 45 deletions(-) diff --git a/tethys_apps/base/url_map.py b/tethys_apps/base/url_map.py index 7ca3a08bc..2959b2617 100644 --- a/tethys_apps/base/url_map.py +++ b/tethys_apps/base/url_map.py @@ -35,7 +35,7 @@ def __init__(self, name, url, controller, regex=None): self.name = name self.url = django_url_preprocessor(url, self.root_url, regex) - self.controller = '.'.join(['tethys_apps.tethysapp', controller]) + self.controller = controller self.custom_match_regex = regex def __repr__(self): diff --git a/tethys_apps/urls.py b/tethys_apps/urls.py index 66ab778a0..80f7c962a 100644 --- a/tethys_apps/urls.py +++ b/tethys_apps/urls.py @@ -23,12 +23,18 @@ ] # Append the app urls urlpatterns -app_url_patterns = generate_url_patterns() +app_url_patterns, extension_url_patterns = generate_url_patterns() for namespace, urls in app_url_patterns.items(): root_pattern = r'^{0}/'.format(namespace.replace('_', '-')) urlpatterns.append(url(root_pattern, include(urls, namespace=namespace))) +extension_urls = [] + +for namespace, urls in extension_url_patterns.items(): + root_pattern = r'^{0}/'.format(namespace.replace('_', '-')) + extension_urls.append(url(root_pattern, include(urls, namespace=namespace))) + # Register permissions here? try: register_app_permissions() diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 7a858fe5d..7cd3a4aec 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -18,7 +18,7 @@ from past.builtins import basestring from tethys_apps import tethys_log from tethys_apps.harvester import SingletonHarvester -from tethys_apps.base import permissions +from tethys_apps.base import permissions, TethysExtensionBase from tethys_apps.models import TethysApp, TethysExtension log = logging.getLogger('tethys.tethys_apps.utilities') @@ -159,50 +159,75 @@ def generate_url_patterns(): # Get controllers list from app harvester harvester = SingletonHarvester() - apps_and_extensions = harvester.apps + harvester.extensions + apps = harvester.apps + extensions = harvester.extensions + app_url_patterns = dict() + extension_url_patterns = dict() + + for app in apps: + app_url_patterns.update(get_django_urls_for(app)) + + for extension in extensions: + extension_url_patterns.update(get_django_urls_for(extension)) + + return app_url_patterns, extension_url_patterns + + +def get_django_urls_for(app_or_extension): + """ + Get all UrlMaps from the app or extension given and convert to django urls. + + Args: + app_or_extension(TethysApp or TethysExtension): TethysApp or TethysExtension instance. + + Returns: + dictionary: django urls grouped by namespace. + """ + is_extension = isinstance(app_or_extension, TethysExtensionBase) url_patterns = dict() - for app_or_extension in apps_and_extensions: - if hasattr(app_or_extension, 'url_maps'): - url_maps = app_or_extension.url_maps() - elif hasattr(app_or_extension, 'controllers'): - url_maps = app_or_extension.controllers() - else: - url_maps = None - - if url_maps: - for url_map in url_maps: - root_url = app_or_extension.root_url - namespace = root_url.replace('-', '_') - - if namespace not in url_patterns: - url_patterns[namespace] = [] - - # Create django url object - if isinstance(url_map.controller, basestring): - controller_parts = url_map.controller.split('.') - module_name = '.'.join(controller_parts[:-1]) - function_name = controller_parts[-1] - try: - module = __import__(module_name, fromlist=[function_name]) - except ImportError: - error_msg = 'The following error occurred while trying to import the controller function ' \ - '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) - log.error(error_msg) - sys.exit(1) - try: - controller_function = getattr(module, function_name) - except AttributeError as e: - error_msg = 'The following error occurred while tyring to access the controller function ' \ - '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) - log.error(error_msg) - sys.exit(1) - else: - controller_function = url_map.controller - django_url = url(url_map.url, controller_function, name=url_map.name) - - # Append to namespace list - url_patterns[namespace].append(django_url) + if hasattr(app_or_extension, 'url_maps'): + url_maps = app_or_extension.url_maps() + elif hasattr(app_or_extension, 'controllers'): + url_maps = app_or_extension.controllers() + else: + url_maps = None + + if url_maps: + for url_map in url_maps: + root_url = app_or_extension.root_url + namespace = root_url.replace('-', '_') + + if namespace not in url_patterns: + url_patterns[namespace] = [] + + # Create django url object + if isinstance(url_map.controller, basestring): + root_controller_path = 'tethysext' if is_extension else 'tethys_apps.tethysapp' + full_controller_path = '.'.join([root_controller_path, url_map.controller]) + controller_parts = full_controller_path.split('.') + module_name = '.'.join(controller_parts[:-1]) + function_name = controller_parts[-1] + try: + module = __import__(module_name, fromlist=[function_name]) + except ImportError: + error_msg = 'The following error occurred while trying to import the controller function ' \ + '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) + log.error(error_msg) + sys.exit(1) + try: + controller_function = getattr(module, function_name) + except AttributeError as e: + error_msg = 'The following error occurred while trying to access the controller function ' \ + '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) + log.error(error_msg) + sys.exit(1) + else: + controller_function = url_map.controller + django_url = url(url_map.url, controller_function, name=url_map.name) + + # Append to namespace list + url_patterns[namespace].append(django_url) return url_patterns diff --git a/tethys_portal/urls.py b/tethys_portal/urls.py index 1a496b330..dbee07de1 100644 --- a/tethys_portal/urls.py +++ b/tethys_portal/urls.py @@ -12,6 +12,8 @@ from django.contrib.auth.views import password_reset, password_reset_done, password_reset_confirm, \ password_reset_complete from django.conf import settings +from tethys_apps.urls import extension_urls + admin.autodiscover() # ensure at least staff users logged in before accessing admin login page @@ -64,6 +66,7 @@ url(r'^oauth2/', include('social_django.urls', namespace='social')), url(r'^user/(?P[\w.@+-]+)/', include(user_urls, namespace='user')), url(r'^apps/', include('tethys_apps.urls')), + url(r'^extensions/', include(extension_urls)), url(r'^developer/', include(developer_urls)), url(r'^handoff/(?P[\w-]+)/$', tethys_apps_views.handoff_capabilities, name='handoff_capabilities'), url(r'^handoff/(?P[\w-]+)/(?P[\w-]+)/$', tethys_apps_views.handoff, name='handoff'), From 7c5b4da8fbf80e3358c320ca2fcee4c47b11f581 Mon Sep 17 00:00:00 2001 From: nswain Date: Mon, 5 Mar 2018 12:12:50 -0700 Subject: [PATCH 141/215] Removed aquaveo_static from the directory structure and the docker file. --- Dockerfile | 3 - aquaveo_static/images/aquaveo_favicon.ico | Bin 33786 -> 0 bytes aquaveo_static/images/aquaveo_logo.png | Bin 136078 -> 0 bytes aquaveo_static/tethys_main.css | 2445 --------------------- 4 files changed, 2448 deletions(-) delete mode 100644 aquaveo_static/images/aquaveo_favicon.ico delete mode 100644 aquaveo_static/images/aquaveo_logo.png delete mode 100644 aquaveo_static/tethys_main.css diff --git a/Dockerfile b/Dockerfile index 26936193d..35b870e50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,9 +72,6 @@ RUN mkdir ${TETHYS_HOME}/workspaces ${TETHYS_HOME}/apps ${TETHYS_HOME}/static # Add static files ADD static ${TETHYS_HOME}/src/static/ -ADD aquaveo_static/images/aquaveo_favicon.ico ${TETHYS_HOME}/src/static/tethys_portal/images/default_favicon.png -ADD aquaveo_static/images/aquaveo_logo.png ${TETHYS_HOME}/src/static/tethys_portal/images/tethys-logo-75.png -ADD aquaveo_static/tethys_main.css ${TETHYS_HOME}/src/static/tethys_portal/css/tethys_main.css # Generate Inital Settings Files RUN /bin/bash -c '. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} \ diff --git a/aquaveo_static/images/aquaveo_favicon.ico b/aquaveo_static/images/aquaveo_favicon.ico deleted file mode 100644 index fcf1f37ed4aaa3fc1fb7e12f88b649ee5d7d3569..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33786 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 L!-$8|;I#h-@wRQK diff --git a/aquaveo_static/images/aquaveo_logo.png b/aquaveo_static/images/aquaveo_logo.png deleted file mode 100644 index 08b7fa2d3f55aa4a89e486360bfa545015fd55bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 136078 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 G=l=sen) .active > a, -.nav-list > .active > a:hover, -.nav-list > .active > a:focus { - background-color: #0A62A9; -} -@media (max-width: 1250px) { - .container { - width: 90%; - } - .container .wrapper { - background-image: none; - } - .container .wrapper .primary { - width: 70%; - border-left: #d8d8d8 1px solid; - margin-left: -1px; - } - .container .wrapper .secondary { - width: 30%; - } - .container .wrapper .secondary .module-heading { - border-right: #d8d8d8 1px solid; - } - .editor textarea { - margin-bottom: 0; - } - .select2-container { - width: 100% !important; - } - .control-full .select2-container { - width: 100% !important; - } - .control-medium input, - .control-medium select, - .control-medium textarea { - width: 95%; - } - .control-custom .control-label { - margin-bottom: 5px; - } - .control-custom .editor .input-prepend { - position: relative; - } - .control-custom .editor .input-prepend .add-on { - display: block; - float: left; - margin-bottom: 10px; - } - .control-custom .editor .input-prepend input { - display: block; - margin-bottom: 10px; - } - .control-custom .editor .input-prepend .icon-remove input[type=checkbox] { - visibility: hidden; - } -} -@media (max-width: 950px) { - .actions { - position: fixed; - top: 100%; - left: 0; - width: 100%; - margin-top: -60px; - height: 60px; - background-color: white; - background-color: rgba(255, 255, 255, 0.95); - -webkit-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); - -moz-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); - -ms-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); - -o-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); - box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); - z-index: 500; - padding: 10px; - } - .resource-item .btn-group { - position: static; - margin-top: 10px; - } - .module-resource .actions { - position: fixed; - top: 100%; - left: 0; - width: 100%; - margin-top: -60px; - height: 60px; - background-color: white; - background-color: rgba(255, 255, 255, 0.95); - -webkit-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); - -moz-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); - -ms-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); - -o-box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); - box-shadow: 0px -1px 15px rgba(0, 0, 0, 0.25); - z-index: 500; - padding: 10px; - } -} -@media (max-width: 880px) { - .control-order-by { - display: none; - } - .results strong:before { - display: none; - } - .form-actions { - position: relative; - padding-top: 75px; - margin-left: auto; - margin-right: auto; - } - .form-actions .action-info.small { - position: absolute; - width: 90%; - top: 12px; - } - .form-actions .btn { - margin-bottom: 5px; - } -} -@media (max-width: 700px) { - .container .wrapper .primary { - border-left: none; - width: 100%; - } - .container .wrapper .primary .context-info { - background: whitesmoke; - margin-bottom: -20px; - } - .container .wrapper .primary .context-info .arrow { - display: none; - } - .container .wrapper .primary .context-info h1 { - font-size: 30px; - margin-right: 80px; - } - .container .wrapper .primary .context-info h4 { - font-size: 30px; - margin-right: 80px; - } - .container .wrapper .primary .context-info p { - margin-right: 80px; - } - .container .wrapper .primary .context-info .nums { - position: absolute; - right: 0; - top: 0; - padding-right: 15px; - border: none; - margin-top: 0; - } - .container .wrapper .primary .context-info .nums dl { - float: none; - } - .container .wrapper .secondary { - display: none; - } -} -@media (max-width: 575px) { - .modal { - width: 90%; - left: 0; - margin-left: 5%; - margin-right: 5%; - } - .stages { - /*li:nth-of-type(1):before { - &:before { - content: "1"; - } - } - - li:nth-of-type(2){ - &:before { - content: "2"; - } - }*/ - - } - .stages li { - /*display: none;*/ - width: 100%; - /*&.active { - display:block; - }*/ - } - .stages li:after { - content: none; - } - .stages li:nth-of-type(3).active { - /*&:before { - content: "3"; - }*/ - left: -1px; - } -} -@media (max-width: 550px) { - .media-item { - width: 50%; - } - .media-item.first { - clear: none; - } - .media-item:nth-child(odd) { - clear: left; - } - .toolbar .breadcrumb { - font-size: 18px; - } - .nav-tabs > li { - font-size: 12px; - } - .dataset-form-resource-types .controls { - position: relative; - margin-bottom: 10px; - height: 180px; - } - .dataset-form-resource-types .controls i { - display: block; - float: left; - clear: left; - margin-top: 30px; - } - .dataset-form-resource-types .controls i:first-of-type { - margin-top: 0; - } - .dataset-form-resource-types .controls label.radio { - display: block; - float: left; - margin-top: 25px; - padding-left: 5px; - font-size: 18px; - line-height: 25px; - } - .dataset-form-resource-types .controls label.radio:first-of-type { - margin-top: -5px; - } - .dataset-form-resource-types .controls span { - display: block; - float: left; - clear: left; - margin-top: 30px; - } - .dataset-form-resource-types .controls input[type=radio]:checked + label:after { - background-position: -320px -35px; - top: 10px; - right: 0; - left: auto; - bottom: auto; - } -} -section.module { - padding: 0; -} -section.module h2 { - font-weight: bold; -} -li.dataset-item .dataset-content h3.dataset-heading a { - font-weight: bold; -} -li.dataset-item .dataset-content h3.dataset-heading a:hover { - color: #333; - text-decoration: underline; -} -li.dataset-item .dataset-content div { - font-weight: 400; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -li.dataset-item .dataset-content p { - font-weight: 400; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.toolbar { - margin-top: 5px; - margin-bottom: 15px; -} -.toolbar .add_action { - top: -2px; -} -.media-grid .media-item .media-content .media-heading a { - font-weight: 500; - color: #2ca6ed; -} -.media-grid .media-item .media-content .media-heading a:hover { - text-decoration: underline; - color: #0a84cb; -} -.media-grid .media-item .media-content p { - font-weight: 400; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.module-content.page-header { - height: 40px; -} -.module-content.page-header .nav.nav-tabs li a { - color: #0A62A9; -} -.module-content.page-header .nav.nav-tabs li.active a { - color: #555; -} -.resources h3 { - font-weight: normal; -} -.resources .resource-list .resource-item .dropdown { - top: 10px; -} -.tags .tag-list li a.tag:hover { - background-color: #0A62A9; -} -.additional-info h3 { - font-weight: normal; -} -.notes.embedded-content p { - font-weight: 400; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.module-content h3 { - font-weight: normal; -} -.module-content p a.resource-url-analytics { - color: #505050; -} -.nav-simple .nav-item.active a { - background-color: #0A62A9; -} -.nav-simple .nav-item.active a:before { - background-image: none; - height: 0; - width: 0; - border-top: 17px solid transparent; - border-bottom: 17px solid transparent; - border-left: 6px solid #0A62A9; -} -.nav-simple .nav-item a:hover { - color: #333; -} -.banner { - background-color: #0A62A9; -} -.popover-followee .popover-content .nav li a { - color: #0A62A9; -} -.popover-followee .popover-content .nav li a i { - color: white; - background-color: #0A62A9; - -webkit-font-smooting: antialiased; -} -.popover-followee .popover-content .nav li.active a { - color: white; - background-color: #0A62A9; -} -.popover-followee .popover-content .nav li.active a i { - background-color: white; - color: #0A62A9; - -webkit-font-smooting: antialiased; -} -/***************************************************************************** - * FILE: footer - * DATE: 2014 - * AUTHOR: Nathan Swain - * COPYRIGHT: (c) Brigham Young University 2014 - * LICENSE: BSD 2-Clause - *****************************************************************************/ -/* ==================================== - The footer at the bottom of the site - ==================================== */ -footer.site-footer { - background-color: #0A62A9; - position: relative; - z-index: 100; - box-shadow: 0px -1px 10px rgba(0, 0, 0, 0.25); - padding: 0 50px; - overflow: auto; -} -footer.site-footer .tethys-attribution { - font-size: 16px; - float: right; - margin-top: 37px; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - color: #ffffff; -} -footer.site-footer .tethys-attribution a { - font-size: 20px; - color: #eeeeff; -} -footer.site-footer .tethys-attribution a img { - padding-left: 5px; -} -footer.site-footer .tethys-attribution a:hover { - color: #ffffff; -} -.footer-col { - float: left; - margin-top: 80px; - margin-bottom: 80px; - margin-right: 5%; - min-height: 50px; - width: 30%; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-align: center; -} -.footer-col:nth-child(3) { - margin-right: 0; -} -.footer-col .centered { - width: 200px; -} -.footer-col ul { - list-style: none; - margin: 0; - padding: 0; -} -.footer-col ul li { - font-size: 18px; - font-weight: 300; - line-height: 2; - color: #efefef; - list-style: none; -} -.footer-col ul li a { - color: #CCDEE3; - text-decoration: none; -} -.footer-col ul li a:hover { - color: #ffffff; -} -.footer-col h5 { - line-height: 2; - margin-top: 8%; - color: #fff; - font-size: 18px; - font-weight: bold; - text-transform: none; - letter-spacing: 0; -} -.copyright-wrapper { - margin-top: 40px; - float: left; -} -.copyright { - display: block; - font-weight: 300; - font-size: 16px; - color: #efefef; - margin-left: 0; -} -.copyright a { - text-decoration: none; - color: #efefef; -} -.copyright a:hover { - color: #ffffff; -} -.copyright-bottom { - display: none; -} -.documents-wrapper { - margin-top: 40px; - margin-left: 10px; - float: left; - font-weight: 300; -} -.documents-wrapper .document-link { - margin: 0 5px; - display: inline-block; - font-size: 16px; - color: #eeeeff; -} -.documents-wrapper .document-link:hover { - color: #ffffff; -} -/*@media (max-width: 1180px) { - .footer-col > .png-cp-logo { - width: 200px; - margin-left: -11px; - } - - .footer-col { - float: none; - font-size: 16px; - width: 100%; - margin: 0; - text-align: center; - - &.one { - display: none; - } - - &.two, &.three { - margin-top: 40px; - } - - &.three { - width: 100%; - } - - &:last-child { - width: 100%; - margin-bottom: 40px; - } - - h5 { - margin-top: 20px; - } - } - - .copyright-bottom { - display: block; - position: relative; - margin-top: 40px; - - img { - width: 100px; - } - - .copyright { - margin-top: 40px; - font-size: 18px; - text-align: center; - } - } -}*/ -@media (max-width: 1100px) { - footer.site-footer .tethys-attribution { - float: none; - margin-top: 15px; - text-align: center; - } - .copyright-wrapper { - float: none; - margin-top: 22px; - text-align: center; - } - .documents-wrapper { - display: block; - float: none; - margin-top: 15px; - text-align: center; - } -} -/***************************************************************************** - * FILE: account - * DATE: 2014 - * AUTHOR: Nathan Swain - * COPYRIGHT: (c) Brigham Young University 2014 - * LICENSE: BSD 2-Clause - *****************************************************************************/ -.account-form-wrapper { - position: relative; - padding: 50px; - margin: 50px 0; - background: #ffffff; - box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.25); - box-sizing: border-box; -} -.account-form-wrapper .account-form-header { - position: absolute; - left: 0; - top: 0; - width: 100%; - padding: 0 50px; - background: #eeeeee; -} -.account-form-wrapper .account-form-header h1 { - color: #555555; - font-weight: 400; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.account-form-wrapper.disconnect p { - padding: 20px 0; -} -.account-form-wrapper .account-form-body { - margin-top: 50px; -} -.account-form-wrapper .help-block { - margin-top: 10px; -} -.btn-hydroshare { - color: #555555; - background-color: #ffffff; - border-color: rgba(0, 0, 0, 0.2); -} -.btn-hydroshare:hover { - color: #ffffff; - background-color: #71a43a; - border-color: rgba(0, 0, 0, 0.2); -} -.fa-hydroshare { - background-image: url(/static/tethys_portal/images/hs-icon-sm.png); - background-repeat: no-repeat; - background-size: 75%; - background-position: 4px 5px; -} -.btn-google { - background-color: #ffffff; - color: #555555; -} -.fa-google { - color: #dd4b39; -} -.btn-google:hover { - background-color: #dd4b39; -} -.btn-google:hover .fa-google { - color: #ffffff; -} -.btn-google:focus { - background-color: #dd4b39; -} -.btn-google:focus .fa-google { - color: #ffffff; -} -.btn-linkedin { - background-color: #ffffff; - color: #555555; -} -.fa-linkedin { - color: #007bb6; -} -.btn-linkedin:hover { - background-color: #007bb6; -} -.btn-linkedin:hover .fa-linkedin { - color: #ffffff; -} -.btn-linkedin:focus { - background-color: #007bb6; -} -.btn-linkedin:focus .fa-linkedin { - color: #ffffff; -} -.btn-facebook { - background-color: #ffffff; - color: #555555; -} -.fa-facebook { - color: #3b5998; -} -.btn-facebook:hover { - background-color: #3b5998; -} -.btn-facebook:hover .fa-facebook { - color: #ffffff; -} -.btn-facebook:focus { - background-color: #3b5998; -} -.btn-facebook:focus .fa-facebook { - color: #ffffff; -} -.btn-social { - font-size: 15px; - font-weight: 500; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.btn-block + .btn-block { - margin-top: 10px; -} -.social-divide-or { - position: relative; - margin: 30px 0; - width: 100%; - text-align: center; -} -.social-divide-or .line { - width: 100%; - height: 1px; - background-color: #dddddd; -} -.social-divide-or .or { - position: absolute; - left: 50%; - top: -15px; - margin-left: -20px; - background-color: #ffffff; - padding: 0 10px; - font-size: 20px; - color: #555555; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -@media (max-width: 992px) { - .account-form-wrapper { - margin: 0; - } -} -/***************************************************************************** - * FILE: user - * DATE: 2014 - * AUTHOR: Nathan Swain - * COPYRIGHT: (c) Brigham Young University 2014 - * LICENSE: BSD 2-Clause - *****************************************************************************/ -.profile-header { - position: relative; - height: 250px; - background: #1b95dc; -} -.profile-header .profile-image-wrapper { - position: absolute; - top: 50px; -} -.profile-header .profile-image-wrapper img { - border-radius: 50%; -} -.profile-header .profile-name { - display: block; - color: white; - font-size: 60px; - font-weight: 300; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - padding-top: 130px; - padding-left: 30px; -} -.profile-section { - padding: 25px 0; - color: #555555; - width: 80%; - margin: 0 auto; - border-bottom: 1px solid #555555; -} -.profile-section.first { - margin-top: 75px; -} -.profile-section.last { - border-bottom: none; -} -.profile-section .profile-section-header h3 { - margin: 0; - font-weight: 500; -} -.profile-section .profile-parameters { - margin-top: 8px; -} -.profile-section .profile-parameters ul { - padding: 0; - margin: 0; -} -.profile-section .profile-parameters ul li { - display: block; - height: 28px; - margin-bottom: 10px; - list-style: none; - font-size: 18px; - font-weight: 300; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.profile-section .profile-parameters ul li .parameter { - float: left; - width: 30%; - text-align: right; - padding-right: 20px; - font-weight: 400; -} -.profile-section .profile-parameters ul li .value { - float: left; -} -.profile-section .profile-parameters ul li .value.button { - -webkit-font-smoothing: subpixel-antialiased; - -moz-font-smoothing: subpixel-antialiased; - -ms-font-smoothing: subpixel-antialiased; - -o-font-smoothing: subpixel-antialiased; - font-smoothing: subpixel-antialiased; -} -.profile-section .profile-parameters ul li .value.button .btn { - font-size: 13.5px; -} -.profile-section .profile-parameters ul > li:last-of-type { - margin-bottom: 0; -} -.profile-section .profile-parameters label { - width: 30%; - font-size: 18px; - font-weight: 400; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.profile-section .profile-parameters label.error-label { - text-align: left; - margin-left: 30%; - padding-left: 15px; - width: 50%; -} -.profile-section .profile-parameters .form-control-static { - font-size: 18px; - font-weight: 300; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.profile-section .profile-parameters .form-control-static .btn { - margin-top: -4px; -} -.profile-section .profile-parameters .form-control { - margin-top: 4px; -} -.profile-section.social .profile-parameters ul li { - height: auto; -} -.profile-section.social .profile-parameters ul li .social-label-container { - margin-left: 30%; -} -.profile-section.social .profile-parameters ul li .social-label-container span { - line-height: 32px; -} -#settings-buttons { - margin: 20px 0; -} -#settings-buttons .btn { - width: 150px; - margin-left: 10px; -} -.profile-edit-button { - float: right; - padding-top: 20px; - padding-right: 10%; - margin-bottom: -65px; -} -@media (max-width: 992px) { - .profile-header { - height: 125px; - } - .profile-header .profile-image-wrapper { - top: 25px; - } - .profile-header .profile-image-wrapper img { - height: 75px; - width: 75px; - } - .profile-header .profile-name { - font-size: 35px; - padding-top: 38px; - padding-left: 90px; - } - .profile-section.first { - margin-top: 0; - } - .profile-section .profile-parameters ul li .parameter { - float: none; - } - .profile-section .profile-parameters ul li .value { - float: none; - } - .profile-section .profile-parameters label { - width: 100%; - padding-left: 0; - } - .profile-section .profile-parameters label.error-label { - margin-left: 0; - padding-left: 15px; - width: 100%; - } - .profile-section.social .profile-parameters ul li .social-label-container { - margin-left: 15px; - } - .profile-section.social .profile-parameters ul li .social-label-container span { - line-height: 45px; - font-size: 18px; - } - #settings-buttons .btn-toolbar { - float: none!important; - } - #settings-buttons .btn-toolbar .btn-group { - float: left; - width: 100%; - margin-bottom: 10px; - } - #settings-buttons .btn-toolbar .btn-group .btn { - width: 100%; - margin-left: 0; - } - .profile-edit-button { - float: none; - padding-right: 0; - margin-bottom: 0; - } - .profile-edit-button .btn { - width: 80%; - margin: 0 10%; - } -} -.label-google { - color: #dd4b39; - border: #aaaaaa solid 1px; -} -.label-google:hover { - color: #ffffff; - background-color: #dd4b39; -} -.label-facebook { - color: #3b5998; - border: #aaaaaa solid 1px; -} -.label-facebook:hover { - color: #ffffff; - background-color: #3b5998; -} -.label-hydroshare { - color: #71a43a; - border: #aaaaaa solid 1px; -} -.label-hydroshare:hover { - color: #ffffff; - background-color: #71a43a; -} -.label-linkedin { - color: #007bb6; - border: #aaaaaa solid 1px; -} -.label-linkedin:hover { - color: #ffffff; - background-color: #007bb6; -} -/***************************************************************************** - * FILE: secondary_header - * DATE: 2014 - * AUTHOR: Nathan Swain - * COPYRIGHT: (c) Brigham Young University 2014 - * LICENSE: BSD 2-Clause - *****************************************************************************/ -.tethys-secondary-header { - position: relative; - height: 100px; - background: #1b95dc; - margin-bottom: 70px; -} -.tethys-secondary-header .image-wrapper { - position: absolute; - top: 0; - left: 0; - padding: 20px; - z-index: 100; -} -.tethys-secondary-header .image-wrapper img { - border-radius: 50%; - width: 120px; - background: #1b95dc; - box-shadow: 0 5px 10px 0px rgba(0, 0, 0, 0.1); -} -.tethys-secondary-header .secondary-title-wrapper { - float: left; -} -.tethys-secondary-header .secondary-title-wrapper .secondary-title { - display: block; - color: white; - font-size: 40px; - font-weight: 300; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - padding: 21px 0; - padding-left: 130px; -} -@media (max-width: 700px) { - .tethys-secondary-header { - height: 100px; - margin-bottom: 0; - } - .tethys-secondary-header .image-wrapper { - position: inline-block; - padding: 10px 20px; - } - .tethys-secondary-header .image-wrapper img { - width: 80px; - box-shadow: none; - } - .tethys-secondary-header .secondary-title-wrapper .secondary-title { - font-size: 40px; - padding: 21px 0; - padding-left: 50px; - } -} - -/* - * Override App Library Header Styles - */ - -.tethys-secondary-header { - height: 60px!important; -} - -.tethys-secondary-header .secondary-title-wrapper .secondary-title { - font-size: 30px!important; - font-weight: 400!important; - padding: 0!important; - padding-left: 50px!important; - margin-top: 9px!important; -} - -@media(max-width: 900px) { - .tethys-secondary-header .secondary-title-wrapper .secondary-title { - padding-left: 20px!important; - } -} - -@media(max-width: 750px) { - .tethys-secondary-header { - height: 40px!important; - } - - .tethys-secondary-header .secondary-title-wrapper .secondary-title { - font-size: 20px!important; - margin-top: 6px!important; - } -} From 6da586ea7e9808998d56034fd4a09b8c68bfde8d Mon Sep 17 00:00:00 2001 From: nswain Date: Mon, 5 Mar 2018 14:55:36 -0700 Subject: [PATCH 142/215] Expanded tethys test command to test extensions. Added default test package back to the scaffold for extensions. --- tethys_apps/cli/__init__.py | 13 +- .../tethysext/+project+/tests/__init__.py | 0 .../tethysext/+project+/tests/tests.py_tmpl | 155 ++++++++++++++++++ 3 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/__init__.py create mode 100644 tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index fe9d3e5ef..41b76fb7d 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -154,8 +154,9 @@ def test_command(args): # Define the process to be run primary_process = ['python', manage_path, 'test'] - # Tag to later check if tests are being run on a specific app + # Tag to later check if tests are being run on a specific app or extension app_package_tag = 'tethys_apps.tethysapp.' + extension_package_tag = 'tethysext.' if args.coverage or args.coverage_html: os.environ['TETHYS_TEST_DIR'] = tests_path @@ -165,6 +166,12 @@ def test_command(args): core_app_package = '{}{}'.format(app_package_tag, app_name) app_package = 'tethysapp.{}'.format(app_name) config_opt = '--source={},{}'.format(core_app_package, app_package) + elif args.file and extension_package_tag in args.file: + extension_package_parts = args.file.split(extension_package_tag) + extension_name = extension_package_parts[1].split('.')[0] + core_extension_package = '{}{}'.format(extension_package_tag, extension_name) + extension_package = 'tethysext.{}'.format(extension_name) + config_opt = '--source={},{}'.format(core_extension_package, extension_package) else: config_opt = '--rcfile={0}'.format(os.path.join(tests_path, 'coverage.cfg')) primary_process = ['coverage', 'run', config_opt, manage_path, 'test'] @@ -179,7 +186,7 @@ def test_command(args): # print(primary_process) run_process(primary_process) if args.coverage: - if args.file and app_package_tag in args.file: + if args.file and (app_package_tag in args.file or extension_package_tag in args.file): run_process(['coverage', 'report']) else: run_process(['coverage', 'report', config_opt]) @@ -187,7 +194,7 @@ def test_command(args): report_dirname = 'coverage_html_report' index_fname = 'index.html' - if args.file and app_package_tag in args.file: + if args.file and (app_package_tag in args.file or extension_package_tag in args.file): run_process(['coverage', 'html', '--directory={0}'.format(os.path.join(tests_path, report_dirname))]) else: run_process(['coverage', 'html', config_opt]) diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/__init__.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl new file mode 100644 index 000000000..8713d52b0 --- /dev/null +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl @@ -0,0 +1,155 @@ +# Most of your test classes should inherit from TethysTestCase +from tethys_sdk.testing import TethysTestCase + +# Use if you'd like a simplified way to test rendered HTML templates. +# You likely need to install BeautifulSoup, as it is not included by default in Tethys Platform +# 1. Open a terminal +# 2. Enter command ". /usr/lib/tethys/bin/activate" to activate the Tethys python environment +# 3. Enter command "pip install beautifulsoup4" +# For help, see https://www.crummy.com/software/BeautifulSoup/bs4/doc/ +# from bs4 import BeautifulSoup + +""" +To run any tests: + 1. Open a terminal + 2. Enter command "t" to activate the Tethys python environment + 3. In settings.py make sure that the tethys_default database user is set to tethys_super or is a super user of the database + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'tethys_default', + 'USER': 'tethys_super', + 'PASSWORD': 'pass', + 'HOST': '127.0.0.1', + 'PORT': '5435' + } + } + 4. Enter tethys test command. + The general form is: "tethys test -f tethysext....." + See below for specific examples + + To run all tests across this app: + Test command: "tethys test -f tethysext.{{project}}" + + To run all tests in this file: + Test command: "tethys test -f tethysext.{{project}}.tests.tests" + + To run tests in the {{class_name}}TestCase class: + Test command: "tethys test -f tethysext.{{project}}.tests.tests.{{class_name}}TestCase" + + To run only the test_if_tethys_platform_is_great function in the {{class_name}}TestCase class: + Test command: "tethys test -f tethysext.{{project}}.tests.tests.{{class_name}}TestCase.test_if_tethys_platform_is_great" + +To learn more about writing tests, see: + https://docs.djangoproject.com/en/1.9/topics/testing/overview/#writing-tests + https://docs.python.org/2.7/library/unittest.html#module-unittest +""" + + +class {{class_name}}TestCase(TethysTestCase): + """ + In this class you may define as many functions as you'd like to test different aspects of your app. + Each function must start with the word "test" for it to be recognized and executed during testing. + You could also create multiple TethysTestCase classes within this or other python files to organize your tests. + """ + + def set_up(self): + """ + This function is not required, but can be used if any environmental setup needs to take place before + execution of each test function. Thus, if you have multiple test that require the same setup to run, + place that code here. + + If you are testing against a controller that check for certain user info, you can create a fake test user and + get a test client, like so: + + #The test client simulates a browser that can navigate your app's url endpoints + self.c = self.get_test_client() + self.user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") + # To create a super_user, use "self.create_test_superuser(*params)" with the same params + + # To force a login for the test user + self.c.force_login(self.user) + + # If for some reason you do not want to force a login, you can use the following: + login_success = self.c.login(username="joe", password="secret") + + NOTE: You do not have place these functions here, but if they are not placed here and are needed + then they must be placed at the beginning of your individual test functions. Also, if a certain + setup does not apply to all of your functions, you should either place it directly in each + function it applies to, or maybe consider creating a new test file or test class to group similar + tests. + """ + pass + + def tear_down(self): + """ + This function is not required, but should be used if you need to tear down any environmental setup + that took place before execution of the test functions. + + NOTE: You do not have to set these functions up here, but if they are not placed here and are needed + then they must be placed at the very end of your individual test functions. Also, if certain + tearDown code does not apply to all of your functions, you should either place it directly in each + function it applies to, or maybe consider creating a new test file or test class to group similar + tests. + """ + pass + + def is_tethys_platform_great(self): + return True + + def test_if_tethys_platform_is_great(self): + """ + This is an example test function that can be modified to test a specific aspect of your app. + It is required that the function name begins with the word "test" or it will not be executed. + Generally, the code written here will consist of many assert methods. + A list of assert methods is included here for reference or to get you started: + assertEqual(a, b) a == b + assertNotEqual(a, b) a != b + assertTrue(x) bool(x) is True + assertFalse(x) bool(x) is False + assertIs(a, b) a is b + assertIsNot(a, b) a is not b + assertIsNone(x) x is None + assertIsNotNone(x) x is not None + assertIn(a, b) a in b + assertNotIn(a, b) a not in b + assertIsInstance(a, b) isinstance(a, b) + assertNotIsInstance(a, b) !isinstance(a, b) + Learn more about assert methods here: + https://docs.python.org/2.7/library/unittest.html#assert-methods + """ + + self.assertEqual(self.is_tethys_platform_great(), True) + self.assertNotEqual(self.is_tethys_platform_great(), False) + self.assertTrue(self.is_tethys_platform_great()) + self.assertFalse(not self.is_tethys_platform_great()) + self.assertIs(self.is_tethys_platform_great(), True) + self.assertIsNot(self.is_tethys_platform_great(), False) + + def test_a_controller(self): + """ + This is an example test function of how you might test a controller that returns an HTML template rendered + with context variables. + """ + + # If all test functions were testing controllers or required a test client for another reason, the following + # 3 lines of code could be placed once in the set_up function. Note that in that case, each variable should be + # prepended with "self." (i.e. self.c = ...) to make those variables "global" to this test class and able to be + # used in each separate test function. + c = self.get_test_client() + user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") + c.force_login(user) + + # Have the test client "browse" to your page + response = c.get('/extensions/{{project_url}}/foo/') # The final '/' is essential for all pages/controllers + + # Test that the request processed correctly (with a 200 status code) + self.assertEqual(response.status_code, 200) + + ''' + NOTE: Next, you would likely test that your context variables returned as expected. That would look + something like the following: + ''' + + context = response.context + self.assertEqual(context['my_integer'], 10) From 56701d210a7bac38d5cbee87ec78ed6260b57077 Mon Sep 17 00:00:00 2001 From: nswain Date: Tue, 6 Mar 2018 08:26:16 -0700 Subject: [PATCH 143/215] Added django model migration for tethys extensions. --- .../migrations/0004_tethysextension.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tethys_apps/migrations/0004_tethysextension.py diff --git a/tethys_apps/migrations/0004_tethysextension.py b/tethys_apps/migrations/0004_tethysextension.py new file mode 100644 index 000000000..52b3765de --- /dev/null +++ b/tethys_apps/migrations/0004_tethysextension.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2018-02-20 21:10 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tethys_apps', '0001_initial_20'), + ] + + operations = [ + migrations.CreateModel( + name='TethysExtension', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('package', models.CharField(default=b'', max_length=200, unique=True)), + ('name', models.CharField(default=b'', max_length=200)), + ('description', models.TextField(blank=True, default=b'', max_length=1000)), + ('root_url', models.CharField(default=b'', max_length=200)), + ('enabled', models.BooleanField(default=True)), + ], + ), + ] From 099c6da081efd9e136a77243450d19a51a13058c Mon Sep 17 00:00:00 2001 From: nswain Date: Tue, 6 Mar 2018 12:20:09 -0700 Subject: [PATCH 144/215] Remove default controllers from extension template. --- tethys_apps/cli/__init__.py | 1 + tethys_apps/cli/manage_commands.py | 2 + .../tethysext/+project+/controllers.py_tmpl | 74 ------------------- 3 files changed, 3 insertions(+), 74 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index 41b76fb7d..a55a24a46 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -185,6 +185,7 @@ def test_command(args): # print(primary_process) run_process(primary_process) + print(primary_process) if args.coverage: if args.file and (app_package_tag in args.file or extension_package_tag in args.file): run_process(['coverage', 'report']) diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index e211853bf..00ccde8ba 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -113,6 +113,8 @@ def manage_command(args): def run_process(process): # Call the process with a little trick to ignore the keyboard interrupt error when it happens + import os + print(os.getcwd()) try: if 'test' in process: set_testing_environment(True) diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/controllers.py_tmpl b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/controllers.py_tmpl index 85bfe1a7b..e69de29bb 100644 --- a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/controllers.py_tmpl +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/controllers.py_tmpl @@ -1,74 +0,0 @@ -from django.shortcuts import render -from django.contrib.auth.decorators import login_required -from tethys_sdk.gizmos import Button - -@login_required() -def home(request): - """ - Controller for the app home page. - """ - save_button = Button( - display_text='', - name='save-button', - icon='glyphicon glyphicon-floppy-disk', - style='success', - attributes={ - 'data-toggle':'tooltip', - 'data-placement':'top', - 'title':'Save' - } - ) - - edit_button = Button( - display_text='', - name='edit-button', - icon='glyphicon glyphicon-edit', - style='warning', - attributes={ - 'data-toggle':'tooltip', - 'data-placement':'top', - 'title':'Edit' - } - ) - - remove_button = Button( - display_text='', - name='remove-button', - icon='glyphicon glyphicon-remove', - style='danger', - attributes={ - 'data-toggle':'tooltip', - 'data-placement':'top', - 'title':'Remove' - } - ) - - previous_button = Button( - display_text='Previous', - name='previous-button', - attributes={ - 'data-toggle':'tooltip', - 'data-placement':'top', - 'title':'Previous' - } - ) - - next_button = Button( - display_text='Next', - name='next-button', - attributes={ - 'data-toggle':'tooltip', - 'data-placement':'top', - 'title':'Next' - } - ) - - context = { - 'save_button': save_button, - 'edit_button': edit_button, - 'remove_button': remove_button, - 'previous_button': previous_button, - 'next_button': next_button - } - - return render(request, '{{project}}/home.html', context) \ No newline at end of file From f9d54b8e4ba994b9f58c44ec46bfaa8a288d0f26 Mon Sep 17 00:00:00 2001 From: nswain Date: Tue, 6 Mar 2018 12:21:40 -0700 Subject: [PATCH 145/215] Revert print statement commit... --- tethys_apps/cli/__init__.py | 1 - tethys_apps/cli/manage_commands.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index a55a24a46..41b76fb7d 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -185,7 +185,6 @@ def test_command(args): # print(primary_process) run_process(primary_process) - print(primary_process) if args.coverage: if args.file and (app_package_tag in args.file or extension_package_tag in args.file): run_process(['coverage', 'report']) diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index 00ccde8ba..e211853bf 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -113,8 +113,6 @@ def manage_command(args): def run_process(process): # Call the process with a little trick to ignore the keyboard interrupt error when it happens - import os - print(os.getcwd()) try: if 'test' in process: set_testing_environment(True) From 599118f8f6925c469167679235f6302398ade88e Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 7 Mar 2018 12:19:39 -0700 Subject: [PATCH 146/215] Manually refactoring after merge complete. --- tethys_apps/apps.py | 3 +- tethys_apps/base/app_base.py | 67 +++++-- tethys_apps/harvester.py | 193 ++++++++++--------- tethys_apps/urls.py | 9 +- tethys_apps/utilities.py | 361 +---------------------------------- 5 files changed, 171 insertions(+), 462 deletions(-) diff --git a/tethys_apps/apps.py b/tethys_apps/apps.py index eed7096f5..1ac8d940c 100644 --- a/tethys_apps/apps.py +++ b/tethys_apps/apps.py @@ -22,7 +22,6 @@ def ready(self): """ # Perform App Harvesting harvester = SingletonHarvester() - harvester.harvest_extensions() - harvester.harvest_apps() + harvester.harvest() diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 8114b08c0..cf080d2f5 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -68,7 +68,7 @@ def url_maps(self): return url_maps """ - raise NotImplementedError() + return [] @property def url_patterns(self): @@ -77,8 +77,9 @@ def url_patterns(self): """ if self._url_patterns is None: + is_extension = isinstance(self, TethysExtensionBase) - app_url_patterns = dict() + url_patterns = dict() if hasattr(self, 'url_maps'): url_maps = self.url_maps() @@ -86,20 +87,22 @@ def url_patterns(self): url_maps = [] for url_map in url_maps: - app_root = self.root_url - app_namespace = app_root.replace('-', '_') + root_url = self.root_url + namespace = root_url.replace('-', '_') - if app_namespace not in app_url_patterns: - app_url_patterns[app_namespace] = [] + if namespace not in url_patterns: + url_patterns[namespace] = [] # Create django url object if isinstance(url_map.controller, basestring): - controller_parts = url_map.controller.split('.') + root_controller_path = 'tethysext' if is_extension else 'tethys_apps.tethysapp' + full_controller_path = '.'.join([root_controller_path, url_map.controller]) + controller_parts = full_controller_path.split('.') module_name = '.'.join(controller_parts[:-1]) function_name = controller_parts[-1] try: module = __import__(module_name, fromlist=[function_name]) - except: + except ImportError: error_msg = 'The following error occurred while trying to import the controller function ' \ '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) tethys_log.error(error_msg) @@ -107,7 +110,7 @@ def url_patterns(self): try: controller_function = getattr(module, function_name) except AttributeError as e: - error_msg = 'The following error occurred while tyring to access the controller function ' \ + error_msg = 'The following error occurred while trying to access the controller function ' \ '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) tethys_log.error(error_msg) raise @@ -116,11 +119,17 @@ def url_patterns(self): django_url = url(url_map.url, controller_function, name=url_map.name) # Append to namespace list - app_url_patterns[app_namespace].append(django_url) - self._url_patterns = app_url_patterns + url_patterns[namespace].append(django_url) + self._url_patterns = url_patterns return self._url_patterns + def sync_with_tethys_db(self): + """ + Sync installed apps with database. + """ + raise NotImplementedError + class TethysExtensionBase(TethysBase): """ Base class used to define the extension class for Tethys extensions. @@ -168,7 +177,43 @@ def url_maps(self): return url_maps """ + return [] + + def sync_with_tethys_db(self): + """ + Sync installed apps with database. + """ + from django.conf import settings + from tethys_apps.models import TethysExtension + try: + # Query to see if installed extension is in the database + db_extensions = TethysExtension.objects. \ + filter(package__exact=self.package). \ + all() + + # If the extension is not in the database, then add it + if len(db_extensions) == 0: + extension = TethysExtension( + name=self.name, + package=self.package, + description=self.description, + root_url=self.root_url, + ) + extension.save() + + # If the extension is in the database, update developer-first attributes + elif len(db_extensions) == 1: + db_extension = db_extensions[0] + db_extension.root_url = self.root_url + db_extension.save() + + if hasattr(settings, 'DEBUG') and settings.DEBUG: + db_extension.name = self.name + db_extension.description = self.description + db_extension.save() + except Exception as e: + tethys_log.error(e) class TethysAppBase(TethysBase): diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index f379e2cdf..53c5efca4 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -11,15 +11,20 @@ print_function, unicode_literals) from builtins import * +import traceback +import sys import os import inspect import logging import pkgutil +from django.conf.urls import url from django.db.utils import ProgrammingError from django.core.exceptions import ObjectDoesNotExist from tethys_apps.base import TethysAppBase, TethysExtensionBase +from tethys_apps.models import TethysApp, TethysExtension + from .terminal_colors import TerminalColors tethys_log = logging.getLogger('tethys.' + __name__) @@ -34,6 +39,13 @@ class SingletonHarvester(object): apps = [] _instance = None + def harvest(self): + """ + Harvest apps and extensions. + """ + self.harvest_extensions() + self.harvest_apps() + def harvest_extensions(self): """ @@ -119,34 +131,43 @@ def _harvest_extension_instances(self, extension_packages): for extension_name, extension_package in extension_packages.items(): - # Import the "ext" module from the extension package - ext_module = __import__(extension_package + ".ext", fromlist=['']) + try: + # Import the "ext" module from the extension package + ext_module = __import__(extension_package + ".ext", fromlist=['']) + + # Retrieve the members of the ext_module and iterate through + # them to find the the class that inherits from TethysExtensionBase. + for name, obj in inspect.getmembers(ext_module): + try: + # issubclass() will fail if obj is not a class + if (issubclass(obj, TethysExtensionBase)) and (obj is not TethysExtensionBase): + # Assign a handle to the class + ExtensionClass = getattr(ext_module, name) - # Retrieve the members of the ext_module and iterate through - # them to find the the class that inherits from TethysExtensionBase. - for name, obj in inspect.getmembers(ext_module): - try: - # issubclass() will fail if obj is not a class - if (issubclass(obj, TethysExtensionBase)) and (obj is not TethysExtensionBase): - # Assign a handle to the class - ExtensionClass = getattr(ext_module, name) + # Instantiate app and validate + ext_instance = ExtensionClass() + validated_ext_instance = self._validate_extension(ext_instance) - # Instantiate app and validate - ext_instance = ExtensionClass() - validated_ext_instance = self._validate_extension(ext_instance) + # sync app with Tethys db + ext_instance.sync_with_tethys_db() - # compile valid apps - if validated_ext_instance: - valid_ext_instances.append(validated_ext_instance) - valid_extension_modules[extension_name] = extension_package + # compile valid apps + if validated_ext_instance: + valid_ext_instances.append(validated_ext_instance) + valid_extension_modules[extension_name] = extension_package - # Notify user that the app has been loaded - loaded_extensions.append(extension_name) + # Notify user that the app has been loaded + loaded_extensions.append(extension_name) - except TypeError: - '''DO NOTHING''' - except: - raise + # We found the extension class so we're done + break + + except TypeError: + continue + except: + tethys_log.exception( + 'Extension {0} not loaded because of the following error:'.format(extension_package)) + continue # Save valid apps self.extensions = valid_ext_instances @@ -165,79 +186,79 @@ def _harvest_app_instances(self, app_packages_list): loaded_apps = [] for app_package in app_packages_list: - # Collect data from each app package in the apps directory - if app_package not in ['__init__.py', '__init__.pyc', '.gitignore', '.DS_Store']: - # Create the path to the app module in the custom app package - app_module_name = '.'.join(['tethys_apps.tethysapp', app_package, 'app']) - - try: - # Import the app.py module from the custom app package programmatically - # (e.g.: apps.apps..app) - app_module = __import__(app_module_name, fromlist=['']) - - - for name, obj in inspect.getmembers(app_module): - # Retrieve the members of the app_module and iterate through - # them to find the the class that inherits from AppBase. - try: - # issubclass() will fail if obj is not a class - if (issubclass(obj, TethysAppBase)) and (obj is not TethysAppBase): - # Assign a handle to the class - _appClass = getattr(app_module, name) - - # Instantiate app and validate - app_instance = _appClass() - validated_app_instance = self._validate_app(app_instance) - - # sync app with Tethys db - app_instance.sync_with_tethys_db() - - # validate app url patterns - app_instance.url_patterns - - # register app permissions - try: - app_instance.register_app_permissions() - except (ProgrammingError, ObjectDoesNotExist) as e: - tethys_log.error(e) - - # compile valid apps - if validated_app_instance: - valid_app_instance_list.append(validated_app_instance) - - # Notify user that the app has been loaded - loaded_apps.append(app_package) - - except TypeError: - '''DO NOTHING''' - except: - tethys_log.exception( - 'App {0} not loaded because of the following error:'.format(app_package)) - continue + # Skip these things + if app_package in ['__init__.py', '__init__.pyc', '.gitignore', '.DS_Store']: + continue + + # Create the path to the app module in the custom app package + app_module_name = '.'.join(['tethys_apps.tethysapp', app_package, 'app']) + + try: + # Import the app.py module from the custom app package programmatically + # (e.g.: apps.apps..app) + app_module = __import__(app_module_name, fromlist=['']) + + + for name, obj in inspect.getmembers(app_module): + # Retrieve the members of the app_module and iterate through + # them to find the the class that inherits from AppBase. + try: + # issubclass() will fail if obj is not a class + if (issubclass(obj, TethysAppBase)) and (obj is not TethysAppBase): + # Assign a handle to the class + AppClass = getattr(app_module, name) + + # Instantiate app and validate + app_instance = AppClass() + validated_app_instance = self._validate_app(app_instance) + + # sync app with Tethys db + app_instance.sync_with_tethys_db() + + # load/validate app url patterns + app_instance.url_patterns + + # register app permissions + try: + app_instance.register_app_permissions() + except (ProgrammingError, ObjectDoesNotExist) as e: + tethys_log.error(e) + + # compile valid apps + if validated_app_instance: + valid_app_instance_list.append(validated_app_instance) + + # Notify user that the app has been loaded + loaded_apps.append(app_package) + + # We found the app class so we're done + break + + except TypeError: + continue + except: + tethys_log.exception( + 'App {0} not loaded because of the following error:'.format(app_package)) + continue # Save valid apps self.apps = valid_app_instance_list - self.sync_tethys_app_db() - # Update user print(TerminalColors.BLUE + 'Tethys Apps Loaded: ' + TerminalColors.ENDC + '{0}'.format(', '.join(loaded_apps)) + '\n') - def sync_tethys_app_db(self): + def get_url_patterns(self): """ - Sync installed apps with database. + Generate the url pattern lists for each app and namespace them accordingly. """ - from tethys_apps.models import TethysApp + app_url_patterns = dict() + extension_url_patterns = dict() - try: - # Make pass to remove apps that were uninstalled - db_apps = TethysApp.objects.all() - installed_app_packages = [app.package for app in self.apps] + for app in self.apps: + app_url_patterns.update(app.url_patterns) - for db_apps in db_apps: - if db_apps.package not in installed_app_packages: - db_apps.delete() + for extension in self.extensions: + extension_url_patterns.update(extension.url_patterns) - except Exception as e: - tethys_log.error(e) \ No newline at end of file + return app_url_patterns, extension_url_patterns diff --git a/tethys_apps/urls.py b/tethys_apps/urls.py index ce1d7a2c2..8ab48b580 100644 --- a/tethys_apps/urls.py +++ b/tethys_apps/urls.py @@ -10,14 +10,17 @@ # from django.db.utils import ProgrammingError # from django.core.exceptions import ObjectDoesNotExist from django.conf.urls import url, include -from tethys_apps.utilities import generate_url_patterns, sync_tethys_db, register_app_permissions +from tethys_apps.harvester import SingletonHarvester from tethys_apps.views import library, send_beta_feedback_email import logging tethys_log = logging.getLogger('tethys.' + __name__) +# Get the harvester +harvester = SingletonHarvester() + # Sync the tethys apps database -sync_tethys_db() +harvester.sync_tethys_db() urlpatterns = [ url(r'^$', library, name='app_library'), @@ -25,7 +28,7 @@ ] # Append the app urls urlpatterns -app_url_patterns, extension_url_patterns = generate_url_patterns() +app_url_patterns, extension_url_patterns = harvester.get_url_patterns() for namespace, urls in app_url_patterns.items(): root_pattern = r'^{0}/'.format(namespace.replace('_', '-')) diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index d6b3c7def..f1fb41ca1 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -9,228 +9,15 @@ """ import logging import os -import sys -import traceback -from collections import OrderedDict as SortedDict -from django.conf.urls import url from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.utils._os import safe_join from tethys_apps.harvester import SingletonHarvester -from tethys_apps.base import permissions, TethysExtensionBase -from tethys_apps.models import TethysApp, TethysExtension +from tethys_apps.models import TethysApp tethys_log = logging.getLogger('tethys.' + __name__) -def register_app_permissions(): - """ - Register and sync the app permissions. - """ - from guardian.shortcuts import assign_perm, remove_perm, get_perms - from django.contrib.contenttypes.models import ContentType - from django.contrib.auth.models import Permission, Group - - # Get the apps - harvester = SingletonHarvester() - apps = harvester.apps - all_app_permissions = {} - all_groups = {} - - for app in apps: - perms = app.permissions() - - # Name spaced prefix for app permissions - # e.g. my_first_app:view_things - # e.g. my_first_app | View things - perm_codename_prefix = app.package + ':' - perm_name_prefix = app.package + ' | ' - - if perms is not None: - # Thing is either a Permission or a PermissionGroup object - - for thing in perms: - # Permission Case - if isinstance(thing, permissions.Permission): - # Name space the permissions and add it to the list - permission_codename = perm_codename_prefix + thing.name - permission_name = perm_name_prefix + thing.description - all_app_permissions[permission_codename] = permission_name - - # PermissionGroup Case - elif isinstance(thing, permissions.PermissionGroup): - # Record in dict of groups - group_permissions = [] - group_name = perm_codename_prefix + thing.name - - for perm in thing.permissions: - # Name space the permissions and add it to the list - permission_codename = perm_codename_prefix + perm.name - permission_name = perm_name_prefix + perm.description - all_app_permissions[permission_codename] = permission_name - group_permissions.append(permission_codename) - - # Store all groups for all apps - all_groups[group_name] = {'permissions': group_permissions, 'app_package': app.package} - - # Get the TethysApp content type - tethys_content_type = ContentType.objects.get( - app_label='tethys_apps', - model='tethysapp' - ) - - # Remove any permissions that no longer exist - db_app_permissions = Permission.objects.filter(content_type=tethys_content_type).all() - - for db_app_permission in db_app_permissions: - # Delete the permission if the permission is no longer required by an app - if db_app_permission.codename not in all_app_permissions: - db_app_permission.delete() - - # Create permissions that need to be created - for perm in all_app_permissions: - # Create permission if it doesn't exist - try: - # If permission exists, update it - p = Permission.objects.get(codename=perm) - - p.name = all_app_permissions[perm] - p.content_type = tethys_content_type - p.save() - - except Permission.DoesNotExist: - p = Permission( - name=all_app_permissions[perm], - codename=perm, - content_type=tethys_content_type - ) - p.save() - - # Remove any groups that no longer exist - db_groups = Group.objects.all() - db_apps = TethysApp.objects.all() - db_app_names = [db_app.package for db_app in db_apps] - - for db_group in db_groups: - db_group_name_parts = db_group.name.split(':') - - # Only perform maintenance on groups that belong to Tethys Apps - if (len(db_group_name_parts) > 1) and (db_group_name_parts[0] in db_app_names): - - # Delete groups that is no longer required by an app - if db_group.name not in all_groups: - db_group.delete() - - # Create groups that need to be created - for group in all_groups: - # Look up the app - db_app = TethysApp.objects.get(package=all_groups[group]['app_package']) - - # Create group if it doesn't exist - try: - # If it exists, update the permissions assigned to it - g = Group.objects.get(name=group) - - # Get the permissions for the group and remove all of them - perms = get_perms(g, db_app) - - for p in perms: - remove_perm(p, g, db_app) - - # Assign the permission to the group and the app instance - for p in all_groups[group]['permissions']: - assign_perm(p, g, db_app) - - except Group.DoesNotExist: - # Create a new group - g = Group(name=group) - g.save() - - # Assign the permission to the group and the app instance - for p in all_groups[group]['permissions']: - assign_perm(p, g, db_app) - - -def generate_url_patterns(): - """ - Generate the url pattern lists for each app and namespace them accordingly. - """ - - # Get controllers list from app harvester - harvester = SingletonHarvester() - apps = harvester.apps - extensions = harvester.extensions - app_url_patterns = dict() - extension_url_patterns = dict() - - for app in apps: - app_url_patterns.update(get_django_urls_for(app)) - - for extension in extensions: - extension_url_patterns.update(get_django_urls_for(extension)) - - return app_url_patterns, extension_url_patterns - - -def get_django_urls_for(app_or_extension): - """ - Get all UrlMaps from the app or extension given and convert to django urls. - - Args: - app_or_extension(TethysApp or TethysExtension): TethysApp or TethysExtension instance. - - Returns: - dictionary: django urls grouped by namespace. - """ - is_extension = isinstance(app_or_extension, TethysExtensionBase) - url_patterns = dict() - - if hasattr(app_or_extension, 'url_maps'): - url_maps = app_or_extension.url_maps() - elif hasattr(app_or_extension, 'controllers'): - url_maps = app_or_extension.controllers() - else: - url_maps = None - - if url_maps: - for url_map in url_maps: - root_url = app_or_extension.root_url - namespace = root_url.replace('-', '_') - - if namespace not in url_patterns: - url_patterns[namespace] = [] - - # Create django url object - if isinstance(url_map.controller, basestring): - root_controller_path = 'tethysext' if is_extension else 'tethys_apps.tethysapp' - full_controller_path = '.'.join([root_controller_path, url_map.controller]) - controller_parts = full_controller_path.split('.') - module_name = '.'.join(controller_parts[:-1]) - function_name = controller_parts[-1] - try: - module = __import__(module_name, fromlist=[function_name]) - except ImportError: - error_msg = 'The following error occurred while trying to import the controller function ' \ - '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) - tethys_log.error(error_msg) - sys.exit(1) - try: - controller_function = getattr(module, function_name) - except AttributeError as e: - error_msg = 'The following error occurred while trying to access the controller function ' \ - '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) - tethys_log.error(error_msg) - sys.exit(1) - else: - controller_function = url_map.controller - django_url = url(url_map.url, controller_function, name=url_map.name) - - # Append to namespace list - url_patterns[namespace].append(django_url) - - return url_patterns - - def get_directories_in_tethys(directory_names, with_app_name=False): """ # Locate given directories in tethys apps and extensions. @@ -274,152 +61,6 @@ def get_directories_in_tethys(directory_names, with_app_name=False): return match_dirs -def sync_tethys_db(): - """ - Sync installed apps and extensions with database. - """ - # Get the harvester - harvester = SingletonHarvester() - - try: - # Make pass to remove apps that were uninstalled - db_apps = TethysApp.objects.all() - installed_app_packages = [app.package for app in harvester.apps] - - for db_apps in db_apps: - if db_apps.package not in installed_app_packages: - db_apps.delete() - - # Make pass to remove extensions that were uninstalled - db_extensions = TethysExtension.objects.all() - installed_extension_packages = [extension.package for extension in harvester.extensions] - - for db_extensions in db_extensions: - if db_extensions.package not in installed_extension_packages: - db_extensions.delete() - - # Make pass to add apps to db that are newly installed - installed_extensions = harvester.extensions - installed_apps = harvester.apps - - - for installed_app in installed_apps: - # map extension to db - map_app_to_db(installed_app) - - for installed_extension in installed_extensions: - # map extension to db - map_extension_to_db(installed_extension) - except Exception as e: - tethys_log.error(e) - - -def map_app_to_db(installed_app): - """ - Sync installed apps with database. - """ - from django.conf import settings - - # Query to see if installed app is in the database - db_apps = TethysApp.objects. \ - filter(package__exact=installed_app.package). \ - all() - - # If the app is not in the database, then add it - if len(db_apps) == 0: - app = TethysApp( - name=installed_app.name, - package=installed_app.package, - description=installed_app.description, - enable_feedback=installed_app.enable_feedback, - feedback_emails=installed_app.feedback_emails, - index=installed_app.index, - icon=installed_app.icon, - root_url=installed_app.root_url, - color=installed_app.color, - tags=installed_app.tags - ) - app.save() - - # custom settings - app.add_settings(installed_app.custom_settings()) - # dataset services settings - app.add_settings(installed_app.dataset_service_settings()) - # spatial dataset services settings - app.add_settings(installed_app.spatial_dataset_service_settings()) - # wps settings - app.add_settings(installed_app.web_processing_service_settings()) - # persistent store settings - app.add_settings(installed_app.persistent_store_settings()) - - app.save() - - # If the app is in the database, update developer-first attributes - elif len(db_apps) == 1: - db_app = db_apps[0] - db_app.index = installed_app.index - db_app.icon = installed_app.icon - db_app.root_url = installed_app.root_url - db_app.color = installed_app.color - db_app.save() - - if hasattr(settings, 'DEBUG') and settings.DEBUG: - db_app.name = installed_app.name - db_app.description = installed_app.description - db_app.tags = installed_app.tags - db_app.enable_feedback = installed_app.enable_feedback - db_app.feedback_emails = installed_app.feedback_emails - db_app.save() - - # custom settings - db_app.add_settings(installed_app.custom_settings()) - # dataset services settings - db_app.add_settings(installed_app.dataset_service_settings()) - # spatial dataset services settings - db_app.add_settings(installed_app.spatial_dataset_service_settings()) - # wps settings - db_app.add_settings(installed_app.web_processing_service_settings()) - # persistent store settings - db_app.add_settings(installed_app.persistent_store_settings()) - db_app.save() - - -def map_extension_to_db(installed_extension): - """ - A function to map extension to the db - - Args: - installed_extension(TethysExtension): extension to be mapped to db - """ - from django.conf import settings - - # Query to see if installed extension is in the database - db_extensions = TethysExtension.objects. \ - filter(package__exact=installed_extension.package). \ - all() - - # If the extension is not in the database, then add it - if len(db_extensions) == 0: - extension = TethysExtension( - name=installed_extension.name, - package=installed_extension.package, - description=installed_extension.description, - root_url=installed_extension.root_url, - ) - extension.save() - - # If the extension is in the database, update developer-first attributes - elif len(db_extensions) == 1: - db_extension = db_extensions[0] - db_extension.root_url = installed_extension.root_url - db_extension.save() - - if hasattr(settings, 'DEBUG') and settings.DEBUG: - db_extension.name = installed_extension.name - db_extension.description = installed_extension.description - db_extension.save() - - def get_active_app(request=None, url=None): """ Get the active TethysApp object based on the request or URL. From fd2187b16dd8ccf0e409d777023616090d0e2fd3 Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 7 Mar 2018 13:21:01 -0700 Subject: [PATCH 147/215] Fixed bug with uninstalling apps and extensions. --- tethys_apps/base/app_base.py | 18 ++++++++ .../default/tethysext/+project+/ext.py_tmpl | 4 +- .../tethysext/+project+/tests/tests.py_tmpl | 8 ++-- tethys_apps/harvester.py | 46 +++++++++---------- .../commands/tethys_app_uninstall.py | 11 +++-- tethys_apps/urls.py | 16 +------ 6 files changed, 55 insertions(+), 48 deletions(-) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index cf080d2f5..e6a875c28 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -130,6 +130,12 @@ def sync_with_tethys_db(self): """ raise NotImplementedError + def remove_from_db(self): + """ + Remove the instance from the db. + """ + raise NotImplementedError + class TethysExtensionBase(TethysBase): """ Base class used to define the extension class for Tethys extensions. @@ -1329,6 +1335,18 @@ def sync_with_tethys_db(self): except Exception as e: tethys_log.error(e) + def remove_from_db(self): + """ + Remove the instance from the db. + """ + from tethys_apps.models import TethysApp + + try: + # Attempt to delete the object + TethysApp.objects.filter(package__exact=self.package).delete() + except Exception as e: + tethys_log.error(e) + @classmethod def _log_tethys_app_setting_not_assigned_error(cls, setting_type, setting_name): """ diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/ext.py_tmpl b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/ext.py_tmpl index 70c51c388..66f6f25d5 100644 --- a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/ext.py_tmpl +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/ext.py_tmpl @@ -3,10 +3,10 @@ from tethys_sdk.base import TethysExtensionBase class {{class_name}}(TethysExtensionBase): """ - Tethys app class for {{proper_name}}. + Tethys extension class for {{proper_name}}. """ name = '{{proper_name}}' package = '{{project}}' root_url = '{{project_url}}' - description = '{{description|default:"Place a brief description of your app here."}}' + description = '{{description|default:"Place a brief description of your extension here."}}' diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl index 8713d52b0..46d9d4aa3 100644 --- a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/tests/tests.py_tmpl @@ -28,7 +28,7 @@ To run any tests: The general form is: "tethys test -f tethysext....." See below for specific examples - To run all tests across this app: + To run all tests across this extension: Test command: "tethys test -f tethysext.{{project}}" To run all tests in this file: @@ -48,7 +48,7 @@ To learn more about writing tests, see: class {{class_name}}TestCase(TethysTestCase): """ - In this class you may define as many functions as you'd like to test different aspects of your app. + In this class you may define as many functions as you'd like to test different aspects of your extension. Each function must start with the word "test" for it to be recognized and executed during testing. You could also create multiple TethysTestCase classes within this or other python files to organize your tests. """ @@ -62,7 +62,7 @@ class {{class_name}}TestCase(TethysTestCase): If you are testing against a controller that check for certain user info, you can create a fake test user and get a test client, like so: - #The test client simulates a browser that can navigate your app's url endpoints + #The test client simulates a browser that can navigate your extension's url endpoints self.c = self.get_test_client() self.user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") # To create a super_user, use "self.create_test_superuser(*params)" with the same params @@ -99,7 +99,7 @@ class {{class_name}}TestCase(TethysTestCase): def test_if_tethys_platform_is_great(self): """ - This is an example test function that can be modified to test a specific aspect of your app. + This is an example test function that can be modified to test a specific aspect of your extension. It is required that the function name begins with the word "test" or it will not be executed. Generally, the code written here will consist of many assert methods. A list of assert methods is included here for reference or to get you started: diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index 53c5efca4..3155eb5d1 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -11,21 +11,15 @@ print_function, unicode_literals) from builtins import * -import traceback -import sys import os import inspect import logging import pkgutil -from django.conf.urls import url from django.db.utils import ProgrammingError from django.core.exceptions import ObjectDoesNotExist - from tethys_apps.base import TethysAppBase, TethysExtensionBase -from tethys_apps.models import TethysApp, TethysExtension - -from .terminal_colors import TerminalColors +from tethys_apps.terminal_colors import TerminalColors tethys_log = logging.getLogger('tethys.' + __name__) @@ -76,6 +70,21 @@ def harvest_apps(self): # Harvest App Instances self._harvest_app_instances(app_packages_list) + + def get_url_patterns(self): + """ + Generate the url pattern lists for each app and namespace them accordingly. + """ + app_url_patterns = dict() + extension_url_patterns = dict() + + for app in self.apps: + app_url_patterns.update(app.url_patterns) + + for extension in self.extensions: + extension_url_patterns.update(extension.url_patterns) + + return app_url_patterns, extension_url_patterns def __new__(cls): """ @@ -216,7 +225,13 @@ def _harvest_app_instances(self, app_packages_list): app_instance.sync_with_tethys_db() # load/validate app url patterns - app_instance.url_patterns + try: + app_instance.url_patterns + except: + tethys_log.exception( + 'App {0} not loaded because of an issue with loading urls:'.format(app_package)) + app_instance.remove_from_db() + continue # register app permissions try: @@ -247,18 +262,3 @@ def _harvest_app_instances(self, app_packages_list): # Update user print(TerminalColors.BLUE + 'Tethys Apps Loaded: ' + TerminalColors.ENDC + '{0}'.format(', '.join(loaded_apps)) + '\n') - - def get_url_patterns(self): - """ - Generate the url pattern lists for each app and namespace them accordingly. - """ - app_url_patterns = dict() - extension_url_patterns = dict() - - for app in self.apps: - app_url_patterns.update(app.url_patterns) - - for extension in self.extensions: - extension_url_patterns.update(extension.url_patterns) - - return app_url_patterns, extension_url_patterns diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index 20a60606b..043b971ed 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -32,15 +32,14 @@ def handle(self, *args, **options): app_or_extension = "App" if not options['is_extension'] else 'Extension' PREFIX = 'tethysapp' if not options['is_extension'] else 'tethysext' item_name = options['app_or_extension'][0] - installed_items = get_installed_tethys_apps() - installed_items.update(get_installed_tethys_extensions()) + installed_items = get_installed_tethys_extensions() if options['is_extension'] else get_installed_tethys_apps() if PREFIX in item_name: prefix_length = len(PREFIX) + 1 item_name = item_name[prefix_length:] if item_name not in installed_items: - warnings.warn('WARNING: {} with name "{}" cannot be uninstalled, because it is not installed.'.format(app_or_extension, item_name)) + warnings.warn('WARNING: {0} with name "{1}" cannot be uninstalled, because it is not installed or not an {0}.'.format(app_or_extension, item_name)) exit(0) item_with_prefix = '{0}-{1}'.format(PREFIX, item_name) @@ -84,9 +83,13 @@ def handle(self, *args, **options): except KeyboardInterrupt: pass + # Remove the namespace package file if applicable. for site_package in site.getsitepackages(): try: - os.remove(os.path.join(site_package, "{}-{}-nspkg.pth".format(PREFIX, item_name))) + print("******") + print(site_package) + print(os.path.join(site_package, "{}-{}-nspkg.pth".format(PREFIX, item_name.replace('_','-')))) + os.remove(os.path.join(site_package, "{}-{}-nspkg.pth".format(PREFIX, item_name.replace('_','-')))) except: continue diff --git a/tethys_apps/urls.py b/tethys_apps/urls.py index 8ab48b580..0b61676b6 100644 --- a/tethys_apps/urls.py +++ b/tethys_apps/urls.py @@ -7,8 +7,6 @@ * License: BSD 2-Clause ******************************************************************************** """ -# from django.db.utils import ProgrammingError -# from django.core.exceptions import ObjectDoesNotExist from django.conf.urls import url, include from tethys_apps.harvester import SingletonHarvester from tethys_apps.views import library, send_beta_feedback_email @@ -16,18 +14,13 @@ tethys_log = logging.getLogger('tethys.' + __name__) -# Get the harvester -harvester = SingletonHarvester() - -# Sync the tethys apps database -harvester.sync_tethys_db() - urlpatterns = [ url(r'^$', library, name='app_library'), url(r'^send-beta-feedback/$', send_beta_feedback_email, name='send_beta_feedback'), ] # Append the app urls urlpatterns +harvester = SingletonHarvester() app_url_patterns, extension_url_patterns = harvester.get_url_patterns() for namespace, urls in app_url_patterns.items(): @@ -39,10 +32,3 @@ for namespace, urls in extension_url_patterns.items(): root_pattern = r'^{0}/'.format(namespace.replace('_', '-')) extension_urls.append(url(root_pattern, include(urls, namespace=namespace))) - -# # Register permissions here? -# try: -# register_app_permissions() -# except (ProgrammingError, ObjectDoesNotExist) as e: -# tethys_log.error(e) - From 7e6291b187e76bc17c4fd61a1a2e0a42fe8c36dd Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 7 Mar 2018 13:23:20 -0700 Subject: [PATCH 148/215] Removed the dang prints!@#!@ --- tethys_apps/management/commands/tethys_app_uninstall.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index 043b971ed..a9c852096 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -86,9 +86,6 @@ def handle(self, *args, **options): # Remove the namespace package file if applicable. for site_package in site.getsitepackages(): try: - print("******") - print(site_package) - print(os.path.join(site_package, "{}-{}-nspkg.pth".format(PREFIX, item_name.replace('_','-')))) os.remove(os.path.join(site_package, "{}-{}-nspkg.pth".format(PREFIX, item_name.replace('_','-')))) except: continue From bc4e32fd4442bbff9f41f55147c13f36ee82b1a8 Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 7 Mar 2018 13:58:15 -0700 Subject: [PATCH 149/215] Added gitlab-ci. --- .gitlab-ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 000000000..a73b42ac1 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,29 @@ +image: docker:git + +stages: + - build + +before_script: + # Set up ssh-agent for use when checking out other repos + - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" > key + - chmod 600 key + - ssh-add key + - ssh-add -l + # Add known host keys + - mkdir -p ~/.ssh + - '[[ -f /.dockerenv ]] && echo "$SSH_SERVER_HOSTKEYS" > ~/.ssh/known_hosts' + +build: + stage: build + script: + # Docker Stuff + - docker build --squash -t $CI_REGISTRY_IMAGE/tethyscore:$CI_COMMIT_TAG -t $CI_REGISTRY_IMAGE/tethyscore:latest . + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker push $CI_REGISTRY_IMAGE/tethyscore:$CI_COMMIT_TAG + - docker push $CI_REGISTRY_IMAGE/tethyscore:latest + tags: + - docker + only: + - tags From d09fb471844f9f1ffad7852adca958558ee305ae Mon Sep 17 00:00:00 2001 From: nswain Date: Tue, 20 Mar 2018 15:47:44 -0600 Subject: [PATCH 150/215] Fixed typo in app base, fixed issue with get_persistent_store_setting methods, fixed an issue with function extractor, made has_permission decorator able to handle methods, fixed several utf encoding issues. --- tethys_apps/base/app_base.py | 12 ++++--- tethys_apps/base/function_extractor.py | 2 +- tethys_apps/decorators.py | 47 +++++++++++++++++++++----- tethys_apps/exceptions.py | 2 +- tethys_apps/models.py | 14 ++++---- 5 files changed, 56 insertions(+), 21 deletions(-) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index e6a875c28..78cea5497 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -982,12 +982,14 @@ def get_persistent_store_connection(cls, name, as_url=False, as_sessionmaker=Fal ps_connection_settings = db_app.persistent_store_connection_settings try: + # Return as_engine if the other two are False + as_engine = not as_sessionmaker and not as_url ps_connection_setting = ps_connection_settings.get(name=name) return ps_connection_setting.get_value(as_url=as_url, as_sessionmaker=as_sessionmaker, as_engine=as_engine) except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist('PersistentStoreConnectionSetting', name, cls.name) except TethysAppSettingNotAssigned: - cls._log_tethys_app_setting_not_assigned_errror('PersistentStoreConnectionSetting', name) + cls._log_tethys_app_setting_not_assigned_error('PersistentStoreConnectionSetting', name) @classmethod def get_persistent_store_database(cls, name, as_url=False, as_sessionmaker=False): @@ -1021,13 +1023,15 @@ def get_persistent_store_database(cls, name, as_url=False, as_sessionmaker=False verified_name = name if not is_testing_environment() else get_test_db_name(name) try: + # Return as_engine if the other two are False + as_engine = not as_sessionmaker and not as_url ps_database_setting = ps_database_settings.get(name=verified_name) return ps_database_setting.get_value(with_db=True, as_url=as_url, as_sessionmaker=as_sessionmaker, - as_engine=True) + as_engine=as_engine) except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist('PersistentStoreDatabaseSetting', verified_name, cls.name) except TethysAppSettingNotAssigned: - cls._log_tethys_app_setting_not_assigned_errror('PersistentStoreConnectionSetting', verified_name) + cls._log_tethys_app_setting_not_assigned_error('PersistentStoreConnectionSetting', verified_name) @classmethod def create_persistent_store(cls, db_name, connection_name, spatial=False, initializer='', refresh=False, @@ -1362,5 +1366,5 @@ def _log_tethys_app_setting_not_assigned_error(cls, setting_type, setting_name): tethys_log.warn('Tethys app setting is not assigned.\nTraceback (most recent call last):\n{0} ' 'TethysAppSettingNotAssigned: {1} named "{2}" has not been assigned. ' 'Please visit the setting page for the app {3} and assign all required settings.' - .format(traceback.format_stack(limit=3)[0], setting_type, setting_name, cls.name) + .format(traceback.format_stack(limit=3)[0], setting_type, setting_name, cls.name.encode('utf-8')) ) diff --git a/tethys_apps/base/function_extractor.py b/tethys_apps/base/function_extractor.py index 55027ae87..3dd0abe84 100644 --- a/tethys_apps/base/function_extractor.py +++ b/tethys_apps/base/function_extractor.py @@ -48,7 +48,7 @@ def function(self): full_module_path = '.'.join((self.prefix, module_path)) if self.prefix else module_path # Import module - module = __import__(full_module_path, fromlist=[function_name]) + module = __import__(full_module_path, fromlist=[str(function_name)]) except (ValueError, ImportError) as e: self._valid = False diff --git a/tethys_apps/decorators.py b/tethys_apps/decorators.py index 83ba0c855..e50908be0 100644 --- a/tethys_apps/decorators.py +++ b/tethys_apps/decorators.py @@ -12,6 +12,7 @@ except ImportError: from urllib.parse import urlparse +from django.core.handlers.wsgi import WSGIRequest from django.contrib import messages from django.urls import reverse from django.shortcuts import redirect @@ -86,16 +87,40 @@ def my_controller(request): use_or = kwargs.pop('use_or', False) message = kwargs.pop('message', "We're sorry, but you are not allowed to perform this operation.") raise_exception = kwargs.pop('raise_exception', False) - - for arg in args: - if not isinstance(arg, basestring): - raise ValueError("Arguments must be a string and the name of a permission for the app.") - - perms = args + perms = [arg for arg in args if isinstance(arg, basestring)] def decorator(controller_func): - def _wrapped_controller(request, *args, **kwargs): + def _wrapped_controller(*args, **kwargs): # With OR check, we assume the permission test passes upfront + # Find request (varies position if class method is wrapped) + # e.g.: func(request, *args, **kwargs) vs. method(self, request, *args, **kwargs) + request_args_index = None + the_self = None + + for index, arg in enumerate(args): + print(arg) + if isinstance(arg, WSGIRequest): + request_args_index = index + + print('*** ARGS AND REQUEST') + print(args) + print(kwargs) + print(request_args_index) + + # Args are everything after the request object + if request_args_index is not None: + request = args[request_args_index] + else: + raise ValueError("No WSGIRequest object provided.") + + if request_args_index > 0: + the_self = args[0] + + args = args[request_args_index+1:] + + print(request) + print(the_self) + print(request_args_index) # Check permission pass_permission_test = True @@ -159,6 +184,12 @@ def _wrapped_controller(request, *args, **kwargs): else: return tethys_portal_error.handler_403(request) - return controller_func(request, *args, **kwargs) + # Call the controller + if the_self is not None: + response = controller_func(the_self, request, *args, **kwargs) + else: + response = controller_func(request, *args, **kwargs) + + return response return wraps(controller_func)(_wrapped_controller) return decorator \ No newline at end of file diff --git a/tethys_apps/exceptions.py b/tethys_apps/exceptions.py index 512fae1bf..fa5e5a742 100644 --- a/tethys_apps/exceptions.py +++ b/tethys_apps/exceptions.py @@ -12,7 +12,7 @@ class TethysAppSettingDoesNotExist(Exception): def __init__(self, setting_type, setting_name, app_name, *args, **kwargs): msg = 'A {0} named "{1}" does not exist in the {2} app.'\ - .format(setting_type, setting_name, app_name) + .format(setting_type, setting_name, app_name.encode('utf-8')) super(TethysAppSettingDoesNotExist, self).__init__(msg, *args, **kwargs) diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 8185d80b5..91663a7a3 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -507,8 +507,8 @@ def get_value(self, as_url=False, as_sessionmaker=False, as_engine=False): if ps_service is None: raise TethysAppSettingNotAssigned('Cannot create engine or url for PersistentStoreConnection "{0}" for app ' '"{1}": no PersistentStoreService found.'.format(self.name, - self.tethys_app.package)) - # TODO order here manters. Is this the order we want? + self.tethys_app.package)) + # Order matters here. Think carefully before changing... if as_engine: return ps_service.get_engine() @@ -585,7 +585,7 @@ def get_namespaced_persistent_store_name(self): return '_'.join((self.tethys_app.package, safe_name)) - def get_value(self, with_db=False, as_url=False, as_sessionmaker=False, as_engine=True): + def get_value(self, with_db=False, as_url=False, as_sessionmaker=False, as_engine=False): """ Get the SQLAlchemy engine from the connected persistent store service """ @@ -596,19 +596,19 @@ def get_value(self, with_db=False, as_url=False, as_sessionmaker=False, as_engin raise TethysAppSettingNotAssigned('Cannot create engine or url for PersistentStoreDatabase "{0}" for app ' '"{1}": no PersistentStoreService found.'.format(self.name, self.tethys_app.package)) - # TODO order here manters. Is this the order we want? if with_db: ps_service.database = self.get_namespaced_persistent_store_name() + # Order matters here. Think carefully before changing... + if as_engine: + return ps_service.get_engine() + if as_sessionmaker: return sessionmaker(bind=ps_service.get_engine()) if as_url: return ps_service.get_url() - if as_engine: - return ps_service.get_engine() - return ps_service def persistent_store_database_exists(self): From 141a621492d2f14c9599e2891f0088b1fdcd0464 Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 22 Mar 2018 09:29:12 -0600 Subject: [PATCH 151/215] Removed print statements used for debugging. --- tethys_apps/decorators.py | 13 ------------- tethys_apps/models.py | 6 +++--- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/tethys_apps/decorators.py b/tethys_apps/decorators.py index e50908be0..da5aa7fbb 100644 --- a/tethys_apps/decorators.py +++ b/tethys_apps/decorators.py @@ -98,15 +98,9 @@ def _wrapped_controller(*args, **kwargs): the_self = None for index, arg in enumerate(args): - print(arg) if isinstance(arg, WSGIRequest): request_args_index = index - print('*** ARGS AND REQUEST') - print(args) - print(kwargs) - print(request_args_index) - # Args are everything after the request object if request_args_index is not None: request = args[request_args_index] @@ -118,13 +112,6 @@ def _wrapped_controller(*args, **kwargs): args = args[request_args_index+1:] - print(request) - print(the_self) - print(request_args_index) - - # Check permission - pass_permission_test = True - # OR Loop if use_or: pass_permission_test = False diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 91663a7a3..62bfbe153 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -22,8 +22,9 @@ try: from tethys_services.models import (DatasetService, SpatialDatasetService, WebProcessingService, PersistentStoreService) -except RuntimeError as e: - print(e) +except RuntimeError: + log = logging.getLogger('tethys') + log.exception('An error occurred while trying to import tethys service models.') from tethys_apps.base.function_extractor import TethysFunctionExtractor @@ -783,7 +784,6 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False else: self.initializer_function(self.get_value(with_db=True, as_engine=True), not self.initialized) except Exception as e: - print(type(e)) raise PersistentStoreInitializerError(e) # Update initialization From 1ef3c0f311c064c8de17630d271c51943724db7f Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 22 Mar 2018 10:17:30 -0600 Subject: [PATCH 152/215] Added base TethysController class based view. --- tethys_apps/base/controller.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tethys_apps/base/controller.py b/tethys_apps/base/controller.py index f55f18e64..44b91f391 100644 --- a/tethys_apps/base/controller.py +++ b/tethys_apps/base/controller.py @@ -7,7 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ - +from django.views.generic import View from .url_map import UrlMapBase @@ -19,3 +19,11 @@ def app_controller_maker(root_url): return type('UrlMap', (UrlMapBase,), properties) +class TethysController(View): + + @classmethod + def as_controller(cls, *args, **kwargs): + """ + Thin veneer around the as_view method to make interface more consistent with Tethys terminology. + """ + return cls.as_view(*args, **kwargs) From 8df9bd56bbe425be63cf5e6dd80d72d4d9ebfb0b Mon Sep 17 00:00:00 2001 From: nswain Date: Fri, 23 Mar 2018 12:57:48 -0600 Subject: [PATCH 153/215] Added namespace property for tethys apps. --- tethys_apps/base/app_base.py | 13 +++++++++++-- tethys_sdk/base.py | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 78cea5497..63fbd1b8a 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -37,6 +37,7 @@ class TethysBase(object): def __init__(self): self._url_patterns = None + self._namespace = None def url_maps(self): """ @@ -70,6 +71,15 @@ def url_maps(self): """ return [] + @property + def namespace(self): + """ + Get the namespace for the app. + """ + if self._namespace is None: + self._namespace = self.root_url.replace('-', '_') + return self._namespace + @property def url_patterns(self): """ @@ -87,8 +97,7 @@ def url_patterns(self): url_maps = [] for url_map in url_maps: - root_url = self.root_url - namespace = root_url.replace('-', '_') + namespace = self.namespace if namespace not in url_patterns: url_patterns[namespace] = [] diff --git a/tethys_sdk/base.py b/tethys_sdk/base.py index dba498234..c274b8033 100644 --- a/tethys_sdk/base.py +++ b/tethys_sdk/base.py @@ -10,3 +10,4 @@ # DO NOT ERASE from tethys_apps.base import TethysAppBase, TethysExtensionBase from tethys_apps.base.url_map import url_map_maker +from tethys_apps.base.controller import TethysController From b8988e9db5eb57cd37a3da12f9f3adc35b7220be Mon Sep 17 00:00:00 2001 From: nswain Date: Mon, 2 Apr 2018 13:56:22 -0600 Subject: [PATCH 154/215] Added namespace property to tethys apps and context processor. --- tethys_apps/base/app_base.py | 12 ++---------- tethys_apps/base/mixins.py | 18 ++++++++++++++++++ tethys_apps/context_processors.py | 3 ++- tethys_apps/models.py | 5 +++-- 4 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 tethys_apps/base/mixins.py diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 63fbd1b8a..9ea1ba9e8 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -21,12 +21,13 @@ from tethys_apps.base import permissions from .handoff import HandoffManager from .workspace import TethysWorkspace +from .mixins import TethysBaseMixin from ..exceptions import TethysAppSettingDoesNotExist, TethysAppSettingNotAssigned tethys_log = logging.getLogger('tethys.app_base') -class TethysBase(object): +class TethysBase(TethysBaseMixin): """ Abstract base class of app and extension classes. """ @@ -71,15 +72,6 @@ def url_maps(self): """ return [] - @property - def namespace(self): - """ - Get the namespace for the app. - """ - if self._namespace is None: - self._namespace = self.root_url.replace('-', '_') - return self._namespace - @property def url_patterns(self): """ diff --git a/tethys_apps/base/mixins.py b/tethys_apps/base/mixins.py new file mode 100644 index 000000000..2790a9287 --- /dev/null +++ b/tethys_apps/base/mixins.py @@ -0,0 +1,18 @@ +class TethysBaseMixin(object): + """ + Provides methods and properties common to the TethysBase and model classes. + """ + root_url = '' + + @property + def namespace(self): + """ + Get the namespace for the app or extension. + """ + if not hasattr(self, '_namespace'): + self._namespace = None + + if self._namespace is None: + self._namespace = self.root_url.replace('-', '_') + + return self._namespace diff --git a/tethys_apps/context_processors.py b/tethys_apps/context_processors.py index 5a1c89847..8a7bcb33e 100644 --- a/tethys_apps/context_processors.py +++ b/tethys_apps/context_processors.py @@ -31,7 +31,8 @@ def tethys_apps_context(request): 'icon': app.icon, 'color': app.color, 'tags': app.tags, - 'description': app.description + 'description': app.description, + 'namespace': app.namespace } if hasattr(app, 'feedback_emails') and len(app.feedback_emails) > 0: diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 62bfbe153..dbcf22c8d 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -17,6 +17,7 @@ PersistentStoreInitializerError from tethys_compute.utilities import ListField from sqlalchemy.orm import sessionmaker +from tethys_apps.base.mixins import TethysBaseMixin from tethys_sdk.testing import is_testing_environment, get_test_db_name try: @@ -30,7 +31,7 @@ from tethys_apps.base.function_extractor import TethysFunctionExtractor -class TethysApp(models.Model): +class TethysApp(models.Model, TethysBaseMixin): """ DB Model for Tethys Apps """ @@ -124,7 +125,7 @@ def configured(self): return True -class TethysExtension(models.Model): +class TethysExtension(models.Model, TethysBaseMixin): """ DB Model for Tethys Extension """ From 3cd74dfdf11b1bbb7c0f5df04153a245d31a23e0 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Mon, 9 Apr 2018 18:11:02 +0000 Subject: [PATCH 155/215] Fix issues with the tethys manage sync command. --- tethys_apps/cli/manage_commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index e211853bf..930161b7c 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -104,8 +104,8 @@ def manage_command(args): elif args.command == MANAGE_CREATESUPERUSER: primary_process = ['python', manage_path, 'createsuperuser'] elif args.command == MANAGE_SYNC: - from tethys_apps.utilities import sync_tethys_db - sync_tethys_db() + from tethys_apps.harvester import SingletonHarvester + harvester.harvest() if primary_process: run_process(primary_process) From 184c4b6f26b583af21e6f4d7a9112cdc65b400eb Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Fri, 13 Apr 2018 10:06:00 -0500 Subject: [PATCH 156/215] Python3-Compatibility --- .../commands/tethys_app_uninstall.py | 8 ++++--- tethys_apps/models.py | 24 ++++++++++++------- tethys_config/context_processors.py | 2 +- tethys_config/init.py | 5 +--- tethys_config/models.py | 8 ++++++- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index c43aee672..6885c4ce7 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -15,6 +15,8 @@ from django.core.management.base import BaseCommand from tethys_apps.helpers import get_installed_tethys_apps +from builtins import input + class Command(BaseCommand): """ @@ -45,11 +47,11 @@ def handle(self, *args, **options): valid_inputs = ('y', 'n', 'yes', 'no') no_inputs = ('n', 'no') - overwrite_input = raw_input('Are you sure you want to uninstall "{0}"? (y/n): '.format(app_with_prefix)).lower() + overwrite_input = input('Are you sure you want to uninstall "{0}"? (y/n): '.format(app_with_prefix)).lower() while overwrite_input not in valid_inputs: - overwrite_input = raw_input('Invalid option. Are you sure you want to ' - 'uninstall "{0}"? (y/n): '.format(app_with_prefix)).lower() + overwrite_input = input('Invalid option. Are you sure you want to ' + 'uninstall "{0}"? (y/n): '.format(app_with_prefix)).lower() if overwrite_input in no_inputs: self.stdout.write('Uninstall cancelled by user.') diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 3ee8e517f..6eb6b5d39 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -40,7 +40,7 @@ class TethysApp(models.Model): description = models.TextField(max_length=1000, blank=True, default='') enable_feedback = models.BooleanField(default=False) feedback_emails = ListField(default='', blank=True) - tags = models.CharField(max_length=200, blank=True, default='') + tags = models.CharField(max_length=200, blank=True, default='') # Developer first attributes index = models.CharField(max_length=200, default='') @@ -63,6 +63,9 @@ class Meta: def __unicode__(self): return text(self.name) + def __str__(self): + return text(self.name) + def add_settings(self, setting_list): """ Associate setting with app in database @@ -84,22 +87,22 @@ def settings(self): @property def custom_settings(self): return self.settings_set.exclude(customsetting__isnull=True) \ - .select_subclasses('customsetting') + .select_subclasses('customsetting') @property def dataset_service_settings(self): return self.settings_set.exclude(datasetservicesetting__isnull=True) \ - .select_subclasses('datasetservicesetting') + .select_subclasses('datasetservicesetting') @property def spatial_dataset_service_settings(self): return self.settings_set.exclude(spatialdatasetservicesetting__isnull=True) \ - .select_subclasses('spatialdatasetservicesetting') + .select_subclasses('spatialdatasetservicesetting') @property def wps_services_settings(self): return self.settings_set.exclude(webprocessingservicesetting__isnull=True) \ - .select_subclasses('webprocessingservicesetting') + .select_subclasses('webprocessingservicesetting') @property def persistent_store_connection_settings(self): @@ -139,6 +142,9 @@ class TethysAppSetting(models.Model): def __unicode__(self): return self.name + def __str__(self): + return self.name + @property def initializer_function(self): """ @@ -232,13 +238,13 @@ def clean(self): if self.value != '' and self.type == self.TYPE_FLOAT: try: float(self.value) - except: + except Exception: raise ValidationError('Value must be a float.') elif self.value != '' and self.type == self.TYPE_INTEGER: try: int(self.value) - except: + except Exception: raise ValidationError('Value must be an integer.') elif self.value != '' and self.type == self.TYPE_BOOLEAN: @@ -328,6 +334,7 @@ def get_value(self, as_public_endpoint=False, as_endpoint=False, as_engine=False return self.dataset_service + class SpatialDatasetServiceSetting(TethysAppSetting): """ Used to define a Spatial Dataset Service Setting. @@ -367,7 +374,7 @@ def clean(self): raise ValidationError('Required.') def get_value(self, as_public_endpoint=False, as_endpoint=False, as_wms=False, - as_wfs=False, as_engine=False): + as_wfs=False, as_engine=False): if not self.spatial_dataset_service: return None # TODO Why don't we raise a NotAssigned error here? @@ -390,6 +397,7 @@ def get_value(self, as_public_endpoint=False, as_endpoint=False, as_wms=False, return self.spatial_dataset_service + class WebProcessingServiceSetting(TethysAppSetting): """ Used to define a Web Processing Service Setting. diff --git a/tethys_config/context_processors.py b/tethys_config/context_processors.py index 8a8ba552b..8886ee184 100644 --- a/tethys_config/context_processors.py +++ b/tethys_config/context_processors.py @@ -20,7 +20,7 @@ def tethys_global_settings_context(request): site_globals = Setting.as_dict() # Get terms and conditions - site_globals.update({'documents': TermsAndConditions.get_active_list(as_dict=False)}) + site_globals.update({'documents': TermsAndConditions.get_active_terms_list()}) context = {'site_globals': site_globals} return context diff --git a/tethys_config/init.py b/tethys_config/init.py index 04562e131..5ad1ec4d8 100644 --- a/tethys_config/init.py +++ b/tethys_config/init.py @@ -36,7 +36,7 @@ def initial_settings(apps, schema_editor): date_modified=now) general_category.setting_set.create(name="Brand Image", - content="/static/tethys_portal/images/tethys-logo-75.png", + content="/tethys_portal/images/tethys-logo-75.png", date_modified=now) general_category.setting_set.create(name="Brand Image Height", @@ -167,6 +167,3 @@ def reverse_init(apps, schema_editor): for category in categories: category.delete() - - - diff --git a/tethys_config/models.py b/tethys_config/models.py index 7bc004d15..475b85b38 100644 --- a/tethys_config/models.py +++ b/tethys_config/models.py @@ -11,7 +11,7 @@ class SettingsCategory(models.Model): - name = models.CharField(max_length=30) + name = models.TextField(max_length=30) class Meta: verbose_name = 'Settings Category' @@ -20,6 +20,9 @@ class Meta: def __unicode__(self): return self.name + def __str__(self): + return self.name + class Setting(models.Model): name = models.TextField(max_length=30) @@ -30,6 +33,9 @@ class Setting(models.Model): def __unicode__(self): return self.name + def __str__(self): + return self.name + @classmethod def as_dict(cls): all_settings = cls.objects.all() From f1371ccd9b71788ab5f044674795944384bbcaf9 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Fri, 13 Apr 2018 10:06:00 -0500 Subject: [PATCH 157/215] Python3-Compatibility --- .../commands/tethys_app_uninstall.py | 8 ++++--- tethys_apps/models.py | 24 ++++++++++++------- tethys_config/context_processors.py | 2 +- tethys_config/init.py | 5 +--- tethys_config/models.py | 8 ++++++- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index a9c852096..b406bac4c 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -16,6 +16,8 @@ from django.core.management.base import BaseCommand from tethys_apps.helpers import get_installed_tethys_apps, get_installed_tethys_extensions +from builtins import input + class Command(BaseCommand): """ @@ -48,11 +50,11 @@ def handle(self, *args, **options): valid_inputs = ('y', 'n', 'yes', 'no') no_inputs = ('n', 'no') - overwrite_input = raw_input('Are you sure you want to uninstall "{0}"? (y/n): '.format(item_with_prefix)).lower() + overwrite_input = input('Are you sure you want to uninstall "{0}"? (y/n): '.format(item_with_prefix)).lower() while overwrite_input not in valid_inputs: - overwrite_input = raw_input('Invalid option. Are you sure you want to ' - 'uninstall "{0}"? (y/n): '.format(item_with_prefix)).lower() + overwrite_input = input('Invalid option. Are you sure you want to ' + 'uninstall "{0}"? (y/n): '.format(item_with_prefix)).lower() if overwrite_input in no_inputs: self.stdout.write('Uninstall cancelled by user.') diff --git a/tethys_apps/models.py b/tethys_apps/models.py index dbcf22c8d..869526336 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -43,7 +43,7 @@ class TethysApp(models.Model, TethysBaseMixin): description = models.TextField(max_length=1000, blank=True, default='') enable_feedback = models.BooleanField(default=False) feedback_emails = ListField(default='', blank=True) - tags = models.CharField(max_length=200, blank=True, default='') + tags = models.CharField(max_length=200, blank=True, default='') # Developer first attributes index = models.CharField(max_length=200, default='') @@ -66,6 +66,9 @@ class Meta: def __unicode__(self): return text(self.name) + def __str__(self): + return text(self.name) + def add_settings(self, setting_list): """ Associate setting with app in database @@ -87,22 +90,22 @@ def settings(self): @property def custom_settings(self): return self.settings_set.exclude(customsetting__isnull=True) \ - .select_subclasses('customsetting') + .select_subclasses('customsetting') @property def dataset_service_settings(self): return self.settings_set.exclude(datasetservicesetting__isnull=True) \ - .select_subclasses('datasetservicesetting') + .select_subclasses('datasetservicesetting') @property def spatial_dataset_service_settings(self): return self.settings_set.exclude(spatialdatasetservicesetting__isnull=True) \ - .select_subclasses('spatialdatasetservicesetting') + .select_subclasses('spatialdatasetservicesetting') @property def wps_services_settings(self): return self.settings_set.exclude(webprocessingservicesetting__isnull=True) \ - .select_subclasses('webprocessingservicesetting') + .select_subclasses('webprocessingservicesetting') @property def persistent_store_connection_settings(self): @@ -167,6 +170,9 @@ class TethysAppSetting(models.Model): def __unicode__(self): return self.name + def __str__(self): + return self.name + @property def initializer_function(self): """ @@ -260,13 +266,13 @@ def clean(self): if self.value != '' and self.type == self.TYPE_FLOAT: try: float(self.value) - except: + except Exception: raise ValidationError('Value must be a float.') elif self.value != '' and self.type == self.TYPE_INTEGER: try: int(self.value) - except: + except Exception: raise ValidationError('Value must be an integer.') elif self.value != '' and self.type == self.TYPE_BOOLEAN: @@ -356,6 +362,7 @@ def get_value(self, as_public_endpoint=False, as_endpoint=False, as_engine=False return self.dataset_service + class SpatialDatasetServiceSetting(TethysAppSetting): """ Used to define a Spatial Dataset Service Setting. @@ -395,7 +402,7 @@ def clean(self): raise ValidationError('Required.') def get_value(self, as_public_endpoint=False, as_endpoint=False, as_wms=False, - as_wfs=False, as_engine=False): + as_wfs=False, as_engine=False): if not self.spatial_dataset_service: return None # TODO Why don't we raise a NotAssigned error here? @@ -418,6 +425,7 @@ def get_value(self, as_public_endpoint=False, as_endpoint=False, as_wms=False, return self.spatial_dataset_service + class WebProcessingServiceSetting(TethysAppSetting): """ Used to define a Web Processing Service Setting. diff --git a/tethys_config/context_processors.py b/tethys_config/context_processors.py index 8a8ba552b..8886ee184 100644 --- a/tethys_config/context_processors.py +++ b/tethys_config/context_processors.py @@ -20,7 +20,7 @@ def tethys_global_settings_context(request): site_globals = Setting.as_dict() # Get terms and conditions - site_globals.update({'documents': TermsAndConditions.get_active_list(as_dict=False)}) + site_globals.update({'documents': TermsAndConditions.get_active_terms_list()}) context = {'site_globals': site_globals} return context diff --git a/tethys_config/init.py b/tethys_config/init.py index 04562e131..5ad1ec4d8 100644 --- a/tethys_config/init.py +++ b/tethys_config/init.py @@ -36,7 +36,7 @@ def initial_settings(apps, schema_editor): date_modified=now) general_category.setting_set.create(name="Brand Image", - content="/static/tethys_portal/images/tethys-logo-75.png", + content="/tethys_portal/images/tethys-logo-75.png", date_modified=now) general_category.setting_set.create(name="Brand Image Height", @@ -167,6 +167,3 @@ def reverse_init(apps, schema_editor): for category in categories: category.delete() - - - diff --git a/tethys_config/models.py b/tethys_config/models.py index 7bc004d15..475b85b38 100644 --- a/tethys_config/models.py +++ b/tethys_config/models.py @@ -11,7 +11,7 @@ class SettingsCategory(models.Model): - name = models.CharField(max_length=30) + name = models.TextField(max_length=30) class Meta: verbose_name = 'Settings Category' @@ -20,6 +20,9 @@ class Meta: def __unicode__(self): return self.name + def __str__(self): + return self.name + class Setting(models.Model): name = models.TextField(max_length=30) @@ -30,6 +33,9 @@ class Setting(models.Model): def __unicode__(self): return self.name + def __str__(self): + return self.name + @classmethod def as_dict(cls): all_settings = cls.objects.all() From d729da92dba82f9de05790cad85bd7e773b0677d Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Sat, 14 Apr 2018 00:40:20 -0500 Subject: [PATCH 158/215] Python 3 compatibility fixes --- tethys_apps/app_installation.py | 24 +++++++++---------- tethys_apps/cli/app_settings_commands.py | 12 +++++----- tethys_apps/cli/docker_commands.py | 2 +- tethys_apps/cli/link_commands.py | 2 +- tethys_apps/cli/services_commands.py | 8 +++---- .../management/commands/collectworkspaces.py | 2 +- tethys_apps/utilities.py | 2 +- tethys_compute/models.py | 8 +++---- 8 files changed, 30 insertions(+), 30 deletions(-) diff --git a/tethys_apps/app_installation.py b/tethys_apps/app_installation.py index 781ee988f..f2cd6eb68 100644 --- a/tethys_apps/app_installation.py +++ b/tethys_apps/app_installation.py @@ -75,20 +75,20 @@ def _run_develop(self): # Create symbolic link try: - os_symlink = getattr(os,"symlink",None) - if callable(os_symlink): - os.symlink(self.app_package_dir, destination_dir) - else: - def symlink_ms(source,dest): - csl = ctypes.windll.kernel32.CreateSymbolicLinkW - csl.argtypes = (ctypes.c_wchar_p,ctypes.c_wchar_p,ctypes.c_uint32) - csl.restype = ctypes.c_ubyte - flags = 1 if os.path.isdir(source) else 0 - if csl(dest,source.replace('/','\\'),flags) == 0: + os_symlink = getattr(os,"symlink",None) + if callable(os_symlink): + os.symlink(self.app_package_dir, destination_dir) + else: + def symlink_ms(source,dest): + csl = ctypes.windll.kernel32.CreateSymbolicLinkW + csl.argtypes = (ctypes.c_wchar_p,ctypes.c_wchar_p,ctypes.c_uint32) + csl.restype = ctypes.c_ubyte + flags = 1 if os.path.isdir(source) else 0 + if csl(dest,source.replace('/','\\'),flags) == 0: raise ctypes.WinError() - os.symlink = symlink_ms(self.app_package_dir, destination_dir) + os.symlink = symlink_ms(self.app_package_dir, destination_dir) except Exception as e: - print e + print(e) try: shutil.rmtree(destination_dir) except Exception as e: diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index b86887b82..19955391b 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -39,7 +39,7 @@ def app_settings_list_command(args): p.write("\nUnlinked Settings:") if len(unlinked_settings) == 0: - print 'None' + print('None') else: is_first_row = True for setting in unlinked_settings: @@ -47,13 +47,13 @@ def app_settings_list_command(args): with pretty_output(BOLD) as p: p.write('{0: <10}{1: <40}{2: <15}'.format('ID', 'Name', 'Type')) is_first_row = False - print '{0: <10}{1: <40}{2: <15}'.format(setting.pk, setting.name, setting_type_dict[type(setting)]) + print('{0: <10}{1: <40}{2: <15}'.format(setting.pk, setting.name, setting_type_dict[type(setting)])) with pretty_output(BOLD) as p: p.write("\nLinked Settings:") if len(linked_settings) == 0: - print 'None' + print('None') else: is_first_row = True for setting in linked_settings: @@ -63,13 +63,13 @@ def app_settings_list_command(args): is_first_row = False service_name = setting.spatial_dataset_service.name if hasattr(setting, 'spatial_dataset_service') \ else setting.persistent_store_service.name - print '{0: <10}{1: <40}{2: <15}{3: <20}'.format(setting.pk, setting.name, - setting_type_dict[type(setting)], service_name) + print('{0: <10}{1: <40}{2: <15}{3: <20}'.format(setting.pk, setting.name, + setting_type_dict[type(setting)], service_name)) except ObjectDoesNotExist: with pretty_output(FG_RED) as p: p.write('The app you specified ("{0}") does not exist. Command aborted.'.format(app_package)) except Exception as e: - print e + print(e) with pretty_output(FG_RED) as p: p.write('Something went wrong. Please try again.') diff --git a/tethys_apps/cli/docker_commands.py b/tethys_apps/cli/docker_commands.py index 345b57793..f4dbd2c13 100644 --- a/tethys_apps/cli/docker_commands.py +++ b/tethys_apps/cli/docker_commands.py @@ -151,7 +151,7 @@ def validate_directory_cli_input(value, default=None): try: os.makedirs(value) except OSError as e: - print ('{0}: {1}'.format(repr(e), value)) + print('{0}: {1}'.format(repr(e), value)) prompt = 'Please provide a valid directory' prompt = add_default_to_prompt(prompt, default) prompt = close_prompt(prompt) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index a7dfe09dd..2b066cab4 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -40,7 +40,7 @@ def link_command(args): exit(0) except Exception as e: - print e + print(e) with pretty_output(FG_RED) as p: p.write('An unexpected error occurred. Please try again.') exit(1) diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 253ae5adb..6bf2c3c9b 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -202,8 +202,8 @@ def services_list_command(args): with pretty_output(BOLD) as p: p.write('{0: <3}{1: <50}{2: <25}{3: <6}'.format('ID', 'Name', 'Host', 'Port')) is_first_entry = False - print '{0: <3}{1: <50}{2: <25}{3: <6}'.format(model_dict['id'], model_dict['name'], - model_dict['host'], model_dict['port']) + print('{0: <3}{1: <50}{2: <25}{3: <6}'.format(model_dict['id'], model_dict['name'], + model_dict['host'], model_dict['port'])) if list_spatial: spatial_entries = SpatialDatasetService.objects.order_by('id').all() @@ -218,8 +218,8 @@ def services_list_command(args): p.write('{0: <3}{1: <50}{2: <50}{3: <50}{4: <30}'.format('ID', 'Name', 'Endpoint', 'Public Endpoint', 'API Key')) is_first_entry = False - print '{0: <3}{1: <50}{2: <50}{3: <50}{4: <30}'.format(model_dict['id'], model_dict['name'], + print('{0: <3}{1: <50}{2: <50}{3: <50}{4: <30}'.format(model_dict['id'], model_dict['name'], model_dict['endpoint'], model_dict['public_endpoint'], model_dict['apikey'] if model_dict['apikey'] - else "None") + else "None")) diff --git a/tethys_apps/management/commands/collectworkspaces.py b/tethys_apps/management/commands/collectworkspaces.py index fc3da9949..e34699072 100644 --- a/tethys_apps/management/commands/collectworkspaces.py +++ b/tethys_apps/management/commands/collectworkspaces.py @@ -55,7 +55,7 @@ def handle(self, *args, **options): # Only perform if workspaces_path is a directory if not os.path.isdir(app_ws_path): - print 'WARNING: The workspace_path for app "{}" is not a directory. Skipping...'.format(app) + print('WARNING: The workspace_path for app "{}" is not a directory. Skipping...'.format(app)) continue if not os.path.islink(app_ws_path): diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index f1fb41ca1..e95fc75f4 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -132,7 +132,7 @@ def create_ps_database_setting(app_package, name, description='', required=False app_package)) return True except Exception as e: - print e + print(e) with pretty_output(FG_RED) as p: p.write('The above error was encountered. Aborted.'.format(app_package)) return False diff --git a/tethys_compute/models.py b/tethys_compute/models.py index d30cffdb6..7a6865416 100644 --- a/tethys_compute/models.py +++ b/tethys_compute/models.py @@ -425,8 +425,8 @@ def condor_job_pre_delete(sender, instance, using, **kwargs): try: instance.condor_object.close_remote() shutil.rmtree(instance.initial_dir, ignore_errors=True) - except Exception, e: - log.exception(e.message) + except Exception as e: + log.exception(str(e)) class CondorPyWorkflow(models.Model): @@ -543,8 +543,8 @@ def condor_workflow_pre_delete(sender, instance, using, **kwargs): try: instance.condor_object.close_remote() shutil.rmtree(instance.workspace, ignore_errors=True) - except Exception, e: - log.exception(e.message) + except Exception as e: + log.exception(str(e)) class CondorWorkflowNode(models.Model): From 0a0d43e74604f6f25a0617775e3c91ffe7ead385 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Fri, 13 Apr 2018 10:05:06 -0500 Subject: [PATCH 159/215] Python3-Migrations --- tethys_apps/migrations/0001_initial.py | 39 ------ tethys_apps/migrations/0001_initial_20.py | 38 +++--- tethys_apps/migrations/0002_tethysapp_tags.py | 20 --- .../migrations/0003_auto_20170505_0350.py | 86 ------------ tethys_compute/migrations/0001_initial.py | 96 ------------- tethys_compute/migrations/0001_initial_20.py | 32 ++--- .../migrations/0002_initialize_settings.py | 16 --- .../migrations/0003_auto_20150529_1651.py | 128 ------------------ .../migrations/0004_auto_20150812_1915.py | 109 --------------- .../migrations/0005_auto_20150914_1712.py | 20 --- .../migrations/0006_auto_20151026_2142.py | 27 ---- .../migrations/0006_auto_20151221_2207.py | 27 ---- tethys_compute/migrations/0007_merge.py | 16 --- .../0008_start_condorjob_refactor.py | 52 ------- .../0009_condorjob_data_migration.py | 87 ------------ .../0010_finish_condorjob_refactor.py | 125 ----------------- .../migrations/0011_delete_cluster.py | 18 --- .../migrations/0012_delete_settings.py | 25 ---- tethys_config/migrations/0001_initial.py | 41 ------ tethys_config/migrations/0001_initial_20.py | 53 +++----- .../migrations/0002_auto_20141029_1848.py | 16 --- .../migrations/0003_auto_20141223_2244.py | 36 ----- .../migrations/0004_auto_20150424_2050.py | 47 ------- .../migrations/0005_auto_20151023_1720.py | 107 --------------- .../migrations/0006_auto_20170603_0419.py | 42 ------ tethys_services/migrations/0001_initial.py | 30 ---- tethys_services/migrations/0001_initial_20.py | 22 ++- .../migrations/0002_auto_20150119_1756.py | 20 --- .../migrations/0003_spatialdatasetservice.py | 31 ----- .../migrations/0004_webprocessingservice.py | 29 ---- .../migrations/0005_auto_20150424_2126.py | 111 --------------- .../migrations/0006_auto_20150729_1551.py | 33 ----- ...7_spatialdatasetservice_public_endpoint.py | 21 --- .../migrations/0008_auto_20151023_1427.py | 33 ----- .../migrations/0009_persistentstoreservice.py | 32 ----- 35 files changed, 69 insertions(+), 1596 deletions(-) delete mode 100644 tethys_apps/migrations/0001_initial.py delete mode 100644 tethys_apps/migrations/0002_tethysapp_tags.py delete mode 100644 tethys_apps/migrations/0003_auto_20170505_0350.py delete mode 100644 tethys_compute/migrations/0001_initial.py delete mode 100644 tethys_compute/migrations/0002_initialize_settings.py delete mode 100644 tethys_compute/migrations/0003_auto_20150529_1651.py delete mode 100644 tethys_compute/migrations/0004_auto_20150812_1915.py delete mode 100644 tethys_compute/migrations/0005_auto_20150914_1712.py delete mode 100644 tethys_compute/migrations/0006_auto_20151026_2142.py delete mode 100644 tethys_compute/migrations/0006_auto_20151221_2207.py delete mode 100644 tethys_compute/migrations/0007_merge.py delete mode 100644 tethys_compute/migrations/0008_start_condorjob_refactor.py delete mode 100644 tethys_compute/migrations/0009_condorjob_data_migration.py delete mode 100644 tethys_compute/migrations/0010_finish_condorjob_refactor.py delete mode 100644 tethys_compute/migrations/0011_delete_cluster.py delete mode 100644 tethys_compute/migrations/0012_delete_settings.py delete mode 100644 tethys_config/migrations/0001_initial.py delete mode 100644 tethys_config/migrations/0002_auto_20141029_1848.py delete mode 100644 tethys_config/migrations/0003_auto_20141223_2244.py delete mode 100644 tethys_config/migrations/0004_auto_20150424_2050.py delete mode 100644 tethys_config/migrations/0005_auto_20151023_1720.py delete mode 100644 tethys_config/migrations/0006_auto_20170603_0419.py delete mode 100644 tethys_services/migrations/0001_initial.py delete mode 100644 tethys_services/migrations/0002_auto_20150119_1756.py delete mode 100644 tethys_services/migrations/0003_spatialdatasetservice.py delete mode 100644 tethys_services/migrations/0004_webprocessingservice.py delete mode 100644 tethys_services/migrations/0005_auto_20150424_2126.py delete mode 100644 tethys_services/migrations/0006_auto_20150729_1551.py delete mode 100644 tethys_services/migrations/0007_spatialdatasetservice_public_endpoint.py delete mode 100644 tethys_services/migrations/0008_auto_20151023_1427.py delete mode 100644 tethys_services/migrations/0009_persistentstoreservice.py diff --git a/tethys_apps/migrations/0001_initial.py b/tethys_apps/migrations/0001_initial.py deleted file mode 100644 index 7243dafef..000000000 --- a/tethys_apps/migrations/0001_initial.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.5 on 2016-05-11 17:10 -from __future__ import unicode_literals - -from django.db import migrations, models -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='TethysApp', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('package', models.CharField(default=b'', max_length=200, unique=True)), - ('name', models.CharField(default=b'', max_length=200)), - ('description', models.TextField(blank=True, default=b'', max_length=1000)), - ('enable_feedback', models.BooleanField(default=False)), - ('feedback_emails', tethys_compute.utilities.ListField(blank=True, default=b'')), - ('index', models.CharField(default=b'', max_length=200)), - ('icon', models.CharField(default=b'', max_length=200)), - ('root_url', models.CharField(default=b'', max_length=200)), - ('color', models.CharField(default=b'', max_length=10)), - ('enabled', models.BooleanField(default=True)), - ('show_in_apps_library', models.BooleanField(default=True)), - ], - options={ - 'verbose_name': 'Tethys App', - 'verbose_name_plural': 'Installed Apps', - 'permissions': (('view_app', 'Can see app in library'), ('access_app', 'Can access app')), - }, - ), - ] diff --git a/tethys_apps/migrations/0001_initial_20.py b/tethys_apps/migrations/0001_initial_20.py index f20d515d8..285b77039 100644 --- a/tethys_apps/migrations/0001_initial_20.py +++ b/tethys_apps/migrations/0001_initial_20.py @@ -9,31 +9,31 @@ class Migration(migrations.Migration): - replaces = [(b'tethys_apps', '0001_initial'), (b'tethys_apps', '0002_tethysapp_tags'), (b'tethys_apps', '0003_auto_20170505_0350')] + # replaces = [('tethys_apps', '0001_initial'), ('tethys_apps', '0002_tethysapp_tags'), ('tethys_apps', '0003_auto_20170505_0350')] initial = True - # dependencies = [ - # ('tethys_services', '0009_persistentstoreservice'), - # ] + dependencies = [ + ('tethys_services', '0001_initial_20'), + ] operations = [ migrations.CreateModel( name='TethysApp', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('package', models.CharField(default=b'', max_length=200, unique=True)), - ('name', models.CharField(default=b'', max_length=200)), - ('description', models.TextField(blank=True, default=b'', max_length=1000)), + ('package', models.CharField(default='', max_length=200, unique=True)), + ('name', models.CharField(default='', max_length=200)), + ('description', models.TextField(blank=True, default='', max_length=1000)), ('enable_feedback', models.BooleanField(default=False)), - ('feedback_emails', tethys_compute.utilities.ListField(blank=True, default=b'')), - ('index', models.CharField(default=b'', max_length=200)), - ('icon', models.CharField(default=b'', max_length=200)), - ('root_url', models.CharField(default=b'', max_length=200)), - ('color', models.CharField(default=b'', max_length=10)), + ('feedback_emails', tethys_compute.utilities.ListField(blank=True, default='')), + ('index', models.CharField(default='', max_length=200)), + ('icon', models.CharField(default='', max_length=200)), + ('root_url', models.CharField(default='', max_length=200)), + ('color', models.CharField(default='', max_length=10)), ('enabled', models.BooleanField(default=True)), ('show_in_apps_library', models.BooleanField(default=True)), - ('tags', models.CharField(blank=True, default=b'', max_length=200)), + ('tags', models.CharField(blank=True, default='', max_length=200)), ], options={ 'verbose_name': 'Tethys App', @@ -45,10 +45,10 @@ class Migration(migrations.Migration): name='TethysAppSetting', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(default=b'', max_length=200)), - ('description', models.TextField(blank=True, default=b'', max_length=1000)), + ('name', models.CharField(default='', max_length=200)), + ('description', models.TextField(blank=True, default='', max_length=1000)), ('required', models.BooleanField(default=True)), - ('initializer', models.CharField(default=b'', max_length=1000)), + ('initializer', models.CharField(default='', max_length=1000)), ('initialized', models.BooleanField(default=False)), ], ), @@ -57,7 +57,7 @@ class Migration(migrations.Migration): fields=[ ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), ('value', models.CharField(blank=True, max_length=1024)), - ('type', models.CharField(choices=[(b'STRING', b'String'), (b'INTEGER', b'Integer'), (b'FLOAT', b'Float'), (b'BOOLEAN', b'Boolean')], default=b'STRING', max_length=200)), + ('type', models.CharField(choices=[('STRING', 'String'), ('INTEGER', 'Integer'), ('FLOAT', 'Float'), ('BOOLEAN', 'Boolean')], default='STRING', max_length=200)), ], bases=('tethys_apps.tethysappsetting',), ), @@ -65,7 +65,7 @@ class Migration(migrations.Migration): name='DatasetServiceSetting', fields=[ ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.CkanDatasetEngine', b'CKAN'), (b'tethys_dataset_services.engines.HydroShareDatasetEngine', b'HydroShare')], default=b'tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.CkanDatasetEngine', 'CKAN'), ('tethys_dataset_services.engines.HydroShareDatasetEngine', 'HydroShare')], default='tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), ('dataset_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.DatasetService')), ], bases=('tethys_apps.tethysappsetting',), @@ -92,7 +92,7 @@ class Migration(migrations.Migration): name='SpatialDatasetServiceSetting', fields=[ ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', b'GeoServer')], default=b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', 'GeoServer')], default='tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), ('spatial_dataset_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.SpatialDatasetService')), ], bases=('tethys_apps.tethysappsetting',), diff --git a/tethys_apps/migrations/0002_tethysapp_tags.py b/tethys_apps/migrations/0002_tethysapp_tags.py deleted file mode 100644 index b7e739133..000000000 --- a/tethys_apps/migrations/0002_tethysapp_tags.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-06-08 18:29 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_apps', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='tethysapp', - name='tags', - field=models.CharField(blank=True, default=b'', max_length=200), - ), - ] diff --git a/tethys_apps/migrations/0003_auto_20170505_0350.py b/tethys_apps/migrations/0003_auto_20170505_0350.py deleted file mode 100644 index f97aa2aeb..000000000 --- a/tethys_apps/migrations/0003_auto_20170505_0350.py +++ /dev/null @@ -1,86 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.8 on 2017-05-05 03:50 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0009_persistentstoreservice'), - ('tethys_apps', '0002_tethysapp_tags'), - ] - - operations = [ - migrations.CreateModel( - name='TethysAppSetting', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(default=b'', max_length=200)), - ('description', models.TextField(blank=True, default=b'', max_length=1000)), - ('required', models.BooleanField(default=True)), - ('initializer', models.CharField(default=b'', max_length=1000)), - ('initialized', models.BooleanField(default=False)), - ], - ), - migrations.CreateModel( - name='CustomSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('value', models.CharField(blank=True, max_length=1024)), - ('type', models.CharField(choices=[(b'STRING', b'String'), (b'INTEGER', b'Integer'), (b'FLOAT', b'Float'), (b'BOOLEAN', b'Boolean')], default=b'STRING', max_length=200)), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.CreateModel( - name='DatasetServiceSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.CkanDatasetEngine', b'CKAN'), (b'tethys_dataset_services.engines.HydroShareDatasetEngine', b'HydroShare')], default=b'tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), - ('dataset_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.DatasetService')), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.CreateModel( - name='PersistentStoreConnectionSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('persistent_store_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.PersistentStoreService')), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.CreateModel( - name='PersistentStoreDatabaseSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('spatial', models.BooleanField(default=False)), - ('dynamic', models.BooleanField(default=False)), - ('persistent_store_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.PersistentStoreService')), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.CreateModel( - name='SpatialDatasetServiceSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', b'GeoServer')], default=b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), - ('spatial_dataset_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.SpatialDatasetService')), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.CreateModel( - name='WebProcessingServiceSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('web_processing_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.WebProcessingService')), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.AddField( - model_name='tethysappsetting', - name='tethys_app', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings_set', to='tethys_apps.TethysApp'), - ), - ] diff --git a/tethys_compute/migrations/0001_initial.py b/tethys_compute/migrations/0001_initial.py deleted file mode 100644 index d33a6ab87..000000000 --- a/tethys_compute/migrations/0001_initial.py +++ /dev/null @@ -1,96 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import datetime -from django.conf import settings -from django.utils.timezone import utc - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Cluster', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('_name', models.CharField(default=b'tethys_default', unique=True, max_length=30)), - ('_size', models.IntegerField(default=1)), - ('_status', models.CharField(default=b'STR', max_length=3, choices=[(b'STR', b'Starting'), (b'RUN', b'Running'), (b'STP', b'Stopped'), (b'UPD', b'Updating'), (b'DEL', b'Deleting'), (b'ERR', b'Error')])), - ('_cloud_provider', models.CharField(default=b'AWS', max_length=3, choices=[(b'AWS', b'Amazon Web Services'), (b'AZR', b'Microsoft Azure')])), - ('_master_image_id', models.CharField(max_length=9, null=True, blank=True)), - ('_node_image_id', models.CharField(max_length=9, null=True, blank=True)), - ('_master_instance_type', models.CharField(max_length=20, null=True, blank=True)), - ('_node_instance_type', models.CharField(max_length=20, null=True, blank=True)), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.CreateModel( - name='Setting', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.TextField(max_length=30)), - ('content', models.TextField(max_length=500, blank=True)), - ('date_modified', models.DateTimeField(auto_now=True, verbose_name=b'date modified')), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.CreateModel( - name='SettingsCategory', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=30)), - ], - options={ - 'verbose_name': 'Settings Category', - 'verbose_name_plural': 'Settings', - }, - bases=(models.Model,), - ), - migrations.CreateModel( - name='TethysJob', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=30)), - ('group', models.CharField(max_length=30)), - ('creation_time', models.DateTimeField(default=datetime.datetime(2015, 4, 6, 22, 37, 42, 933728, tzinfo=utc))), - ('submission_time', models.DateTimeField()), - ('completion_time', models.DateTimeField()), - ('status', models.CharField(default=b'PEN', max_length=3, choices=[(b'PEN', b'Pending'), (b'SUB', b'Submitted'), (b'RUN', b'Running'), (b'COM', b'Complete'), (b'ERR', b'Error')])), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.CreateModel( - name='CondorJob', - fields=[ - ('tethysjob_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='tethys_compute.TethysJob')), - ('scheduler', models.CharField(max_length=12)), - ('ami', models.CharField(max_length=9)), - ], - options={ - }, - bases=('tethys_compute.tethysjob',), - ), - migrations.AddField( - model_name='tethysjob', - name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), - preserve_default=True, - ), - migrations.AddField( - model_name='setting', - name='category', - field=models.ForeignKey(to='tethys_compute.SettingsCategory'), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0001_initial_20.py b/tethys_compute/migrations/0001_initial_20.py index 02a875565..a3d3d17d1 100644 --- a/tethys_compute/migrations/0001_initial_20.py +++ b/tethys_compute/migrations/0001_initial_20.py @@ -12,14 +12,14 @@ class Migration(migrations.Migration): initial = True - replaces = [(b'tethys_compute', '0001_initial'), (b'tethys_compute', '0002_initialize_settings'), - (b'tethys_compute', '0003_auto_20150529_1651'), (b'tethys_compute', '0004_auto_20150812_1915'), - (b'tethys_compute', '0005_auto_20150914_1712'), (b'tethys_compute', '0006_auto_20151221_2207'), - (b'tethys_compute', '0006_auto_20151026_2142'), (b'tethys_compute', '0007_merge'), - (b'tethys_compute', '0008_start_condorjob_refactor'), - (b'tethys_compute', '0009_condorjob_data_migration'), - (b'tethys_compute', '0010_finish_condorjob_refactor'), (b'tethys_compute', '0011_delete_cluster'), - (b'tethys_compute', '0012_delete_settings')] + # replaces = [('tethys_compute', '0001_initial'), ('tethys_compute', '0002_initialize_settings'), + # ('tethys_compute', '0003_auto_20150529_1651'), ('tethys_compute', '0004_auto_20150812_1915'), + # ('tethys_compute', '0005_auto_20150914_1712'), ('tethys_compute', '0006_auto_20151221_2207'), + # ('tethys_compute', '0006_auto_20151026_2142'), ('tethys_compute', '0007_merge'), + # ('tethys_compute', '0008_start_condorjob_refactor'), + # ('tethys_compute', '0009_condorjob_data_migration'), + # ('tethys_compute', '0010_finish_condorjob_refactor'), ('tethys_compute', '0011_delete_cluster'), + # ('tethys_compute', '0012_delete_settings')] dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), @@ -30,16 +30,16 @@ class Migration(migrations.Migration): name='CondorPyJob', fields=[ ('condorpyjob_id', models.AutoField(primary_key=True, serialize=False)), - ('_attributes', tethys_compute.utilities.DictionaryField(default=b'')), + ('_attributes', tethys_compute.utilities.DictionaryField(default='')), ('_num_jobs', models.IntegerField(default=1)), - ('_remote_input_files', tethys_compute.utilities.ListField(default=b'')), + ('_remote_input_files', tethys_compute.utilities.ListField(default='')), ], ), migrations.CreateModel( name='CondorPyWorkflow', fields=[ ('condorpyworkflow_id', models.AutoField(primary_key=True, serialize=False)), - ('_max_jobs', tethys_compute.utilities.DictionaryField(blank=True, default=b'')), + ('_max_jobs', tethys_compute.utilities.DictionaryField(blank=True, default='')), ('_config', models.CharField(blank=True, max_length=1024, null=True)), ], ), @@ -52,7 +52,7 @@ class Migration(migrations.Migration): ('pre_script_args', models.CharField(blank=True, max_length=1024, null=True)), ('post_script', models.CharField(blank=True, max_length=1024, null=True)), ('post_script_args', models.CharField(blank=True, max_length=1024, null=True)), - ('variables', tethys_compute.utilities.DictionaryField(blank=True, default=b'')), + ('variables', tethys_compute.utilities.DictionaryField(blank=True, default='')), ('priority', models.IntegerField(blank=True, null=True)), ('category', models.CharField(blank=True, max_length=128, null=True)), ('retry', models.PositiveSmallIntegerField(blank=True, null=True)), @@ -82,16 +82,16 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=1024)), - ('description', models.CharField(blank=True, default=b'', max_length=2048)), + ('description', models.CharField(blank=True, default='', max_length=2048)), ('label', models.CharField(max_length=1024)), ('creation_time', models.DateTimeField(auto_now_add=True)), ('execute_time', models.DateTimeField(blank=True, null=True)), ('start_time', models.DateTimeField(blank=True, null=True)), ('completion_time', models.DateTimeField(blank=True, null=True)), - ('workspace', models.CharField(default=b'', max_length=1024)), - ('extended_properties', tethys_compute.utilities.DictionaryField(blank=True, default=b'')), + ('workspace', models.CharField(default='', max_length=1024)), + ('extended_properties', tethys_compute.utilities.DictionaryField(blank=True, default='')), ('_process_results_function', models.CharField(blank=True, max_length=1024, null=True)), - ('_status', models.CharField(choices=[(b'PEN', b'Pending'), (b'SUB', b'Submitted'), (b'RUN', b'Running'), (b'COM', b'Complete'), (b'ERR', b'Error'), (b'ABT', b'Aborted'), (b'VAR', b'Various'), (b'VCP', b'Various-Complete')], default=b'PEN', max_length=3)), + ('_status', models.CharField(choices=[('PEN', 'Pending'), ('SUB', 'Submitted'), ('RUN', 'Running'), ('COM', 'Complete'), ('ERR', 'Error'), ('ABT', 'Aborted'), ('VAR', 'Various'), ('VCP', 'Various-Complete')], default='PEN', max_length=3)), ], options={ 'verbose_name': 'Job', diff --git a/tethys_compute/migrations/0002_initialize_settings.py b/tethys_compute/migrations/0002_initialize_settings.py deleted file mode 100644 index 0dc2f4162..000000000 --- a/tethys_compute/migrations/0002_initialize_settings.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -from tethys_compute.migrations import initialize_settings, clear_settings - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0001_initial'), - ] - - operations = [ - migrations.RunPython(clear_settings), - migrations.RunPython(initialize_settings, reverse_code=clear_settings), - ] \ No newline at end of file diff --git a/tethys_compute/migrations/0003_auto_20150529_1651.py b/tethys_compute/migrations/0003_auto_20150529_1651.py deleted file mode 100644 index ac539380a..000000000 --- a/tethys_compute/migrations/0003_auto_20150529_1651.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0002_initialize_settings'), - ] - - operations = [ - migrations.AlterModelOptions( - name='tethysjob', - options={'verbose_name': 'Job'}, - ), - migrations.RenameField( - model_name='tethysjob', - old_name='group', - new_name='label', - ), - migrations.RemoveField( - model_name='condorjob', - name='ami', - ), - migrations.RemoveField( - model_name='condorjob', - name='scheduler', - ), - migrations.RemoveField( - model_name='condorjob', - name='tethysjob_ptr', - ), - migrations.RemoveField( - model_name='tethysjob', - name='status', - ), - migrations.RemoveField( - model_name='tethysjob', - name='submission_time', - ), - migrations.AddField( - model_name='condorjob', - name='attributes', - field=tethys_compute.utilities.DictionaryField(default=b''), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='cluster_id', - field=models.IntegerField(default=0, blank=True), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='condorpy_template_name', - field=models.CharField(max_length=256, null=True, blank=True), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='executable', - field=models.CharField(default='', max_length=256), - preserve_default=False, - ), - migrations.AddField( - model_name='condorjob', - name='num_jobs', - field=models.IntegerField(default=1), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='remote_id', - field=models.CharField(max_length=32, null=True, blank=True), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='remote_input_files', - field=tethys_compute.utilities.ListField(default=b''), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='tethys_job', - field=models.OneToOneField(related_name='child', primary_key=True, default='', serialize=False, to='tethys_compute.TethysJob'), - preserve_default=False, - ), - migrations.AddField( - model_name='condorjob', - name='working_directory', - field=models.CharField(max_length=512, null=True, blank=True), - preserve_default=True, - ), - migrations.AddField( - model_name='tethysjob', - name='_status', - field=models.CharField(default=b'PEN', max_length=3, choices=[(b'PEN', b'Pending'), (b'SUB', b'Submitted'), (b'RUN', b'Running'), (b'COM', b'Complete'), (b'ERR', b'Error'), (b'ABT', b'Aborted')]), - preserve_default=True, - ), - migrations.AddField( - model_name='tethysjob', - name='description', - field=models.CharField(default=b'', max_length=1024, blank=True), - preserve_default=True, - ), - migrations.AddField( - model_name='tethysjob', - name='execute_time', - field=models.DateTimeField(null=True, blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='completion_time', - field=models.DateTimeField(null=True, blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='creation_time', - field=models.DateTimeField(auto_now_add=True), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0004_auto_20150812_1915.py b/tethys_compute/migrations/0004_auto_20150812_1915.py deleted file mode 100644 index 73cee0ffe..000000000 --- a/tethys_compute/migrations/0004_auto_20150812_1915.py +++ /dev/null @@ -1,109 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0003_auto_20150529_1651'), - ] - - operations = [ - migrations.CreateModel( - name='BasicJob', - fields=[ - ('tethysjob_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='tethys_compute.TethysJob')), - ], - options={ - }, - bases=('tethys_compute.tethysjob',), - ), - migrations.CreateModel( - name='Scheduler', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=1024)), - ('host', models.CharField(max_length=1024)), - ('username', models.CharField(max_length=1024, null=True, blank=True)), - ('password', models.CharField(max_length=1024, null=True, blank=True)), - ('private_key_path', models.CharField(max_length=1024, null=True, blank=True)), - ('private_key_pass', models.CharField(max_length=1024, null=True, blank=True)), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.RemoveField( - model_name='condorjob', - name='working_directory', - ), - migrations.AddField( - model_name='condorjob', - name='scheduler', - field=models.ForeignKey(default=1, to='tethys_compute.Scheduler'), - preserve_default=False, - ), - migrations.AddField( - model_name='tethysjob', - name='_subclass', - field=models.CharField(default=b'basicjob', max_length=30), - preserve_default=True, - ), - migrations.AddField( - model_name='tethysjob', - name='extended_properties', - field=tethys_compute.utilities.DictionaryField(default=b''), - preserve_default=True, - ), - migrations.AddField( - model_name='tethysjob', - name='workspace', - field=models.CharField(default=b'', max_length=1024), - preserve_default=True, - ), - migrations.AlterField( - model_name='condorjob', - name='condorpy_template_name', - field=models.CharField(max_length=1024, null=True, blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='condorjob', - name='executable', - field=models.CharField(max_length=1024), - preserve_default=True, - ), - migrations.AlterField( - model_name='condorjob', - name='tethys_job', - field=models.OneToOneField(primary_key=True, serialize=False, to='tethys_compute.TethysJob'), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='_status', - field=models.CharField(default=b'PEN', max_length=3, choices=[(b'PEN', b'Pending'), (b'SUB', b'Submitted'), (b'RUN', b'Running'), (b'COM', b'Complete'), (b'ERR', b'Error'), (b'ABT', b'Aborted'), (b'VAR', b'Various'), (b'VCP', b'Various-Complete')]), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='description', - field=models.CharField(default=b'', max_length=2048, blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='label', - field=models.CharField(max_length=1024), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='name', - field=models.CharField(max_length=1024), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0005_auto_20150914_1712.py b/tethys_compute/migrations/0005_auto_20150914_1712.py deleted file mode 100644 index 2f56da717..000000000 --- a/tethys_compute/migrations/0005_auto_20150914_1712.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0004_auto_20150812_1915'), - ] - - operations = [ - migrations.AlterField( - model_name='condorjob', - name='scheduler', - field=models.ForeignKey(blank=True, to='tethys_compute.Scheduler', null=True), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0006_auto_20151026_2142.py b/tethys_compute/migrations/0006_auto_20151026_2142.py deleted file mode 100644 index 2df6837b2..000000000 --- a/tethys_compute/migrations/0006_auto_20151026_2142.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0005_auto_20150914_1712'), - ] - - operations = [ - migrations.AlterField( - model_name='tethysjob', - name='extended_properties', - field=tethys_compute.utilities.DictionaryField(default=b'', blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='workspace', - field=models.CharField(default=b'', max_length=1024), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0006_auto_20151221_2207.py b/tethys_compute/migrations/0006_auto_20151221_2207.py deleted file mode 100644 index 2df6837b2..000000000 --- a/tethys_compute/migrations/0006_auto_20151221_2207.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0005_auto_20150914_1712'), - ] - - operations = [ - migrations.AlterField( - model_name='tethysjob', - name='extended_properties', - field=tethys_compute.utilities.DictionaryField(default=b'', blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='workspace', - field=models.CharField(default=b'', max_length=1024), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0007_merge.py b/tethys_compute/migrations/0007_merge.py deleted file mode 100644 index 98c987c78..000000000 --- a/tethys_compute/migrations/0007_merge.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9 on 2015-12-21 22:19 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0006_auto_20151221_2207'), - ('tethys_compute', '0006_auto_20151026_2142'), - ] - - operations = [ - ] diff --git a/tethys_compute/migrations/0008_start_condorjob_refactor.py b/tethys_compute/migrations/0008_start_condorjob_refactor.py deleted file mode 100644 index e2874e78a..000000000 --- a/tethys_compute/migrations/0008_start_condorjob_refactor.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-02-25 23:36 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0007_merge'), - ] - - operations = [ - migrations.CreateModel( - name='CondorBase', - fields=[ - ('tethysjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.TethysJob')), - ('cluster_id', models.IntegerField(blank=True, default=0)), - ('remote_id', models.CharField(blank=True, max_length=32, null=True)), - ('scheduler', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tethys_compute.Scheduler')), - ], - bases=('tethys_compute.tethysjob',), - ), - migrations.CreateModel( - name='CondorPyJob', - fields=[ - ('condorpyjob_id', models.AutoField(primary_key=True, serialize=False)), - ('_attributes', tethys_compute.utilities.DictionaryField(default=b'')), - ('_num_jobs', models.IntegerField(default=1)), - ('_remote_input_files', tethys_compute.utilities.ListField(default=b'')), - ], - ), - migrations.AddField( - model_name='condorjob', - name='condorpyjob_ptr', - field=models.OneToOneField(auto_created=True, null=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyJob'), - preserve_default=False, - ), - migrations.AlterField( - model_name='condorjob', - name='executable', - field=models.CharField(max_length=1024, default=''), - preserve_default=True, - ), - migrations.RemoveField( - model_name='tethysjob', - name='_subclass', - ), - ] diff --git a/tethys_compute/migrations/0009_condorjob_data_migration.py b/tethys_compute/migrations/0009_condorjob_data_migration.py deleted file mode 100644 index 3197494e9..000000000 --- a/tethys_compute/migrations/0009_condorjob_data_migration.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-02-26 14:53 -from __future__ import unicode_literals - -from django.db import migrations - - -def migrate_condorjobs(apps, schema_editor): - """ - Copy data from old CondorJob model to new CondorBase and CondorPyJob models. - - Args: - apps: Historical version of the models from django.apps.registry.Apps - schema_editor: Instance of SchemaEditor for manual DB editing. - """ - CondorJob = apps.get_model('tethys_compute', 'CondorJob') - CondorBase = apps.get_model('tethys_compute', 'CondorBase') - CondorPyJob = apps.get_model('tethys_compute', 'CondorPyJob') - - condorjobs = CondorJob.objects.all() - - for condorjob in condorjobs: - condorbase = CondorBase(tethysjob_ptr=condorjob.tethys_job, - cluster_id=condorjob.cluster_id, - remote_id=condorjob.remote_id, - scheduler=condorjob.scheduler, - ) - tethysjob = condorbase.tethysjob_ptr - condorbase.creation_time = tethysjob.creation_time - condorbase.user_id = tethysjob.user_id - condorbase.save() - - condorpyjob = CondorPyJob(_attributes=condorjob.attributes, - _num_jobs=condorjob.num_jobs, - _remote_input_files=condorjob.remote_input_files) - condorpyjob.save() - - condorjob.condorbase_ptr = condorbase - condorjob.condorpyjob_ptr = condorpyjob - condorjob.save() - - tethysjob = condorjob.tethys_job - tethysjob._subclass = 'condorbase' - tethysjob.save() - - -def unmigrate_condorjobs(apps, schema_editor): - """ - Copy data from new CondorBase and CondorPyJob models back to old CondorJob model. - - Args: - apps: Historical version of the models from django.apps.registry.Apps - schema_editor: Instance of SchemaEditor for manual DB editing. - """ - CondorJob = apps.get_model('tethys_compute', 'CondorJob') - CondorBase = apps.get_model('tethys_compute', 'CondorBase') - - condorjobs = CondorJob.objects.all() - for condorjob in condorjobs: - condorbase = CondorBase.objects.get(pk=condorjob.tethys_job_id) - condorjob.cluster_id =condorbase.cluster_id - condorjob.remote_id = condorbase.remote_id - condorjob.scheduler = condorbase.scheduler - tethysjob = condorbase.tethysjob_ptr - - condorpyjob = condorjob.condorpyjob_ptr - condorjob.attributes = condorpyjob.attributes - condorjob.num_jobs = condorpyjob.num_jobs - condorjob.remote_input_files = condorpyjob.remote_input_files - if 'executable' in condorjob.attributes: - condorjob.executable = condorjob.attributes['executable'] - - condorjob.save() - - tethysjob._subclass = 'condorjob' - tethysjob.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0008_start_condorjob_refactor'), - ] - - operations = [ - migrations.RunPython(code=migrate_condorjobs, reverse_code=unmigrate_condorjobs) - ] diff --git a/tethys_compute/migrations/0010_finish_condorjob_refactor.py b/tethys_compute/migrations/0010_finish_condorjob_refactor.py deleted file mode 100644 index b80b7ca5e..000000000 --- a/tethys_compute/migrations/0010_finish_condorjob_refactor.py +++ /dev/null @@ -1,125 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-02-26 14:54 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - -import tethys_compute - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0009_condorjob_data_migration'), - ] - - operations = [ - migrations.AddField( - model_name='tethysjob', - name='_process_results_function', - field=models.CharField(blank=True, max_length=1024, null=True), - ), - migrations.AddField( - model_name='tethysjob', - name='start_time', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.RemoveField( - model_name='condorjob', - name='cluster_id', - ), - migrations.RemoveField( - model_name='condorjob', - name='remote_id', - ), - migrations.RemoveField( - model_name='condorjob', - name='scheduler', - ), - migrations.RemoveField( - model_name='condorjob', - name='attributes', - ), - migrations.RemoveField( - model_name='condorjob', - name='num_jobs', - ), - migrations.RemoveField( - model_name='condorjob', - name='remote_input_files', - ), - migrations.RemoveField( - model_name='condorjob', - name='condorpy_template_name', - ), - migrations.RemoveField( - model_name='condorjob', - name='executable', - ), - migrations.RenameField( - model_name='condorjob', - old_name='tethys_job', - new_name='condorbase_ptr', - ), - migrations.AlterField( - model_name='condorjob', - name='condorbase_ptr', - field=models.OneToOneField(auto_created=True, primary_key=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, serialize=False, to='tethys_compute.CondorBase'), - preserve_default=False, - ), - migrations.AlterField( - model_name='condorjob', - name='condorpyjob_ptr', - field=models.OneToOneField(auto_created=True, null=False, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyJob'), - preserve_default=False, - ), - migrations.CreateModel( - name='CondorPyWorkflow', - fields=[ - ('condorpyworkflow_id', models.AutoField(primary_key=True, serialize=False)), - ('_max_jobs', tethys_compute.utilities.DictionaryField(blank=True, default=b'')), - ('_config', models.CharField(blank=True, max_length=1024, null=True)), - ], - ), - migrations.CreateModel( - name='CondorWorkflow', - fields=[ - ('condorpyworkflow_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyWorkflow')), - ('condorbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.CondorBase')), - ], - bases=('tethys_compute.condorbase', 'tethys_compute.condorpyworkflow'), - ), - migrations.CreateModel( - name='CondorWorkflowNode', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=1024)), - ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='node_set', to='tethys_compute.CondorPyWorkflow')), - ('pre_script', models.CharField(blank=True, max_length=1024, null=True)), - ('pre_script_args', models.CharField(blank=True, max_length=1024, null=True)), - ('post_script', models.CharField(blank=True, max_length=1024, null=True)), - ('post_script_args', models.CharField(blank=True, max_length=1024, null=True)), - ('variables', tethys_compute.utilities.DictionaryField(blank=True, default=b'')), - ('priority', models.IntegerField(blank=True, null=True)), - ('category', models.CharField(blank=True, max_length=128, null=True)), - ('retry', models.PositiveSmallIntegerField(blank=True, null=True)), - ('retry_unless_exit_value', models.IntegerField(blank=True, null=True)), - ('pre_skip', models.IntegerField(blank=True, null=True)), - ('abort_dag_on', models.IntegerField(blank=True, null=True)), - ('abort_dag_on_return_value', models.IntegerField(blank=True, null=True)), - ('dir', models.CharField(blank=True, max_length=1024, null=True)), - ('noop', models.BooleanField(default=False)), - ('done', models.BooleanField(default=False)), - ('parent_nodes', models.ManyToManyField(related_name='children_nodes', to='tethys_compute.CondorWorkflowNode')), - ], - ), - migrations.CreateModel( - name='CondorWorkflowJobNode', - fields=[ - ('condorpyjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyJob')), - ('condorworkflownode_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.CondorWorkflowNode')), - ], - bases=('tethys_compute.condorworkflownode', 'tethys_compute.condorpyjob'), - ), - ] diff --git a/tethys_compute/migrations/0011_delete_cluster.py b/tethys_compute/migrations/0011_delete_cluster.py deleted file mode 100644 index 5a2d234a6..000000000 --- a/tethys_compute/migrations/0011_delete_cluster.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11 on 2017-06-02 02:32 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0010_finish_condorjob_refactor'), - ] - - operations = [ - migrations.DeleteModel( - name='Cluster', - ), - ] diff --git a/tethys_compute/migrations/0012_delete_settings.py b/tethys_compute/migrations/0012_delete_settings.py deleted file mode 100644 index e4c00d6bb..000000000 --- a/tethys_compute/migrations/0012_delete_settings.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.2 on 2017-06-17 14:10 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0011_delete_cluster'), - ] - - operations = [ - migrations.RemoveField( - model_name='setting', - name='category', - ), - migrations.DeleteModel( - name='Setting', - ), - migrations.DeleteModel( - name='SettingsCategory', - ), - ] diff --git a/tethys_config/migrations/0001_initial.py b/tethys_config/migrations/0001_initial.py deleted file mode 100644 index 0aa7a300b..000000000 --- a/tethys_config/migrations/0001_initial.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Setting', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=30)), - ('content', models.CharField(max_length=500)), - ('date_modified', models.DateTimeField(verbose_name=b'date modified')), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.CreateModel( - name='SettingsCategory', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=30)), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.AddField( - model_name='setting', - name='category', - field=models.ForeignKey(to='tethys_config.SettingsCategory'), - preserve_default=True, - ), - ] diff --git a/tethys_config/migrations/0001_initial_20.py b/tethys_config/migrations/0001_initial_20.py index 6673eab4a..7645da6bd 100644 --- a/tethys_config/migrations/0001_initial_20.py +++ b/tethys_config/migrations/0001_initial_20.py @@ -4,12 +4,16 @@ from django.db import migrations, models import django.db.models.deletion -import tethys_config.init from ..init import initial_settings, reverse_init + class Migration(migrations.Migration): - replaces = [(b'tethys_config', '0001_initial'), (b'tethys_config', '0002_auto_20141029_1848'), (b'tethys_config', '0003_auto_20141223_2244'), (b'tethys_config', '0004_auto_20150424_2050'), (b'tethys_config', '0005_auto_20151023_1720')] + # replaces = [('tethys_config', '0001_initial'), + # ('tethys_config', '0002_auto_20141029_1848'), + # ('tethys_config', '0003_auto_20141223_2244'), + # ('tethys_config', '0004_auto_20150424_2050'), + # ('tethys_config', '0005_auto_20151023_1720')] initial = True @@ -18,48 +22,25 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Setting', + name='SettingsCategory', + options={'verbose_name': 'Settings Category', 'verbose_name_plural': 'Site Settings'}, fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=30)), - ('content', models.CharField(max_length=500)), - ('date_modified', models.DateTimeField(verbose_name=b'date modified')), + ('name', models.TextField(max_length=30)), ], ), migrations.CreateModel( - name='SettingsCategory', + name='Setting', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=30)), + ('name', models.TextField(max_length=30)), + ('content', models.TextField(blank=True, max_length=500)), + ('date_modified', models.DateTimeField(auto_now=True, verbose_name='date modified')), + ('category', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='tethys_config.SettingsCategory' + )), ], ), - migrations.AddField( - model_name='setting', - name='category', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tethys_config.SettingsCategory'), - ), - migrations.RunPython( - code=tethys_config.init.initial_settings, - reverse_code=tethys_config.init.reverse_init, - ), - migrations.AlterModelOptions( - name='settingscategory', - options={'verbose_name': 'Settings Category', 'verbose_name_plural': 'Site Settings'}, - ), - migrations.AlterField( - model_name='setting', - name='content', - field=models.TextField(blank=True, max_length=500), - ), - migrations.AlterField( - model_name='setting', - name='date_modified', - field=models.DateTimeField(auto_now=True, verbose_name=b'date modified'), - ), - migrations.AlterField( - model_name='setting', - name='name', - field=models.TextField(max_length=30), - ), migrations.RunPython(initial_settings, reverse_init), ] diff --git a/tethys_config/migrations/0002_auto_20141029_1848.py b/tethys_config/migrations/0002_auto_20141029_1848.py deleted file mode 100644 index ddc9f9ef5..000000000 --- a/tethys_config/migrations/0002_auto_20141029_1848.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -from ..init import initial_settings, reverse_init - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_config', '0001_initial'), - ] - - operations = [ - migrations.RunPython(initial_settings, reverse_init), - ] diff --git a/tethys_config/migrations/0003_auto_20141223_2244.py b/tethys_config/migrations/0003_auto_20141223_2244.py deleted file mode 100644 index c9adfaa57..000000000 --- a/tethys_config/migrations/0003_auto_20141223_2244.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_config', '0002_auto_20141029_1848'), - ] - - operations = [ - migrations.AlterModelOptions( - name='settingscategory', - options={'verbose_name': 'Settings Category', 'verbose_name_plural': 'Site Settings'}, - ), - migrations.AlterField( - model_name='setting', - name='content', - field=models.TextField(max_length=500, blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='setting', - name='date_modified', - field=models.DateTimeField(auto_now=True, verbose_name=b'date modified'), - preserve_default=True, - ), - migrations.AlterField( - model_name='setting', - name='name', - field=models.TextField(max_length=30), - preserve_default=True, - ), - ] diff --git a/tethys_config/migrations/0004_auto_20150424_2050.py b/tethys_config/migrations/0004_auto_20150424_2050.py deleted file mode 100644 index 2d2f7195f..000000000 --- a/tethys_config/migrations/0004_auto_20150424_2050.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.utils import timezone -from django.db import models, migrations - - -def settings10to11(apps, schema_editor): - """ - Update with new settings introduced in 1.1 which include: - - * Apps Library Title - """ - # Figure out what time it is right now - now = timezone.now() - - # Get current settings - Setting = apps.get_model('tethys_config', 'Setting') - all_settings = Setting.objects.all() - - # Remove any settings that already exist - SettingsCategory = apps.get_model('tethys_config', 'SettingsCategory') - general_category = SettingsCategory.objects.get(name="General Settings") - - app_library_title = False - - for setting in all_settings: - if setting.name == 'Apps Library Title': - app_library_title = True - - if not app_library_title: - general_category.setting_set.create(name='Apps Library Title', - content='Apps Library', - date_modified=now) - - general_category.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_config', '0003_auto_20141223_2244'), - ] - - operations = [ - migrations.RunPython(settings10to11), - ] diff --git a/tethys_config/migrations/0005_auto_20151023_1720.py b/tethys_config/migrations/0005_auto_20151023_1720.py deleted file mode 100644 index 27e0e0b8f..000000000 --- a/tethys_config/migrations/0005_auto_20151023_1720.py +++ /dev/null @@ -1,107 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.utils import timezone -from django.db import models, migrations - - -def settings12to13(apps, schema_editor): - """ - Update with new settings introduced in 1.3 which include: - - * Text Color - * Hover Text Color - * Apps Library Background Color - * Logo Height and Padding Settings - """ - # Figure out what time it is right now - now = timezone.now() - - # Get current settings - Setting = apps.get_model('tethys_config', 'Setting') - all_settings = Setting.objects.all() - - # Remove any settings that already exist - SettingsCategory = apps.get_model('tethys_config', 'SettingsCategory') - general_category = SettingsCategory.objects.get(name="General Settings") - - primary_text_color = False - primary_hover_color = False - secondary_text_color = False - secondary_hover_color = False - background_color = False - brand_image_height = False - brand_image_width = False - brand_image_padding = False - - - for setting in all_settings: - if setting.name == 'Primary Text Color': - primary_text_color = True - if setting.name == 'Primary Text Hover Color': - primary_hover_color = True - if setting.name == 'Secondary Text Color': - secondary_text_color = True - if setting.name == 'Secondary Text Hover Color': - secondary_hover_color = True - if setting.name == 'Background Color': - background_color = True - if setting.name == 'Brand Image Height': - brand_image_height = True - if setting.name == 'Brand Image Width': - brand_image_width = True - if setting.name == 'Brand Image Padding': - brand_image_padding = True - - if not primary_text_color: - general_category.setting_set.create(name="Primary Text Color", - content="", - date_modified=now) - - if not primary_hover_color: - general_category.setting_set.create(name="Primary Text Hover Color", - content="", - date_modified=now) - - if not secondary_text_color: - general_category.setting_set.create(name="Secondary Text Color", - content="", - date_modified=now) - - if not secondary_hover_color: - general_category.setting_set.create(name="Secondary Text Hover Color", - content="", - date_modified=now) - - if not background_color: - general_category.setting_set.create(name="Background Color", - content="", - date_modified=now) - - if not brand_image_height: - general_category.setting_set.create(name="Brand Image Height", - content="", - date_modified=now) - - if not brand_image_width: - general_category.setting_set.create(name="Brand Image Width", - content="", - date_modified=now) - - if not brand_image_padding: - general_category.setting_set.create(name="Brand Image Padding", - content="", - date_modified=now) - - general_category.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_config', '0004_auto_20150424_2050'), - ] - - operations = [ - migrations.RunPython(settings12to13), - ] diff --git a/tethys_config/migrations/0006_auto_20170603_0419.py b/tethys_config/migrations/0006_auto_20170603_0419.py deleted file mode 100644 index fff0dcd4d..000000000 --- a/tethys_config/migrations/0006_auto_20170603_0419.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11 on 2017-06-03 04:19 -from __future__ import unicode_literals -from django.db import migrations -from django.utils import timezone - - -def settings14to20(apps, schema_editor): - """ - Update settings to be compatible with 2.0: - * Remove /static/ from all static files paths. - """ - # Figure out what time it is right now - now = timezone.now() - - # Get current settings - Setting = apps.get_model('tethys_config', 'Setting') - all_settings = Setting.objects.all() - - for setting in all_settings: - setting.content = setting.content.replace('/static/', '') - setting.content = setting.content.replace('static/', '') - setting.save() - - -def settings20to14(apps, schema_editor): - """ - Reverse updates applied while migrating from 2.0 to 1.4. - """ - # nothing to reverse really... - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_config', '0005_auto_20151023_1720'), - ] - - operations = [ - migrations.RunPython(settings14to20, settings20to14), - ] diff --git a/tethys_services/migrations/0001_initial.py b/tethys_services/migrations/0001_initial.py deleted file mode 100644 index 626e9e539..000000000 --- a/tethys_services/migrations/0001_initial.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='DatasetService', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(unique=True, max_length=30)), - ('engine', models.CharField(max_length=200)), - ('endpoint', models.CharField(max_length=1024)), - ('apikey', models.CharField(max_length=100, blank=True)), - ('username', models.CharField(max_length=100, blank=True)), - ('password', models.CharField(max_length=100, blank=True)), - ], - options={ - 'verbose_name': 'Dataset Service', - 'verbose_name_plural': 'Dataset Services', - }, - bases=(models.Model,), - ), - ] diff --git a/tethys_services/migrations/0001_initial_20.py b/tethys_services/migrations/0001_initial_20.py index e7e31328a..4ed1dda6b 100644 --- a/tethys_services/migrations/0001_initial_20.py +++ b/tethys_services/migrations/0001_initial_20.py @@ -2,16 +2,28 @@ # Generated by Django 1.11.2 on 2017-06-17 14:04 from __future__ import unicode_literals +from django.conf import settings from django.db import migrations, models import tethys_services.models + class Migration(migrations.Migration): - replaces = [(b'tethys_services', '0001_initial'), (b'tethys_services', '0002_auto_20150119_1756'), (b'tethys_services', '0003_spatialdatasetservice'), (b'tethys_services', '0004_webprocessingservice'), (b'tethys_services', '0005_auto_20150424_2126'), (b'tethys_services', '0006_auto_20150729_1551'), (b'tethys_services', '0007_spatialdatasetservice_public_endpoint'), (b'tethys_services', '0008_auto_20151023_1427'), (b'tethys_services', '0009_persistentstoreservice')] + # replaces = [('tethys_services', '0001_initial'), + # ('tethys_services', '0002_auto_20150119_1756'), + # ('tethys_services', '0003_spatialdatasetservice'), + # ('tethys_services', '0004_webprocessingservice'), + # ('tethys_services', '0005_auto_20150424_2126'), + # ('tethys_services', '0006_auto_20150729_1551'), + # ('tethys_services', '0007_spatialdatasetservice_public_endpoint'), + # ('tethys_services', '0008_auto_20151023_1427'), + # ('tethys_services', '0009_persistentstoreservice') + # ] initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -20,7 +32,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=30, unique=True)), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.CkanDatasetEngine', b'CKAN'), (b'tethys_dataset_services.engines.HydroShareDatasetEngine', b'HydroShare')], default=b'tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.CkanDatasetEngine', 'CKAN'), ('tethys_dataset_services.engines.HydroShareDatasetEngine', 'HydroShare')], default='tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), ('endpoint', models.CharField(max_length=1024)), ('apikey', models.CharField(blank=True, max_length=100)), ('username', models.CharField(blank=True, max_length=100)), @@ -36,7 +48,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=30, unique=True)), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', b'GeoServer')], default=b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', 'GeoServer')], default='tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), ('endpoint', models.CharField(max_length=1024)), ('apikey', models.CharField(blank=True, max_length=100)), ('username', models.CharField(blank=True, max_length=100)), @@ -96,11 +108,11 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=30, unique=True)), - ('host', models.CharField(default=b'localhost', max_length=255)), + ('host', models.CharField(default='localhost', max_length=255)), ('port', models.IntegerField(default=5435, validators=[tethys_services.models.validate_persistent_store_port])), ('username', models.CharField(blank=True, max_length=100)), ('password', models.CharField(blank=True, max_length=100)), - ('engine', models.CharField(choices=[(b'postgresql', b'PostgreSQL')], default=b'postgresql', max_length=50)), + ('engine', models.CharField(choices=[('postgresql', 'PostgreSQL')], default='postgresql', max_length=50)), ], options={ 'verbose_name': 'Persistent Store Service', diff --git a/tethys_services/migrations/0002_auto_20150119_1756.py b/tethys_services/migrations/0002_auto_20150119_1756.py deleted file mode 100644 index 83ffdd8a5..000000000 --- a/tethys_services/migrations/0002_auto_20150119_1756.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='datasetservice', - name='engine', - field=models.CharField(default=b'tethys_dataset_services.engines.CkanDatasetEngine', max_length=200, choices=[(b'tethys_dataset_services.engines.CkanDatasetEngine', b'CKAN'), (b'tethys_dataset_services.engines.HydroShareDatasetEngine', b'HydroShare')]), - preserve_default=True, - ), - ] diff --git a/tethys_services/migrations/0003_spatialdatasetservice.py b/tethys_services/migrations/0003_spatialdatasetservice.py deleted file mode 100644 index 547113f9e..000000000 --- a/tethys_services/migrations/0003_spatialdatasetservice.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0002_auto_20150119_1756'), - ] - - operations = [ - migrations.CreateModel( - name='SpatialDatasetService', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(unique=True, max_length=30)), - ('engine', models.CharField(default=b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200, choices=[(b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', b'GeoServer')])), - ('endpoint', models.CharField(max_length=1024)), - ('apikey', models.CharField(max_length=100, blank=True)), - ('username', models.CharField(max_length=100, blank=True)), - ('password', models.CharField(max_length=100, blank=True)), - ], - options={ - 'verbose_name': 'Spatial Dataset Service', - 'verbose_name_plural': 'Spatial Dataset Services', - }, - bases=(models.Model,), - ), - ] diff --git a/tethys_services/migrations/0004_webprocessingservice.py b/tethys_services/migrations/0004_webprocessingservice.py deleted file mode 100644 index 19070b133..000000000 --- a/tethys_services/migrations/0004_webprocessingservice.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0003_spatialdatasetservice'), - ] - - operations = [ - migrations.CreateModel( - name='WebProcessingService', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(unique=True, max_length=30)), - ('endpoint', models.CharField(max_length=1024)), - ('username', models.CharField(max_length=100, blank=True)), - ('password', models.CharField(max_length=100, blank=True)), - ], - options={ - 'verbose_name': 'Web Processing Service', - 'verbose_name_plural': 'Web Processing Services', - }, - bases=(models.Model,), - ), - ] diff --git a/tethys_services/migrations/0005_auto_20150424_2126.py b/tethys_services/migrations/0005_auto_20150424_2126.py deleted file mode 100644 index a791a124f..000000000 --- a/tethys_services/migrations/0005_auto_20150424_2126.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -try: - from itertools import izip as zip -except ImportError: - pass - -from django.db import migrations, connection -from django.db.utils import ProgrammingError, InternalError - - -def query_to_dicts(query_string, *query_args): - """ - Utility that converts raw Django query to dictionary. - """ - cursor = connection.cursor() - cursor.execute(query_string, query_args) - col_names = [desc[0] for desc in cursor.description] - while True: - row = cursor.fetchone() - if row is None: - break - row_dict = dict(zip(col_names, row)) - yield row_dict - return - - -def migrate_to_services(apps, schema_editor): - """ - Move data from old tethys_datasets and tethys_wps tables to the new unified tethys_services tables if they exist. - """ - # Legacy table names - DATASET_SERVICE_TABLE = 'tethys_datasets_datasetservice' - SPATIAL_DATASET_SERVICE_TABLE = 'tethys_datasets_spatialdatasetservice' - WPS_TABLE = 'tethys_wps_webprocessingservice' - - # Use SQL literal to query legacy tables - generic_statement = 'SELECT * FROM {0}' - - # For dataset services - dataset_service_statement = generic_statement.format(DATASET_SERVICE_TABLE) - try: - results = query_to_dicts(dataset_service_statement) - DatasetService = apps.get_model('tethys_services', 'DatasetService') - - # Copy contents of old table to new table - for row in results: - d = DatasetService(name=row['name'], - engine=row['engine'], - endpoint=row['endpoint'], - apikey=row['apikey'], - username=row['username'], - password=row['password']) - d.save() - - except ProgrammingError: - pass - except InternalError: - pass - - # For spatial dataset services - spatial_dataset_service_statement = generic_statement.format(SPATIAL_DATASET_SERVICE_TABLE) - try: - results = query_to_dicts(spatial_dataset_service_statement) - SpatialDatasetService = apps.get_model('tethys_services', 'SpatialDatasetService') - - # Copy contents of old table to new table - for row in results: - d = SpatialDatasetService(name=row['name'], - engine=row['engine'], - endpoint=row['endpoint'], - apikey=row['apikey'], - username=row['username'], - password=row['password']) - d.save() - - except ProgrammingError: - pass - except InternalError: - pass - - # For web processing services - web_processing_service_statement = generic_statement.format(WPS_TABLE) - try: - results = query_to_dicts(web_processing_service_statement) - WebProcessingService = apps.get_model('tethys_services', 'WebProcessingService') - - # Copy contents of old table to new table - for row in results: - w = WebProcessingService(name=row['name'], - endpoint=row['endpoint'], - username=row['username'], - password=row['password']) - w.save() - - except ProgrammingError: - pass - except InternalError: - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0004_webprocessingservice'), - ] - - operations = [ - migrations.RunPython(migrate_to_services), - ] \ No newline at end of file diff --git a/tethys_services/migrations/0006_auto_20150729_1551.py b/tethys_services/migrations/0006_auto_20150729_1551.py deleted file mode 100644 index f38d165cd..000000000 --- a/tethys_services/migrations/0006_auto_20150729_1551.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_services.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0005_auto_20150424_2126'), - ] - - operations = [ - migrations.AlterField( - model_name='datasetservice', - name='endpoint', - field=models.CharField(max_length=1024, validators=[tethys_services.models.validate_dataset_service_endpoint]), - preserve_default=True, - ), - migrations.AlterField( - model_name='spatialdatasetservice', - name='endpoint', - field=models.CharField(max_length=1024, validators=[tethys_services.models.validate_spatial_dataset_service_endpoint]), - preserve_default=True, - ), - migrations.AlterField( - model_name='webprocessingservice', - name='endpoint', - field=models.CharField(max_length=1024, validators=[tethys_services.models.validate_wps_service_endpoint]), - preserve_default=True, - ), - ] diff --git a/tethys_services/migrations/0007_spatialdatasetservice_public_endpoint.py b/tethys_services/migrations/0007_spatialdatasetservice_public_endpoint.py deleted file mode 100644 index 57a315111..000000000 --- a/tethys_services/migrations/0007_spatialdatasetservice_public_endpoint.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_services.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0006_auto_20150729_1551'), - ] - - operations = [ - migrations.AddField( - model_name='spatialdatasetservice', - name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_dataset_service_endpoint]), - preserve_default=True, - ), - ] diff --git a/tethys_services/migrations/0008_auto_20151023_1427.py b/tethys_services/migrations/0008_auto_20151023_1427.py deleted file mode 100644 index 577db20d9..000000000 --- a/tethys_services/migrations/0008_auto_20151023_1427.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_services.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0007_spatialdatasetservice_public_endpoint'), - ] - - operations = [ - migrations.AddField( - model_name='datasetservice', - name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_dataset_service_endpoint]), - preserve_default=True, - ), - migrations.AddField( - model_name='webprocessingservice', - name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_wps_service_endpoint]), - preserve_default=True, - ), - migrations.AlterField( - model_name='spatialdatasetservice', - name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_spatial_dataset_service_endpoint]), - preserve_default=True, - ), - ] diff --git a/tethys_services/migrations/0009_persistentstoreservice.py b/tethys_services/migrations/0009_persistentstoreservice.py deleted file mode 100644 index 503ba42fd..000000000 --- a/tethys_services/migrations/0009_persistentstoreservice.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.8 on 2017-05-05 03:50 -from __future__ import unicode_literals - -from django.db import migrations, models -import tethys_services.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0008_auto_20151023_1427'), - ] - - operations = [ - migrations.CreateModel( - name='PersistentStoreService', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=30, unique=True)), - ('host', models.CharField(default=b'localhost', max_length=255)), - ('port', models.IntegerField(default=5435, validators=[tethys_services.models.validate_persistent_store_port])), - ('username', models.CharField(blank=True, max_length=100)), - ('password', models.CharField(blank=True, max_length=100)), - ('engine', models.CharField(choices=[(b'postgresql', b'PostgreSQL')], default=b'postgresql', max_length=50)), - ], - options={ - 'verbose_name': 'Persistent Store Service', - 'verbose_name_plural': 'Persistent Store Services', - }, - ), - ] From b7d0bd005792f65dadea88744038f18db8dda545 Mon Sep 17 00:00:00 2001 From: sdc50 Date: Sat, 14 Apr 2018 02:55:50 -0500 Subject: [PATCH 160/215] Create .travis.yml --- .travis.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..91bf7f8f5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,29 @@ +language: python + +env: + - PYTHON_VERSION="2" + - PYTHON_VERSION="3" + +# Setting sudo to false opts in to Travis-CI container-based builds. +sudo: false + +# Turn off email notifications +notifications: + email: false + +os: + - linux + - osx + +install: + - pwd + - bash ./scripts/install_tethys.sh -b master --python-version $PYTHON_VERSION + +# command to run tests +script: + # tethys test + + +# generate test coverage information +after_success: + # coveralls From a36c9947abd1f47c3b6ec514339e9f08b9a52692 Mon Sep 17 00:00:00 2001 From: sdc50 Date: Sat, 14 Apr 2018 03:07:06 -0500 Subject: [PATCH 161/215] Update .travis.yml --- .travis.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 91bf7f8f5..54d499456 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,9 @@ language: python +python: + # We don't actually use the Travis Python, but this keeps it organized. + - "3.5" + env: - PYTHON_VERSION="2" - PYTHON_VERSION="3" @@ -21,9 +25,10 @@ install: # command to run tests script: + tstart & # tethys test # generate test coverage information -after_success: +# after_success: # coveralls From 7a5a297ee88b8be6702a9e80e7e35882941d79b6 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Sat, 14 Apr 2018 03:10:09 -0500 Subject: [PATCH 162/215] More python 3 compatibility fixes --- tethys_apps/base/app_base.py | 2 ++ tethys_apps/migrations/0001_initial_20.py | 2 -- ...ethysextension.py => 0002_tethysextension.py} | 16 +++++++++++----- tethys_apps/utilities.py | 2 +- tethys_compute/migrations/0001_initial_20.py | 9 --------- tethys_config/context_processors.py | 10 +++++++++- tethys_config/init.py | 8 ++++---- tethys_config/migrations/0001_initial_20.py | 6 ------ tethys_services/migrations/0001_initial_20.py | 11 ----------- 9 files changed, 27 insertions(+), 39 deletions(-) rename tethys_apps/migrations/{0004_tethysextension.py => 0002_tethysextension.py} (51%) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 9ea1ba9e8..239bb9121 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -24,6 +24,8 @@ from .mixins import TethysBaseMixin from ..exceptions import TethysAppSettingDoesNotExist, TethysAppSettingNotAssigned +from past.builtins import basestring + tethys_log = logging.getLogger('tethys.app_base') diff --git a/tethys_apps/migrations/0001_initial_20.py b/tethys_apps/migrations/0001_initial_20.py index 285b77039..f4f258c2e 100644 --- a/tethys_apps/migrations/0001_initial_20.py +++ b/tethys_apps/migrations/0001_initial_20.py @@ -9,8 +9,6 @@ class Migration(migrations.Migration): - # replaces = [('tethys_apps', '0001_initial'), ('tethys_apps', '0002_tethysapp_tags'), ('tethys_apps', '0003_auto_20170505_0350')] - initial = True dependencies = [ diff --git a/tethys_apps/migrations/0004_tethysextension.py b/tethys_apps/migrations/0002_tethysextension.py similarity index 51% rename from tethys_apps/migrations/0004_tethysextension.py rename to tethys_apps/migrations/0002_tethysextension.py index 52b3765de..7f92e3831 100644 --- a/tethys_apps/migrations/0004_tethysextension.py +++ b/tethys_apps/migrations/0002_tethysextension.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2018-02-20 21:10 +# Generated by Django 1.11.10 on 2018-04-14 06:16 from __future__ import unicode_literals from django.db import migrations, models +import tethys_apps.base.mixins class Migration(migrations.Migration): @@ -16,11 +17,16 @@ class Migration(migrations.Migration): name='TethysExtension', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('package', models.CharField(default=b'', max_length=200, unique=True)), - ('name', models.CharField(default=b'', max_length=200)), - ('description', models.TextField(blank=True, default=b'', max_length=1000)), - ('root_url', models.CharField(default=b'', max_length=200)), + ('package', models.CharField(default='', max_length=200, unique=True)), + ('name', models.CharField(default='', max_length=200)), + ('description', models.TextField(blank=True, default='', max_length=1000)), + ('root_url', models.CharField(default='', max_length=200)), ('enabled', models.BooleanField(default=True)), ], + options={ + 'verbose_name': 'Tethys Extension', + 'verbose_name_plural': 'Installed Extensions', + }, + bases=(models.Model, tethys_apps.base.mixins.TethysBaseMixin), ), ] diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index e95fc75f4..046ad04b3 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -30,7 +30,7 @@ def get_directories_in_tethys(directory_names, with_app_name=False): """ # Determine the directories of tethys apps directory tethysapp_dir = safe_join(os.path.abspath(os.path.dirname(__file__)), 'tethysapp') - tethysapp_contents = os.walk(tethysapp_dir).next()[1] + tethysapp_contents = next(os.walk(tethysapp_dir))[1] potential_dirs = [safe_join(tethysapp_dir, item) for item in tethysapp_contents] diff --git a/tethys_compute/migrations/0001_initial_20.py b/tethys_compute/migrations/0001_initial_20.py index a3d3d17d1..8b6005e3a 100644 --- a/tethys_compute/migrations/0001_initial_20.py +++ b/tethys_compute/migrations/0001_initial_20.py @@ -12,15 +12,6 @@ class Migration(migrations.Migration): initial = True - # replaces = [('tethys_compute', '0001_initial'), ('tethys_compute', '0002_initialize_settings'), - # ('tethys_compute', '0003_auto_20150529_1651'), ('tethys_compute', '0004_auto_20150812_1915'), - # ('tethys_compute', '0005_auto_20150914_1712'), ('tethys_compute', '0006_auto_20151221_2207'), - # ('tethys_compute', '0006_auto_20151026_2142'), ('tethys_compute', '0007_merge'), - # ('tethys_compute', '0008_start_condorjob_refactor'), - # ('tethys_compute', '0009_condorjob_data_migration'), - # ('tethys_compute', '0010_finish_condorjob_refactor'), ('tethys_compute', '0011_delete_cluster'), - # ('tethys_compute', '0012_delete_settings')] - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] diff --git a/tethys_config/context_processors.py b/tethys_config/context_processors.py index 8886ee184..b9caf68b9 100644 --- a/tethys_config/context_processors.py +++ b/tethys_config/context_processors.py @@ -20,7 +20,15 @@ def tethys_global_settings_context(request): site_globals = Setting.as_dict() # Get terms and conditions - site_globals.update({'documents': TermsAndConditions.get_active_terms_list()}) + + # Grrr!!! TermsAndConditions has a different interface for Python 2 and 3 + try: + # for Python 3 + site_globals.update({'documents': TermsAndConditions.get_active_terms_list()}) + except AttributeError: + # for Python 3 + site_globals.update({'documents': TermsAndConditions.get_active_list(as_dict=False)}) + context = {'site_globals': site_globals} return context diff --git a/tethys_config/init.py b/tethys_config/init.py index 5ad1ec4d8..45f988f3e 100644 --- a/tethys_config/init.py +++ b/tethys_config/init.py @@ -28,7 +28,7 @@ def initial_settings(apps, schema_editor): date_modified=now) general_category.setting_set.create(name="Favicon", - content="/static/tethys_portal/images/default_favicon.png", + content="/tethys_portal/images/default_favicon.png", date_modified=now) general_category.setting_set.create(name="Brand Text", @@ -113,7 +113,7 @@ def initial_settings(apps, schema_editor): date_modified=now) home_category.setting_set.create(name="Feature 1 Image", - content="/static/tethys_portal/images/placeholder.gif", + content="/tethys_portal/images/placeholder.gif", date_modified=now) home_category.setting_set.create(name="Feature 2 Heading", @@ -126,7 +126,7 @@ def initial_settings(apps, schema_editor): date_modified=now) home_category.setting_set.create(name="Feature 2 Image", - content="/static/tethys_portal/images/placeholder.gif", + content="/tethys_portal/images/placeholder.gif", date_modified=now) home_category.setting_set.create(name="Feature 3 Heading", @@ -140,7 +140,7 @@ def initial_settings(apps, schema_editor): date_modified=now) home_category.setting_set.create(name="Feature 3 Image", - content="/static/tethys_portal/images/placeholder.gif", + content="/tethys_portal/images/placeholder.gif", date_modified=now) home_category.setting_set.create(name="Call to Action", diff --git a/tethys_config/migrations/0001_initial_20.py b/tethys_config/migrations/0001_initial_20.py index 7645da6bd..fb297e88a 100644 --- a/tethys_config/migrations/0001_initial_20.py +++ b/tethys_config/migrations/0001_initial_20.py @@ -9,12 +9,6 @@ class Migration(migrations.Migration): - # replaces = [('tethys_config', '0001_initial'), - # ('tethys_config', '0002_auto_20141029_1848'), - # ('tethys_config', '0003_auto_20141223_2244'), - # ('tethys_config', '0004_auto_20150424_2050'), - # ('tethys_config', '0005_auto_20151023_1720')] - initial = True dependencies = [ diff --git a/tethys_services/migrations/0001_initial_20.py b/tethys_services/migrations/0001_initial_20.py index 4ed1dda6b..e480c1b73 100644 --- a/tethys_services/migrations/0001_initial_20.py +++ b/tethys_services/migrations/0001_initial_20.py @@ -9,17 +9,6 @@ class Migration(migrations.Migration): - # replaces = [('tethys_services', '0001_initial'), - # ('tethys_services', '0002_auto_20150119_1756'), - # ('tethys_services', '0003_spatialdatasetservice'), - # ('tethys_services', '0004_webprocessingservice'), - # ('tethys_services', '0005_auto_20150424_2126'), - # ('tethys_services', '0006_auto_20150729_1551'), - # ('tethys_services', '0007_spatialdatasetservice_public_endpoint'), - # ('tethys_services', '0008_auto_20151023_1427'), - # ('tethys_services', '0009_persistentstoreservice') - # ] - initial = True dependencies = [ From 65b35afff7cf2a18b84ea18ae5d65ce9ae027dd1 Mon Sep 17 00:00:00 2001 From: sdc50 Date: Sat, 14 Apr 2018 03:15:39 -0500 Subject: [PATCH 163/215] Update .travis.yml --- .travis.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 54d499456..8a4e103ab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,4 @@ -language: python - -python: - # We don't actually use the Travis Python, but this keeps it organized. - - "3.5" +language: c env: - PYTHON_VERSION="2" From c53f884dec17d86d891ac28192bd01bc7ad9a741 Mon Sep 17 00:00:00 2001 From: sdc50 Date: Sat, 14 Apr 2018 03:58:15 -0500 Subject: [PATCH 164/215] Update .travis.yml --- .travis.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8a4e103ab..cc28b3f87 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,12 +16,13 @@ os: - osx install: - - pwd - - bash ./scripts/install_tethys.sh -b master --python-version $PYTHON_VERSION + - mkdir src + - mv ./* src/ + - bash ./src/scripts/install_tethys.sh -b master -t $PWD --python-version $PYTHON_VERSION # command to run tests script: - tstart & + tstart # tethys test From 52659508963d5e6174e4624d38df009a5de7d3bf Mon Sep 17 00:00:00 2001 From: sdc50 Date: Sat, 14 Apr 2018 04:00:06 -0500 Subject: [PATCH 165/215] Update .travis.yml --- .travis.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index cc28b3f87..be1a61c9c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,8 +16,9 @@ os: - osx install: - - mkdir src - - mv ./* src/ + - cd .. + - mv tethys src + - bash ./src/scripts/install_tethys.sh -h - bash ./src/scripts/install_tethys.sh -b master -t $PWD --python-version $PYTHON_VERSION # command to run tests From e3e7f09c30b3cd62acc80a31775672888ecd22ed Mon Sep 17 00:00:00 2001 From: sdc50 Date: Sat, 14 Apr 2018 04:36:14 -0500 Subject: [PATCH 166/215] Update .travis.yml --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index be1a61c9c..30677562f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,7 +19,7 @@ install: - cd .. - mv tethys src - bash ./src/scripts/install_tethys.sh -h - - bash ./src/scripts/install_tethys.sh -b master -t $PWD --python-version $PYTHON_VERSION + - bash ./src/scripts/install_tethys.sh --partial-tethys-install mesdat -t $PWD --python-version $PYTHON_VERSION # command to run tests script: From 6c2a13a826d1074d2943a19ab51e90e7196fba9b Mon Sep 17 00:00:00 2001 From: sdc50 Date: Sat, 14 Apr 2018 11:46:25 -0500 Subject: [PATCH 167/215] update install script and docs to provide more flexibility for CI (#339) --- .travis.yml | 20 +- docs/conf.py | 3 +- docs/installation/linux_and_mac.rst | 36 +++- scripts/install_tethys.sh | 301 +++++++++++++++++++--------- setup.py | 5 +- 5 files changed, 262 insertions(+), 103 deletions(-) diff --git a/.travis.yml b/.travis.yml index 30677562f..982834885 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,4 @@ +# language: c env: @@ -21,11 +22,24 @@ install: - bash ./src/scripts/install_tethys.sh -h - bash ./src/scripts/install_tethys.sh --partial-tethys-install mesdat -t $PWD --python-version $PYTHON_VERSION + # activate conda environment + - export PATH="$PWD/miniconda/bin:$PATH" + - source activate tethys + + # start database server + - pg_ctl -U postgres -D "${TETHYS_DB_DIR}/data" -l "${TETHYS_DB_DIR}/logfile" start -o "-p ${TETHYS_DB_PORT}" + + # generate new settings.py file with tethys_super user for tests + - rm ./src/tethys_portal/settings.py + - tethys gen settings --db-username tethys_super --db-password pass --db-port ${TETHYS_DB_PORT} + + # install test dependencies + - pip install -e $TETHYS_HOME/src/[tests] + + # command to run tests script: - tstart - # tethys test - + - tethys test # generate test coverage information # after_success: diff --git a/docs/conf.py b/docs/conf.py index 13726587d..7db2edab2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -174,8 +174,7 @@ # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -html_use_smartypants = False -smart_quotes = False +smartquotes = False # Custom sidebar templates, maps document names to template names. #html_sidebars = {} diff --git a/docs/installation/linux_and_mac.rst b/docs/installation/linux_and_mac.rst index 97ef66b23..61c177e90 100644 --- a/docs/installation/linux_and_mac.rst +++ b/docs/installation/linux_and_mac.rst @@ -29,7 +29,8 @@ For Systems with `curl` (e.g. Mac OSX and CentOS): curl :install_tethys:`sh` -o ./install_tethys.sh bash install_tethys.sh -b |branch| -.. note:: +Install Script Options +...................... You can customize your tethys installation by passing command line options to the installation script. The available options can be listed by running:: @@ -60,8 +61,14 @@ For Systems with `curl` (e.g. Mac OSX and CentOS): Username that the tethys database server will use. Default is 'tethys_default'. * `--db-password `: Password that the tethys database server will use. Default is 'pass'. + * `--db-super-username `: + Username for super user on the tethys database server. Default is 'tethys_super'. + * `--db-super-password `: + Password for super user on the tethys database server. Default is 'pass'. * `--db-port `: Port that the tethys database server will use. Default is 5436. + * `--db-dir `: + Path where the local PostgreSQL database will be created. Default is ${TETHYS_HOME}/psql. * `-S, --superuser `: Tethys super user name. Default is 'admin'. * `-E, --superuser-email `: @@ -75,6 +82,33 @@ For Systems with `curl` (e.g. Mac OSX and CentOS): If conda home is not in the default location then the `--conda-home` options must also be specified with this option. + * `--partial-tethys-install `: + List of flags to indicate which steps of the installation to do. + + Flags: + * `m` - Install Miniconda + * `r` - Clone Tethys repository + * `e` - Create Conda environment + * `s` - Create `settings.py` file + * `d` - Setup local database server + * `a` - Create activation/deactivation scripts for the Tethys Conda environment + * `t` - Create the `t` alias to activate the Tethys Conda environment + + For example, if you already have Miniconda installed and you have the repository cloned and have generated a `settings.py` file, but you want to use the install script to: + + * create a conda environment, + * setup the database, + * create the conda activation/deactivation scripts, and + * create the `t` shortcut, + + then you can run the following command:: + + bash install_tethys.sh --partial-tethys-install edat + + .. warning:: + + If `--skip-tethys-install` is used then this option will be ignored. + * `--install-docker`: Flag to include Docker installation as part of the install script (Linux only). See `2. Install Docker (OPTIONAL)`_ for more details. diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index 567c6787a..9acc2c1ec 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -3,26 +3,42 @@ USAGE="USAGE: . install_tethys.sh [options]\n \n OPTIONS:\n - -t, --tethys-home Path for tethys home directory. Default is ~/tethys.\n - -a, --allowed-host Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n - -p, --port Port on which to serve tethys. Default is 8000.\n - -b, --branch Branch to checkout from version control. Default is 'release'.\n - -c, --conda-home Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n - -n, --conda-env-name Name for tethys conda environment. Default is 'tethys'. - --python-version Main python version to install tethys environment into (2 or 3). Default is 2.\n - --db-username Username that the tethys database server will use. Default is 'tethys_default'.\n - --db-password Password that the tethys database server will use. Default is 'pass'.\n - --db-port Port that the tethys database server will use. Default is 5436.\n - -S, --superuser Tethys super user name. Default is 'admin'.\n - -E, --superuser-email Tethys super user email. Default is ''.\n - -P, --superuser-pass Tethys super user password. Default is 'pass'.\n - --skip-tethys-install Flag to skip the Tethys installation so that the Docker installation or production installation can be added to an existing Tethys installation.\n - --install-docker Flag to include Docker installation as part of the install script (Linux only).\n - --docker-options Command line options to pass to the 'tethys docker init' call if --install-docker is used. Default is \"'-d'\".\n - --production Flag to install Tethys in a production configuration.\n - --configure-selinux Flag to perform configuration of SELinux for production installation. (Linux only).\n - -x Flag to turn on shell command echoing.\n - -h, --help Print this help information.\n +\t -t, --tethys-home \t\t Path for tethys home directory. Default is ~/tethys.\n +\t -a, --allowed-host \t\t Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n +\t -p, --port \t\t\t Port on which to serve tethys. Default is 8000.\n +\t -b, --branch \t\t Branch to checkout from version control. Default is 'release'.\n +\t -c, --conda-home \t\t Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n +\t -n, --conda-env-name \t\t Name for tethys conda environment. Default is 'tethys'.\n +\t --python-version \t\t Main python version to install tethys environment into (2 or 3). Default is 2.\n +\t --db-username \t\t Username that the tethys database server will use. Default is 'tethys_default'.\n +\t --db-password \t\t Password that the tethys database server will use. Default is 'pass'.\n +\t --db-super-username \t Username for super user on the tethys database server. Default is 'tethys_super'.\n +\t --db-super-password \t Password for super user on the tethys database server. Default is 'pass'.\n +\t --db-port \t\t\t Port that the tethys database server will use. Default is 5436.\n +\t --db-dir \t\t\t Path where the local PostgreSQL database will be created. Default is \${TETHYS_HOME}/psql.\n +\t -S, --superuser \t\t Tethys super user name. Default is 'admin'.\n +\t -E, --superuser-email \t\t Tethys super user email. Default is ''.\n +\t -P, --superuser-pass \t Tethys super user password. Default is 'pass'.\n +\t --skip-tethys-install \t\t\t Flag to skip the Tethys installation so that the Docker installation or production installation can be added to an existing Tethys installation.\n +\t --partial-tethys-install \t List of flags to indicate which steps of the installation to do (e.g. --partial-tethys-install mresdat).\n\n + +\t \t FLAGS:\n +\t \t\t m - Install Miniconda\n +\t \t\t r - Clone Tethys repository\n +\t \t\t e - Create Conda environment\n +\t \t\t s - Create 'settings.py' file\n +\t \t\t d - Setup local database server\n +\t \t\t a - Create activation/deactivation scripts for the Tethys Conda environment\n +\t \t\t t - Create the 't' alias t\n\n + +\t \t NOTE: if --skip-tethys-install is used then this option will be ignored.\n\n + +\t --install-docker \t\t\t Flag to include Docker installation as part of the install script (Linux only).\n +\t --docker-options \t\t Command line options to pass to the 'tethys docker init' call if --install-docker is used. Default is \"'-d'\".\n +\t --production \t\t\t\t Flag to install Tethys in a production configuration.\n +\t --configure-selinux \t\t\t Flag to perform configuration of SELinux for production installation. (Linux only).\n +\t -x \t\t\t\t\t Flag to turn on shell command echoing.\n +\t -h, --help \t\t\t\t Print this help information.\n " print_usage () @@ -65,6 +81,8 @@ TETHYS_HOME=~/tethys TETHYS_PORT=8000 TETHYS_DB_USERNAME='tethys_default' TETHYS_DB_PASSWORD='pass' +TETHYS_DB_SUPER_USERNAME='tethys_super' +TETHYS_DB_SUPER_PASSWORD='pass' TETHYS_DB_PORT=5436 CONDA_ENV_NAME='tethys' PYTHON_VERSION='2' @@ -76,6 +94,14 @@ TETHYS_SUPER_USER_PASS='pass' DOCKER_OPTIONS='-d' +INSTALL_MINICONDA="true" +CLONE_REPO="true" +CREATE_ENV="true" +CREATE_SETTINGS="true" +SETUP_DB="true" +CREATE_ENV_SCRIPTS="true" +CREATE_SHORTCUTS="true" + # parse command line options set_option_value () { @@ -125,13 +151,25 @@ case $key in shift # past argument ;; --db-password) - set_option_value TETHYS_DB_PASS "$2" + set_option_value TETHYS_DB_PASSWORD "$2" + shift # past argument + ;; + --db-super-username) + set_option_value TETHYS_DB_SUPER_USERNAME "$2" + shift # past argument + ;; + --db-super-password) + set_option_value TETHYS_DB_SUPER_PASSWORD "$2" shift # past argument ;; --db-port) set_option_value TETHYS_DB_PORT "$2" shift # past argument ;; + --db-dir) + set_option_value TETHYS_DB_DIR "$2" + shift # past argument + ;; -S|--superuser) set_option_value TETHYS_SUPER_USER "$2" shift # past argument @@ -147,6 +185,39 @@ case $key in --skip-tethys-install) SKIP_TETHYS_INSTALL="true" ;; + --partial-tethys-install) + # Set all steps to false be default and then activate only those steps that have been specified. + INSTALL_MINICONDA= + CLONE_REPO= + CREATE_ENV= + CREATE_SETTINGS= + SETUP_DB= + CREATE_ENV_SCRIPTS= + CREATE_SHORTCUTS= + + if [[ "$2" = *"m"* ]]; then + INSTALL_MINICONDA="true" + fi + if [[ "$2" = *"r"* ]]; then + CLONE_REPO="true" + fi + if [[ "$2" = *"e"* ]]; then + CREATE_ENV="true" + fi + if [[ "$2" = *"s"* ]]; then + CREATE_SETTINGS="true" + fi + if [[ "$2" = *"d"* ]]; then + SETUP_DB="true" + fi + if [[ "$2" = *"a"* ]]; then + CREATE_ENV_SCRIPTS="true" + fi + if [[ "$2" = *"t"* ]]; then + CREATE_SHORTCUTS="true" + fi + shift # past argument + ;; --install-docker) if [ "$(uname)" = "Linux" ] then @@ -182,7 +253,8 @@ case $key in print_usage ;; *) # unknown option - echo Ignoring unrecognized option: $key + echo Unrecognized option: $key + print_usage ;; esac shift # past argument or value @@ -199,7 +271,13 @@ else resolve_relative_path CONDA_HOME ${CONDA_HOME} fi - +# set TETHYS_DB_DIR relative to TETHYS_HOME if not already set +if [ -z ${TETHYS_DB_DIR} ] +then + TETHYS_DB_DIR="${TETHYS_HOME}/psql" +else + resolve_relative_path TETHYS_DB_DIR ${TETHYS_DB_DIR} +fi if [ -n "${ECHO_COMMANDS}" ] then @@ -219,85 +297,113 @@ then mkdir -p "${TETHYS_HOME}" - # install miniconda - # first see if Miniconda is already installed - if [ -f "${CONDA_HOME}/bin/activate" ] + if [ -n "${INSTALL_MINICONDA}" ] then - echo "Using existing Miniconda installation..." - else - echo "Installing Miniconda..." - wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" || (echo -using curl instead; curl ${MINICONDA_URL} -o "${TETHYS_HOME}/miniconda.sh") - pushd ./ - cd "${TETHYS_HOME}" - bash miniconda.sh -b -p "${CONDA_HOME}" - popd + echo "MINICONDA" ${INSTALL_MINICONDA} + # install miniconda + # first see if Miniconda is already installed + if [ -f "${CONDA_HOME}/bin/activate" ] + then + echo "Using existing Miniconda installation..." + else + echo "Installing Miniconda..." + wget ${MINICONDA_URL} -O "${TETHYS_HOME}/miniconda.sh" || (echo -using curl instead; curl ${MINICONDA_URL} -o "${TETHYS_HOME}/miniconda.sh") + pushd ./ + cd "${TETHYS_HOME}" + bash miniconda.sh -b -p "${CONDA_HOME}" + popd + fi fi export PATH="${CONDA_HOME}/bin:$PATH" - # clone Tethys repo - echo "Cloning the Tethys Platform repo..." - conda install --yes git - git clone https://github.com/tethysplatform/tethys.git "${TETHYS_HOME}/src" - cd "${TETHYS_HOME}/src" - git checkout ${BRANCH} - - # create conda env and install Tethys - echo "Setting up the ${CONDA_ENV_NAME} environment..." - conda env create -n ${CONDA_ENV_NAME} -f "environment_py${PYTHON_VERSION}.yml" - . activate ${CONDA_ENV_NAME} - python setup.py develop - - # only pass --allowed-hosts option to gen settings command if it is not the default - if [ ${ALLOWED_HOST} != "127.0.0.1" ] + if [ -n "${CLONE_REPO}" ] then - ALLOWED_HOST_OPT="--allowed-host ${ALLOWED_HOST}" + # clone Tethys repo + echo "Cloning the Tethys Platform repo..." + conda install --yes git + git clone https://github.com/tethysplatform/tethys.git "${TETHYS_HOME}/src" + cd "${TETHYS_HOME}/src" + git checkout ${BRANCH} fi - tethys gen settings ${ALLOWED_HOST_OPT} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} - - # Setup local database - echo "Setting up the Tethys database..." - initdb -U postgres -D "${TETHYS_HOME}/psql/data" - pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" - echo "Waiting for databases to startup..."; sleep 10 - psql -U postgres -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" - createdb -U postgres -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 - - # Initialze Tethys database - tethys manage syncdb - echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python manage.py shell - pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" stop - . deactivate + if [ -n "${CREATE_ENV}" ] + then + # create conda env and install Tethys + echo "Setting up the ${CONDA_ENV_NAME} environment..." + conda env create -n ${CONDA_ENV_NAME} -f "${TETHYS_HOME}/src/environment_py${PYTHON_VERSION}.yml" + source activate ${CONDA_ENV_NAME} + python "${TETHYS_HOME}/src/setup.py" develop + else + source activate ${CONDA_ENV_NAME} + fi + + if [ -n "${CREATE_SETTINGS}" ] + then + # only pass --allowed-hosts option to gen settings command if it is not the default + if [ ${ALLOWED_HOST} != "127.0.0.1" ] + then + ALLOWED_HOST_OPT="--allowed-host ${ALLOWED_HOST}" + fi + tethys gen settings ${ALLOWED_HOST_OPT} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} + fi + + if [ -n "${SETUP_DB}" ] + then + # Setup local database + export TETHYS_DB_PORT="${TETHYS_DB_PORT}" + echo "Setting up the Tethys database..." + initdb -U postgres -D "${TETHYS_DB_DIR}/data" + pg_ctl -U postgres -D "${TETHYS_DB_DIR}/data" -l "${TETHYS_DB_DIR}/logfile" start -o "-p ${TETHYS_DB_PORT}" + echo "Waiting for databases to startup..."; sleep 10 + psql -U postgres -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_USERNAME} WITH NOCREATEDB NOCREATEROLE NOSUPERUSER PASSWORD '${TETHYS_DB_PASSWORD}';" + createdb -U postgres -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 + psql -U postgres -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_SUPER_USERNAME} WITH CREATEDB NOCREATEROLE SUPERUSER PASSWORD '${TETHYS_DB_SUPER_PASSWORD}';" + createdb -U postgres -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_SUPER_USERNAME} ${TETHYS_DB_SUPER_USERNAME} -E utf-8 -T template0 + + # Initialze Tethys database + tethys manage syncdb + echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python "${TETHYS_HOME}/src/manage.py" shell + pg_ctl -U postgres -D "${TETHYS_DB_DIR}/data" stop + . deactivate + fi - # Create environment activate/deactivate scripts - mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" - - echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" - echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" - echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" - echo "export CONDA_HOME='${CONDA_HOME}'" >> "${ACTIVATE_SCRIPT}" - echo "export CONDA_ENV_NAME='${CONDA_ENV_NAME}'" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_start_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" -l \"\${TETHYS_HOME}/psql/logfile\" start -o \"-p \${TETHYS_DB_PORT}\"'" >> "${ACTIVATE_SCRIPT}" - echo "alias tstartdb=tethys_start_db" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_stop_db='pg_ctl -U postgres -D \"\${TETHYS_HOME}/psql/data\" stop'" >> "${ACTIVATE_SCRIPT}" - echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" - echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" - echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" - - echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" - echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" - echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" - echo "unset CONDA_HOME" >> "${DEACTIVATE_SCRIPT}" - echo "unset CONDA_ENV_NAME" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tethys_start_db" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tstartdb" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tethys_stop_db" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" - echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" - - echo "# Tethys Platform" >> ~/${BASH_PROFILE} - echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} + if [ -n "${CREATE_ENV_SCRIPTS}" ] + then + # Create environment activate/deactivate scripts + mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" + + echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" + echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" + echo "export TETHYS_DB_PORT='${TETHYS_DB_PORT}'" >> "${ACTIVATE_SCRIPT}" + echo "export TETHYS_DB_DIR='${TETHYS_DB_DIR}'" >> "${ACTIVATE_SCRIPT}" + echo "export CONDA_HOME='${CONDA_HOME}'" >> "${ACTIVATE_SCRIPT}" + echo "export CONDA_ENV_NAME='${CONDA_ENV_NAME}'" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_start_db='pg_ctl -U postgres -D \"\${TETHYS_DB_DIR}/data\" -l \"\${TETHYS_DB_DIR}/logfile\" start -o \"-p \${TETHYS_DB_PORT}\"'" >> "${ACTIVATE_SCRIPT}" + echo "alias tstartdb=tethys_start_db" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_stop_db='pg_ctl -U postgres -D \"\${TETHYS_DB_DIR}/data\" stop'" >> "${ACTIVATE_SCRIPT}" + echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" + echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" + echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" + + echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" + echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" + echo "unset TETHYS_DB_PORT" >> "${DEACTIVATE_SCRIPT}" + echo "unset TETHYS_DB_DIR" >> "${DEACTIVATE_SCRIPT}" + echo "unset CONDA_HOME" >> "${DEACTIVATE_SCRIPT}" + echo "unset CONDA_ENV_NAME" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tethys_start_db" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tstartdb" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tethys_stop_db" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tstopdb" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" + echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" + fi + + if [ -n "${CREATE_SHORTCUTS}" ] + then + echo "# Tethys Platform" >> ~/${BASH_PROFILE} + echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} + fi fi # Install Docker (if flag is set) @@ -385,7 +491,7 @@ then . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} - pg_ctl -U postgres -D "${TETHYS_HOME}/psql/data" -l "${TETHYS_HOME}/psql/logfile" start -o "-p ${TETHYS_DB_PORT}" + pg_ctl -U postgres -D "${TETHYS_DB_DIR}/data" -l "${TETHYS_DB_DIR}/logfile" start -o "-p ${TETHYS_DB_PORT}" echo "Waiting for databases to startup..."; sleep 5 conda install -c conda-forge uwsgi -y tethys gen settings --production --allowed-host=${ALLOWED_HOST} --db-username ${TETHYS_DB_USERNAME} --db-password ${TETHYS_DB_PASSWORD} --db-port ${TETHYS_DB_PORT} --overwrite @@ -533,7 +639,10 @@ if [ -z "${SKIP_TETHYS_INSTALL}" ] then echo "Tethys installation complete!" echo - echo "NOTE: to enable the new alias 't' which activates the tethys environment you must run '. ~/${BASH_PROFILE}'" + if [ -n "${CREATE_SHORTCUTS}" ] + then + echo "NOTE: to enable the new alias 't' which activates the tethys environment you must run '. ~/${BASH_PROFILE}'" + fi fi on_exit(){ diff --git a/setup.py b/setup.py index 0c5480e22..1de715b26 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,10 @@ }, install_requires=requires, extras_require={ - 'tests': [], + 'tests': [ + 'requests_mock', + + ], 'docs': [ 'sphinx', 'sphinx_rtd_theme', From f759b972e1f41151e9996e7bbec1fb8bfb881130 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Fri, 13 Apr 2018 10:06:00 -0500 Subject: [PATCH 168/215] Python3-Compatibility --- .../commands/tethys_app_uninstall.py | 8 ++++--- tethys_apps/models.py | 24 ++++++++++++------- tethys_config/context_processors.py | 2 +- tethys_config/init.py | 5 +--- tethys_config/models.py | 8 ++++++- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index a9c852096..b406bac4c 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -16,6 +16,8 @@ from django.core.management.base import BaseCommand from tethys_apps.helpers import get_installed_tethys_apps, get_installed_tethys_extensions +from builtins import input + class Command(BaseCommand): """ @@ -48,11 +50,11 @@ def handle(self, *args, **options): valid_inputs = ('y', 'n', 'yes', 'no') no_inputs = ('n', 'no') - overwrite_input = raw_input('Are you sure you want to uninstall "{0}"? (y/n): '.format(item_with_prefix)).lower() + overwrite_input = input('Are you sure you want to uninstall "{0}"? (y/n): '.format(item_with_prefix)).lower() while overwrite_input not in valid_inputs: - overwrite_input = raw_input('Invalid option. Are you sure you want to ' - 'uninstall "{0}"? (y/n): '.format(item_with_prefix)).lower() + overwrite_input = input('Invalid option. Are you sure you want to ' + 'uninstall "{0}"? (y/n): '.format(item_with_prefix)).lower() if overwrite_input in no_inputs: self.stdout.write('Uninstall cancelled by user.') diff --git a/tethys_apps/models.py b/tethys_apps/models.py index dbcf22c8d..869526336 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -43,7 +43,7 @@ class TethysApp(models.Model, TethysBaseMixin): description = models.TextField(max_length=1000, blank=True, default='') enable_feedback = models.BooleanField(default=False) feedback_emails = ListField(default='', blank=True) - tags = models.CharField(max_length=200, blank=True, default='') + tags = models.CharField(max_length=200, blank=True, default='') # Developer first attributes index = models.CharField(max_length=200, default='') @@ -66,6 +66,9 @@ class Meta: def __unicode__(self): return text(self.name) + def __str__(self): + return text(self.name) + def add_settings(self, setting_list): """ Associate setting with app in database @@ -87,22 +90,22 @@ def settings(self): @property def custom_settings(self): return self.settings_set.exclude(customsetting__isnull=True) \ - .select_subclasses('customsetting') + .select_subclasses('customsetting') @property def dataset_service_settings(self): return self.settings_set.exclude(datasetservicesetting__isnull=True) \ - .select_subclasses('datasetservicesetting') + .select_subclasses('datasetservicesetting') @property def spatial_dataset_service_settings(self): return self.settings_set.exclude(spatialdatasetservicesetting__isnull=True) \ - .select_subclasses('spatialdatasetservicesetting') + .select_subclasses('spatialdatasetservicesetting') @property def wps_services_settings(self): return self.settings_set.exclude(webprocessingservicesetting__isnull=True) \ - .select_subclasses('webprocessingservicesetting') + .select_subclasses('webprocessingservicesetting') @property def persistent_store_connection_settings(self): @@ -167,6 +170,9 @@ class TethysAppSetting(models.Model): def __unicode__(self): return self.name + def __str__(self): + return self.name + @property def initializer_function(self): """ @@ -260,13 +266,13 @@ def clean(self): if self.value != '' and self.type == self.TYPE_FLOAT: try: float(self.value) - except: + except Exception: raise ValidationError('Value must be a float.') elif self.value != '' and self.type == self.TYPE_INTEGER: try: int(self.value) - except: + except Exception: raise ValidationError('Value must be an integer.') elif self.value != '' and self.type == self.TYPE_BOOLEAN: @@ -356,6 +362,7 @@ def get_value(self, as_public_endpoint=False, as_endpoint=False, as_engine=False return self.dataset_service + class SpatialDatasetServiceSetting(TethysAppSetting): """ Used to define a Spatial Dataset Service Setting. @@ -395,7 +402,7 @@ def clean(self): raise ValidationError('Required.') def get_value(self, as_public_endpoint=False, as_endpoint=False, as_wms=False, - as_wfs=False, as_engine=False): + as_wfs=False, as_engine=False): if not self.spatial_dataset_service: return None # TODO Why don't we raise a NotAssigned error here? @@ -418,6 +425,7 @@ def get_value(self, as_public_endpoint=False, as_endpoint=False, as_wms=False, return self.spatial_dataset_service + class WebProcessingServiceSetting(TethysAppSetting): """ Used to define a Web Processing Service Setting. diff --git a/tethys_config/context_processors.py b/tethys_config/context_processors.py index 8a8ba552b..8886ee184 100644 --- a/tethys_config/context_processors.py +++ b/tethys_config/context_processors.py @@ -20,7 +20,7 @@ def tethys_global_settings_context(request): site_globals = Setting.as_dict() # Get terms and conditions - site_globals.update({'documents': TermsAndConditions.get_active_list(as_dict=False)}) + site_globals.update({'documents': TermsAndConditions.get_active_terms_list()}) context = {'site_globals': site_globals} return context diff --git a/tethys_config/init.py b/tethys_config/init.py index 04562e131..5ad1ec4d8 100644 --- a/tethys_config/init.py +++ b/tethys_config/init.py @@ -36,7 +36,7 @@ def initial_settings(apps, schema_editor): date_modified=now) general_category.setting_set.create(name="Brand Image", - content="/static/tethys_portal/images/tethys-logo-75.png", + content="/tethys_portal/images/tethys-logo-75.png", date_modified=now) general_category.setting_set.create(name="Brand Image Height", @@ -167,6 +167,3 @@ def reverse_init(apps, schema_editor): for category in categories: category.delete() - - - diff --git a/tethys_config/models.py b/tethys_config/models.py index 7bc004d15..475b85b38 100644 --- a/tethys_config/models.py +++ b/tethys_config/models.py @@ -11,7 +11,7 @@ class SettingsCategory(models.Model): - name = models.CharField(max_length=30) + name = models.TextField(max_length=30) class Meta: verbose_name = 'Settings Category' @@ -20,6 +20,9 @@ class Meta: def __unicode__(self): return self.name + def __str__(self): + return self.name + class Setting(models.Model): name = models.TextField(max_length=30) @@ -30,6 +33,9 @@ class Setting(models.Model): def __unicode__(self): return self.name + def __str__(self): + return self.name + @classmethod def as_dict(cls): all_settings = cls.objects.all() From 6ad9e725d3b8722f421164136e23b32797f07170 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Sat, 14 Apr 2018 00:40:20 -0500 Subject: [PATCH 169/215] Python 3 compatibility fixes --- tethys_apps/app_installation.py | 24 +++++++++---------- tethys_apps/cli/app_settings_commands.py | 12 +++++----- tethys_apps/cli/docker_commands.py | 2 +- tethys_apps/cli/link_commands.py | 2 +- tethys_apps/cli/services_commands.py | 8 +++---- .../management/commands/collectworkspaces.py | 2 +- tethys_apps/utilities.py | 2 +- tethys_compute/models.py | 8 +++---- 8 files changed, 30 insertions(+), 30 deletions(-) diff --git a/tethys_apps/app_installation.py b/tethys_apps/app_installation.py index 781ee988f..f2cd6eb68 100644 --- a/tethys_apps/app_installation.py +++ b/tethys_apps/app_installation.py @@ -75,20 +75,20 @@ def _run_develop(self): # Create symbolic link try: - os_symlink = getattr(os,"symlink",None) - if callable(os_symlink): - os.symlink(self.app_package_dir, destination_dir) - else: - def symlink_ms(source,dest): - csl = ctypes.windll.kernel32.CreateSymbolicLinkW - csl.argtypes = (ctypes.c_wchar_p,ctypes.c_wchar_p,ctypes.c_uint32) - csl.restype = ctypes.c_ubyte - flags = 1 if os.path.isdir(source) else 0 - if csl(dest,source.replace('/','\\'),flags) == 0: + os_symlink = getattr(os,"symlink",None) + if callable(os_symlink): + os.symlink(self.app_package_dir, destination_dir) + else: + def symlink_ms(source,dest): + csl = ctypes.windll.kernel32.CreateSymbolicLinkW + csl.argtypes = (ctypes.c_wchar_p,ctypes.c_wchar_p,ctypes.c_uint32) + csl.restype = ctypes.c_ubyte + flags = 1 if os.path.isdir(source) else 0 + if csl(dest,source.replace('/','\\'),flags) == 0: raise ctypes.WinError() - os.symlink = symlink_ms(self.app_package_dir, destination_dir) + os.symlink = symlink_ms(self.app_package_dir, destination_dir) except Exception as e: - print e + print(e) try: shutil.rmtree(destination_dir) except Exception as e: diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index b86887b82..19955391b 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -39,7 +39,7 @@ def app_settings_list_command(args): p.write("\nUnlinked Settings:") if len(unlinked_settings) == 0: - print 'None' + print('None') else: is_first_row = True for setting in unlinked_settings: @@ -47,13 +47,13 @@ def app_settings_list_command(args): with pretty_output(BOLD) as p: p.write('{0: <10}{1: <40}{2: <15}'.format('ID', 'Name', 'Type')) is_first_row = False - print '{0: <10}{1: <40}{2: <15}'.format(setting.pk, setting.name, setting_type_dict[type(setting)]) + print('{0: <10}{1: <40}{2: <15}'.format(setting.pk, setting.name, setting_type_dict[type(setting)])) with pretty_output(BOLD) as p: p.write("\nLinked Settings:") if len(linked_settings) == 0: - print 'None' + print('None') else: is_first_row = True for setting in linked_settings: @@ -63,13 +63,13 @@ def app_settings_list_command(args): is_first_row = False service_name = setting.spatial_dataset_service.name if hasattr(setting, 'spatial_dataset_service') \ else setting.persistent_store_service.name - print '{0: <10}{1: <40}{2: <15}{3: <20}'.format(setting.pk, setting.name, - setting_type_dict[type(setting)], service_name) + print('{0: <10}{1: <40}{2: <15}{3: <20}'.format(setting.pk, setting.name, + setting_type_dict[type(setting)], service_name)) except ObjectDoesNotExist: with pretty_output(FG_RED) as p: p.write('The app you specified ("{0}") does not exist. Command aborted.'.format(app_package)) except Exception as e: - print e + print(e) with pretty_output(FG_RED) as p: p.write('Something went wrong. Please try again.') diff --git a/tethys_apps/cli/docker_commands.py b/tethys_apps/cli/docker_commands.py index 345b57793..f4dbd2c13 100644 --- a/tethys_apps/cli/docker_commands.py +++ b/tethys_apps/cli/docker_commands.py @@ -151,7 +151,7 @@ def validate_directory_cli_input(value, default=None): try: os.makedirs(value) except OSError as e: - print ('{0}: {1}'.format(repr(e), value)) + print('{0}: {1}'.format(repr(e), value)) prompt = 'Please provide a valid directory' prompt = add_default_to_prompt(prompt, default) prompt = close_prompt(prompt) diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index a7dfe09dd..2b066cab4 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -40,7 +40,7 @@ def link_command(args): exit(0) except Exception as e: - print e + print(e) with pretty_output(FG_RED) as p: p.write('An unexpected error occurred. Please try again.') exit(1) diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 253ae5adb..6bf2c3c9b 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -202,8 +202,8 @@ def services_list_command(args): with pretty_output(BOLD) as p: p.write('{0: <3}{1: <50}{2: <25}{3: <6}'.format('ID', 'Name', 'Host', 'Port')) is_first_entry = False - print '{0: <3}{1: <50}{2: <25}{3: <6}'.format(model_dict['id'], model_dict['name'], - model_dict['host'], model_dict['port']) + print('{0: <3}{1: <50}{2: <25}{3: <6}'.format(model_dict['id'], model_dict['name'], + model_dict['host'], model_dict['port'])) if list_spatial: spatial_entries = SpatialDatasetService.objects.order_by('id').all() @@ -218,8 +218,8 @@ def services_list_command(args): p.write('{0: <3}{1: <50}{2: <50}{3: <50}{4: <30}'.format('ID', 'Name', 'Endpoint', 'Public Endpoint', 'API Key')) is_first_entry = False - print '{0: <3}{1: <50}{2: <50}{3: <50}{4: <30}'.format(model_dict['id'], model_dict['name'], + print('{0: <3}{1: <50}{2: <50}{3: <50}{4: <30}'.format(model_dict['id'], model_dict['name'], model_dict['endpoint'], model_dict['public_endpoint'], model_dict['apikey'] if model_dict['apikey'] - else "None") + else "None")) diff --git a/tethys_apps/management/commands/collectworkspaces.py b/tethys_apps/management/commands/collectworkspaces.py index fc3da9949..e34699072 100644 --- a/tethys_apps/management/commands/collectworkspaces.py +++ b/tethys_apps/management/commands/collectworkspaces.py @@ -55,7 +55,7 @@ def handle(self, *args, **options): # Only perform if workspaces_path is a directory if not os.path.isdir(app_ws_path): - print 'WARNING: The workspace_path for app "{}" is not a directory. Skipping...'.format(app) + print('WARNING: The workspace_path for app "{}" is not a directory. Skipping...'.format(app)) continue if not os.path.islink(app_ws_path): diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index f1fb41ca1..e95fc75f4 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -132,7 +132,7 @@ def create_ps_database_setting(app_package, name, description='', required=False app_package)) return True except Exception as e: - print e + print(e) with pretty_output(FG_RED) as p: p.write('The above error was encountered. Aborted.'.format(app_package)) return False diff --git a/tethys_compute/models.py b/tethys_compute/models.py index d30cffdb6..7a6865416 100644 --- a/tethys_compute/models.py +++ b/tethys_compute/models.py @@ -425,8 +425,8 @@ def condor_job_pre_delete(sender, instance, using, **kwargs): try: instance.condor_object.close_remote() shutil.rmtree(instance.initial_dir, ignore_errors=True) - except Exception, e: - log.exception(e.message) + except Exception as e: + log.exception(str(e)) class CondorPyWorkflow(models.Model): @@ -543,8 +543,8 @@ def condor_workflow_pre_delete(sender, instance, using, **kwargs): try: instance.condor_object.close_remote() shutil.rmtree(instance.workspace, ignore_errors=True) - except Exception, e: - log.exception(e.message) + except Exception as e: + log.exception(str(e)) class CondorWorkflowNode(models.Model): From 475841c78cca12bbb892ba1a2059939646b5eae2 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Fri, 13 Apr 2018 10:05:06 -0500 Subject: [PATCH 170/215] Python3-Migrations --- tethys_apps/migrations/0001_initial.py | 39 ------ tethys_apps/migrations/0001_initial_20.py | 38 +++--- tethys_apps/migrations/0002_tethysapp_tags.py | 20 --- .../migrations/0003_auto_20170505_0350.py | 86 ------------ tethys_compute/migrations/0001_initial.py | 96 ------------- tethys_compute/migrations/0001_initial_20.py | 32 ++--- .../migrations/0002_initialize_settings.py | 16 --- .../migrations/0003_auto_20150529_1651.py | 128 ------------------ .../migrations/0004_auto_20150812_1915.py | 109 --------------- .../migrations/0005_auto_20150914_1712.py | 20 --- .../migrations/0006_auto_20151026_2142.py | 27 ---- .../migrations/0006_auto_20151221_2207.py | 27 ---- tethys_compute/migrations/0007_merge.py | 16 --- .../0008_start_condorjob_refactor.py | 52 ------- .../0009_condorjob_data_migration.py | 87 ------------ .../0010_finish_condorjob_refactor.py | 125 ----------------- .../migrations/0011_delete_cluster.py | 18 --- .../migrations/0012_delete_settings.py | 25 ---- tethys_config/migrations/0001_initial.py | 41 ------ tethys_config/migrations/0001_initial_20.py | 53 +++----- .../migrations/0002_auto_20141029_1848.py | 16 --- .../migrations/0003_auto_20141223_2244.py | 36 ----- .../migrations/0004_auto_20150424_2050.py | 47 ------- .../migrations/0005_auto_20151023_1720.py | 107 --------------- .../migrations/0006_auto_20170603_0419.py | 42 ------ tethys_services/migrations/0001_initial.py | 30 ---- tethys_services/migrations/0001_initial_20.py | 22 ++- .../migrations/0002_auto_20150119_1756.py | 20 --- .../migrations/0003_spatialdatasetservice.py | 31 ----- .../migrations/0004_webprocessingservice.py | 29 ---- .../migrations/0005_auto_20150424_2126.py | 111 --------------- .../migrations/0006_auto_20150729_1551.py | 33 ----- ...7_spatialdatasetservice_public_endpoint.py | 21 --- .../migrations/0008_auto_20151023_1427.py | 33 ----- .../migrations/0009_persistentstoreservice.py | 32 ----- 35 files changed, 69 insertions(+), 1596 deletions(-) delete mode 100644 tethys_apps/migrations/0001_initial.py delete mode 100644 tethys_apps/migrations/0002_tethysapp_tags.py delete mode 100644 tethys_apps/migrations/0003_auto_20170505_0350.py delete mode 100644 tethys_compute/migrations/0001_initial.py delete mode 100644 tethys_compute/migrations/0002_initialize_settings.py delete mode 100644 tethys_compute/migrations/0003_auto_20150529_1651.py delete mode 100644 tethys_compute/migrations/0004_auto_20150812_1915.py delete mode 100644 tethys_compute/migrations/0005_auto_20150914_1712.py delete mode 100644 tethys_compute/migrations/0006_auto_20151026_2142.py delete mode 100644 tethys_compute/migrations/0006_auto_20151221_2207.py delete mode 100644 tethys_compute/migrations/0007_merge.py delete mode 100644 tethys_compute/migrations/0008_start_condorjob_refactor.py delete mode 100644 tethys_compute/migrations/0009_condorjob_data_migration.py delete mode 100644 tethys_compute/migrations/0010_finish_condorjob_refactor.py delete mode 100644 tethys_compute/migrations/0011_delete_cluster.py delete mode 100644 tethys_compute/migrations/0012_delete_settings.py delete mode 100644 tethys_config/migrations/0001_initial.py delete mode 100644 tethys_config/migrations/0002_auto_20141029_1848.py delete mode 100644 tethys_config/migrations/0003_auto_20141223_2244.py delete mode 100644 tethys_config/migrations/0004_auto_20150424_2050.py delete mode 100644 tethys_config/migrations/0005_auto_20151023_1720.py delete mode 100644 tethys_config/migrations/0006_auto_20170603_0419.py delete mode 100644 tethys_services/migrations/0001_initial.py delete mode 100644 tethys_services/migrations/0002_auto_20150119_1756.py delete mode 100644 tethys_services/migrations/0003_spatialdatasetservice.py delete mode 100644 tethys_services/migrations/0004_webprocessingservice.py delete mode 100644 tethys_services/migrations/0005_auto_20150424_2126.py delete mode 100644 tethys_services/migrations/0006_auto_20150729_1551.py delete mode 100644 tethys_services/migrations/0007_spatialdatasetservice_public_endpoint.py delete mode 100644 tethys_services/migrations/0008_auto_20151023_1427.py delete mode 100644 tethys_services/migrations/0009_persistentstoreservice.py diff --git a/tethys_apps/migrations/0001_initial.py b/tethys_apps/migrations/0001_initial.py deleted file mode 100644 index 7243dafef..000000000 --- a/tethys_apps/migrations/0001_initial.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.5 on 2016-05-11 17:10 -from __future__ import unicode_literals - -from django.db import migrations, models -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='TethysApp', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('package', models.CharField(default=b'', max_length=200, unique=True)), - ('name', models.CharField(default=b'', max_length=200)), - ('description', models.TextField(blank=True, default=b'', max_length=1000)), - ('enable_feedback', models.BooleanField(default=False)), - ('feedback_emails', tethys_compute.utilities.ListField(blank=True, default=b'')), - ('index', models.CharField(default=b'', max_length=200)), - ('icon', models.CharField(default=b'', max_length=200)), - ('root_url', models.CharField(default=b'', max_length=200)), - ('color', models.CharField(default=b'', max_length=10)), - ('enabled', models.BooleanField(default=True)), - ('show_in_apps_library', models.BooleanField(default=True)), - ], - options={ - 'verbose_name': 'Tethys App', - 'verbose_name_plural': 'Installed Apps', - 'permissions': (('view_app', 'Can see app in library'), ('access_app', 'Can access app')), - }, - ), - ] diff --git a/tethys_apps/migrations/0001_initial_20.py b/tethys_apps/migrations/0001_initial_20.py index f20d515d8..285b77039 100644 --- a/tethys_apps/migrations/0001_initial_20.py +++ b/tethys_apps/migrations/0001_initial_20.py @@ -9,31 +9,31 @@ class Migration(migrations.Migration): - replaces = [(b'tethys_apps', '0001_initial'), (b'tethys_apps', '0002_tethysapp_tags'), (b'tethys_apps', '0003_auto_20170505_0350')] + # replaces = [('tethys_apps', '0001_initial'), ('tethys_apps', '0002_tethysapp_tags'), ('tethys_apps', '0003_auto_20170505_0350')] initial = True - # dependencies = [ - # ('tethys_services', '0009_persistentstoreservice'), - # ] + dependencies = [ + ('tethys_services', '0001_initial_20'), + ] operations = [ migrations.CreateModel( name='TethysApp', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('package', models.CharField(default=b'', max_length=200, unique=True)), - ('name', models.CharField(default=b'', max_length=200)), - ('description', models.TextField(blank=True, default=b'', max_length=1000)), + ('package', models.CharField(default='', max_length=200, unique=True)), + ('name', models.CharField(default='', max_length=200)), + ('description', models.TextField(blank=True, default='', max_length=1000)), ('enable_feedback', models.BooleanField(default=False)), - ('feedback_emails', tethys_compute.utilities.ListField(blank=True, default=b'')), - ('index', models.CharField(default=b'', max_length=200)), - ('icon', models.CharField(default=b'', max_length=200)), - ('root_url', models.CharField(default=b'', max_length=200)), - ('color', models.CharField(default=b'', max_length=10)), + ('feedback_emails', tethys_compute.utilities.ListField(blank=True, default='')), + ('index', models.CharField(default='', max_length=200)), + ('icon', models.CharField(default='', max_length=200)), + ('root_url', models.CharField(default='', max_length=200)), + ('color', models.CharField(default='', max_length=10)), ('enabled', models.BooleanField(default=True)), ('show_in_apps_library', models.BooleanField(default=True)), - ('tags', models.CharField(blank=True, default=b'', max_length=200)), + ('tags', models.CharField(blank=True, default='', max_length=200)), ], options={ 'verbose_name': 'Tethys App', @@ -45,10 +45,10 @@ class Migration(migrations.Migration): name='TethysAppSetting', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(default=b'', max_length=200)), - ('description', models.TextField(blank=True, default=b'', max_length=1000)), + ('name', models.CharField(default='', max_length=200)), + ('description', models.TextField(blank=True, default='', max_length=1000)), ('required', models.BooleanField(default=True)), - ('initializer', models.CharField(default=b'', max_length=1000)), + ('initializer', models.CharField(default='', max_length=1000)), ('initialized', models.BooleanField(default=False)), ], ), @@ -57,7 +57,7 @@ class Migration(migrations.Migration): fields=[ ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), ('value', models.CharField(blank=True, max_length=1024)), - ('type', models.CharField(choices=[(b'STRING', b'String'), (b'INTEGER', b'Integer'), (b'FLOAT', b'Float'), (b'BOOLEAN', b'Boolean')], default=b'STRING', max_length=200)), + ('type', models.CharField(choices=[('STRING', 'String'), ('INTEGER', 'Integer'), ('FLOAT', 'Float'), ('BOOLEAN', 'Boolean')], default='STRING', max_length=200)), ], bases=('tethys_apps.tethysappsetting',), ), @@ -65,7 +65,7 @@ class Migration(migrations.Migration): name='DatasetServiceSetting', fields=[ ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.CkanDatasetEngine', b'CKAN'), (b'tethys_dataset_services.engines.HydroShareDatasetEngine', b'HydroShare')], default=b'tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.CkanDatasetEngine', 'CKAN'), ('tethys_dataset_services.engines.HydroShareDatasetEngine', 'HydroShare')], default='tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), ('dataset_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.DatasetService')), ], bases=('tethys_apps.tethysappsetting',), @@ -92,7 +92,7 @@ class Migration(migrations.Migration): name='SpatialDatasetServiceSetting', fields=[ ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', b'GeoServer')], default=b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', 'GeoServer')], default='tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), ('spatial_dataset_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.SpatialDatasetService')), ], bases=('tethys_apps.tethysappsetting',), diff --git a/tethys_apps/migrations/0002_tethysapp_tags.py b/tethys_apps/migrations/0002_tethysapp_tags.py deleted file mode 100644 index b7e739133..000000000 --- a/tethys_apps/migrations/0002_tethysapp_tags.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-06-08 18:29 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_apps', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='tethysapp', - name='tags', - field=models.CharField(blank=True, default=b'', max_length=200), - ), - ] diff --git a/tethys_apps/migrations/0003_auto_20170505_0350.py b/tethys_apps/migrations/0003_auto_20170505_0350.py deleted file mode 100644 index f97aa2aeb..000000000 --- a/tethys_apps/migrations/0003_auto_20170505_0350.py +++ /dev/null @@ -1,86 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.8 on 2017-05-05 03:50 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0009_persistentstoreservice'), - ('tethys_apps', '0002_tethysapp_tags'), - ] - - operations = [ - migrations.CreateModel( - name='TethysAppSetting', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(default=b'', max_length=200)), - ('description', models.TextField(blank=True, default=b'', max_length=1000)), - ('required', models.BooleanField(default=True)), - ('initializer', models.CharField(default=b'', max_length=1000)), - ('initialized', models.BooleanField(default=False)), - ], - ), - migrations.CreateModel( - name='CustomSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('value', models.CharField(blank=True, max_length=1024)), - ('type', models.CharField(choices=[(b'STRING', b'String'), (b'INTEGER', b'Integer'), (b'FLOAT', b'Float'), (b'BOOLEAN', b'Boolean')], default=b'STRING', max_length=200)), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.CreateModel( - name='DatasetServiceSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.CkanDatasetEngine', b'CKAN'), (b'tethys_dataset_services.engines.HydroShareDatasetEngine', b'HydroShare')], default=b'tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), - ('dataset_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.DatasetService')), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.CreateModel( - name='PersistentStoreConnectionSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('persistent_store_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.PersistentStoreService')), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.CreateModel( - name='PersistentStoreDatabaseSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('spatial', models.BooleanField(default=False)), - ('dynamic', models.BooleanField(default=False)), - ('persistent_store_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.PersistentStoreService')), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.CreateModel( - name='SpatialDatasetServiceSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', b'GeoServer')], default=b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), - ('spatial_dataset_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.SpatialDatasetService')), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.CreateModel( - name='WebProcessingServiceSetting', - fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('web_processing_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.WebProcessingService')), - ], - bases=('tethys_apps.tethysappsetting',), - ), - migrations.AddField( - model_name='tethysappsetting', - name='tethys_app', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings_set', to='tethys_apps.TethysApp'), - ), - ] diff --git a/tethys_compute/migrations/0001_initial.py b/tethys_compute/migrations/0001_initial.py deleted file mode 100644 index d33a6ab87..000000000 --- a/tethys_compute/migrations/0001_initial.py +++ /dev/null @@ -1,96 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import datetime -from django.conf import settings -from django.utils.timezone import utc - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Cluster', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('_name', models.CharField(default=b'tethys_default', unique=True, max_length=30)), - ('_size', models.IntegerField(default=1)), - ('_status', models.CharField(default=b'STR', max_length=3, choices=[(b'STR', b'Starting'), (b'RUN', b'Running'), (b'STP', b'Stopped'), (b'UPD', b'Updating'), (b'DEL', b'Deleting'), (b'ERR', b'Error')])), - ('_cloud_provider', models.CharField(default=b'AWS', max_length=3, choices=[(b'AWS', b'Amazon Web Services'), (b'AZR', b'Microsoft Azure')])), - ('_master_image_id', models.CharField(max_length=9, null=True, blank=True)), - ('_node_image_id', models.CharField(max_length=9, null=True, blank=True)), - ('_master_instance_type', models.CharField(max_length=20, null=True, blank=True)), - ('_node_instance_type', models.CharField(max_length=20, null=True, blank=True)), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.CreateModel( - name='Setting', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.TextField(max_length=30)), - ('content', models.TextField(max_length=500, blank=True)), - ('date_modified', models.DateTimeField(auto_now=True, verbose_name=b'date modified')), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.CreateModel( - name='SettingsCategory', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=30)), - ], - options={ - 'verbose_name': 'Settings Category', - 'verbose_name_plural': 'Settings', - }, - bases=(models.Model,), - ), - migrations.CreateModel( - name='TethysJob', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=30)), - ('group', models.CharField(max_length=30)), - ('creation_time', models.DateTimeField(default=datetime.datetime(2015, 4, 6, 22, 37, 42, 933728, tzinfo=utc))), - ('submission_time', models.DateTimeField()), - ('completion_time', models.DateTimeField()), - ('status', models.CharField(default=b'PEN', max_length=3, choices=[(b'PEN', b'Pending'), (b'SUB', b'Submitted'), (b'RUN', b'Running'), (b'COM', b'Complete'), (b'ERR', b'Error')])), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.CreateModel( - name='CondorJob', - fields=[ - ('tethysjob_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='tethys_compute.TethysJob')), - ('scheduler', models.CharField(max_length=12)), - ('ami', models.CharField(max_length=9)), - ], - options={ - }, - bases=('tethys_compute.tethysjob',), - ), - migrations.AddField( - model_name='tethysjob', - name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), - preserve_default=True, - ), - migrations.AddField( - model_name='setting', - name='category', - field=models.ForeignKey(to='tethys_compute.SettingsCategory'), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0001_initial_20.py b/tethys_compute/migrations/0001_initial_20.py index 02a875565..a3d3d17d1 100644 --- a/tethys_compute/migrations/0001_initial_20.py +++ b/tethys_compute/migrations/0001_initial_20.py @@ -12,14 +12,14 @@ class Migration(migrations.Migration): initial = True - replaces = [(b'tethys_compute', '0001_initial'), (b'tethys_compute', '0002_initialize_settings'), - (b'tethys_compute', '0003_auto_20150529_1651'), (b'tethys_compute', '0004_auto_20150812_1915'), - (b'tethys_compute', '0005_auto_20150914_1712'), (b'tethys_compute', '0006_auto_20151221_2207'), - (b'tethys_compute', '0006_auto_20151026_2142'), (b'tethys_compute', '0007_merge'), - (b'tethys_compute', '0008_start_condorjob_refactor'), - (b'tethys_compute', '0009_condorjob_data_migration'), - (b'tethys_compute', '0010_finish_condorjob_refactor'), (b'tethys_compute', '0011_delete_cluster'), - (b'tethys_compute', '0012_delete_settings')] + # replaces = [('tethys_compute', '0001_initial'), ('tethys_compute', '0002_initialize_settings'), + # ('tethys_compute', '0003_auto_20150529_1651'), ('tethys_compute', '0004_auto_20150812_1915'), + # ('tethys_compute', '0005_auto_20150914_1712'), ('tethys_compute', '0006_auto_20151221_2207'), + # ('tethys_compute', '0006_auto_20151026_2142'), ('tethys_compute', '0007_merge'), + # ('tethys_compute', '0008_start_condorjob_refactor'), + # ('tethys_compute', '0009_condorjob_data_migration'), + # ('tethys_compute', '0010_finish_condorjob_refactor'), ('tethys_compute', '0011_delete_cluster'), + # ('tethys_compute', '0012_delete_settings')] dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), @@ -30,16 +30,16 @@ class Migration(migrations.Migration): name='CondorPyJob', fields=[ ('condorpyjob_id', models.AutoField(primary_key=True, serialize=False)), - ('_attributes', tethys_compute.utilities.DictionaryField(default=b'')), + ('_attributes', tethys_compute.utilities.DictionaryField(default='')), ('_num_jobs', models.IntegerField(default=1)), - ('_remote_input_files', tethys_compute.utilities.ListField(default=b'')), + ('_remote_input_files', tethys_compute.utilities.ListField(default='')), ], ), migrations.CreateModel( name='CondorPyWorkflow', fields=[ ('condorpyworkflow_id', models.AutoField(primary_key=True, serialize=False)), - ('_max_jobs', tethys_compute.utilities.DictionaryField(blank=True, default=b'')), + ('_max_jobs', tethys_compute.utilities.DictionaryField(blank=True, default='')), ('_config', models.CharField(blank=True, max_length=1024, null=True)), ], ), @@ -52,7 +52,7 @@ class Migration(migrations.Migration): ('pre_script_args', models.CharField(blank=True, max_length=1024, null=True)), ('post_script', models.CharField(blank=True, max_length=1024, null=True)), ('post_script_args', models.CharField(blank=True, max_length=1024, null=True)), - ('variables', tethys_compute.utilities.DictionaryField(blank=True, default=b'')), + ('variables', tethys_compute.utilities.DictionaryField(blank=True, default='')), ('priority', models.IntegerField(blank=True, null=True)), ('category', models.CharField(blank=True, max_length=128, null=True)), ('retry', models.PositiveSmallIntegerField(blank=True, null=True)), @@ -82,16 +82,16 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=1024)), - ('description', models.CharField(blank=True, default=b'', max_length=2048)), + ('description', models.CharField(blank=True, default='', max_length=2048)), ('label', models.CharField(max_length=1024)), ('creation_time', models.DateTimeField(auto_now_add=True)), ('execute_time', models.DateTimeField(blank=True, null=True)), ('start_time', models.DateTimeField(blank=True, null=True)), ('completion_time', models.DateTimeField(blank=True, null=True)), - ('workspace', models.CharField(default=b'', max_length=1024)), - ('extended_properties', tethys_compute.utilities.DictionaryField(blank=True, default=b'')), + ('workspace', models.CharField(default='', max_length=1024)), + ('extended_properties', tethys_compute.utilities.DictionaryField(blank=True, default='')), ('_process_results_function', models.CharField(blank=True, max_length=1024, null=True)), - ('_status', models.CharField(choices=[(b'PEN', b'Pending'), (b'SUB', b'Submitted'), (b'RUN', b'Running'), (b'COM', b'Complete'), (b'ERR', b'Error'), (b'ABT', b'Aborted'), (b'VAR', b'Various'), (b'VCP', b'Various-Complete')], default=b'PEN', max_length=3)), + ('_status', models.CharField(choices=[('PEN', 'Pending'), ('SUB', 'Submitted'), ('RUN', 'Running'), ('COM', 'Complete'), ('ERR', 'Error'), ('ABT', 'Aborted'), ('VAR', 'Various'), ('VCP', 'Various-Complete')], default='PEN', max_length=3)), ], options={ 'verbose_name': 'Job', diff --git a/tethys_compute/migrations/0002_initialize_settings.py b/tethys_compute/migrations/0002_initialize_settings.py deleted file mode 100644 index 0dc2f4162..000000000 --- a/tethys_compute/migrations/0002_initialize_settings.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -from tethys_compute.migrations import initialize_settings, clear_settings - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0001_initial'), - ] - - operations = [ - migrations.RunPython(clear_settings), - migrations.RunPython(initialize_settings, reverse_code=clear_settings), - ] \ No newline at end of file diff --git a/tethys_compute/migrations/0003_auto_20150529_1651.py b/tethys_compute/migrations/0003_auto_20150529_1651.py deleted file mode 100644 index ac539380a..000000000 --- a/tethys_compute/migrations/0003_auto_20150529_1651.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0002_initialize_settings'), - ] - - operations = [ - migrations.AlterModelOptions( - name='tethysjob', - options={'verbose_name': 'Job'}, - ), - migrations.RenameField( - model_name='tethysjob', - old_name='group', - new_name='label', - ), - migrations.RemoveField( - model_name='condorjob', - name='ami', - ), - migrations.RemoveField( - model_name='condorjob', - name='scheduler', - ), - migrations.RemoveField( - model_name='condorjob', - name='tethysjob_ptr', - ), - migrations.RemoveField( - model_name='tethysjob', - name='status', - ), - migrations.RemoveField( - model_name='tethysjob', - name='submission_time', - ), - migrations.AddField( - model_name='condorjob', - name='attributes', - field=tethys_compute.utilities.DictionaryField(default=b''), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='cluster_id', - field=models.IntegerField(default=0, blank=True), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='condorpy_template_name', - field=models.CharField(max_length=256, null=True, blank=True), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='executable', - field=models.CharField(default='', max_length=256), - preserve_default=False, - ), - migrations.AddField( - model_name='condorjob', - name='num_jobs', - field=models.IntegerField(default=1), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='remote_id', - field=models.CharField(max_length=32, null=True, blank=True), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='remote_input_files', - field=tethys_compute.utilities.ListField(default=b''), - preserve_default=True, - ), - migrations.AddField( - model_name='condorjob', - name='tethys_job', - field=models.OneToOneField(related_name='child', primary_key=True, default='', serialize=False, to='tethys_compute.TethysJob'), - preserve_default=False, - ), - migrations.AddField( - model_name='condorjob', - name='working_directory', - field=models.CharField(max_length=512, null=True, blank=True), - preserve_default=True, - ), - migrations.AddField( - model_name='tethysjob', - name='_status', - field=models.CharField(default=b'PEN', max_length=3, choices=[(b'PEN', b'Pending'), (b'SUB', b'Submitted'), (b'RUN', b'Running'), (b'COM', b'Complete'), (b'ERR', b'Error'), (b'ABT', b'Aborted')]), - preserve_default=True, - ), - migrations.AddField( - model_name='tethysjob', - name='description', - field=models.CharField(default=b'', max_length=1024, blank=True), - preserve_default=True, - ), - migrations.AddField( - model_name='tethysjob', - name='execute_time', - field=models.DateTimeField(null=True, blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='completion_time', - field=models.DateTimeField(null=True, blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='creation_time', - field=models.DateTimeField(auto_now_add=True), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0004_auto_20150812_1915.py b/tethys_compute/migrations/0004_auto_20150812_1915.py deleted file mode 100644 index 73cee0ffe..000000000 --- a/tethys_compute/migrations/0004_auto_20150812_1915.py +++ /dev/null @@ -1,109 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0003_auto_20150529_1651'), - ] - - operations = [ - migrations.CreateModel( - name='BasicJob', - fields=[ - ('tethysjob_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='tethys_compute.TethysJob')), - ], - options={ - }, - bases=('tethys_compute.tethysjob',), - ), - migrations.CreateModel( - name='Scheduler', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=1024)), - ('host', models.CharField(max_length=1024)), - ('username', models.CharField(max_length=1024, null=True, blank=True)), - ('password', models.CharField(max_length=1024, null=True, blank=True)), - ('private_key_path', models.CharField(max_length=1024, null=True, blank=True)), - ('private_key_pass', models.CharField(max_length=1024, null=True, blank=True)), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.RemoveField( - model_name='condorjob', - name='working_directory', - ), - migrations.AddField( - model_name='condorjob', - name='scheduler', - field=models.ForeignKey(default=1, to='tethys_compute.Scheduler'), - preserve_default=False, - ), - migrations.AddField( - model_name='tethysjob', - name='_subclass', - field=models.CharField(default=b'basicjob', max_length=30), - preserve_default=True, - ), - migrations.AddField( - model_name='tethysjob', - name='extended_properties', - field=tethys_compute.utilities.DictionaryField(default=b''), - preserve_default=True, - ), - migrations.AddField( - model_name='tethysjob', - name='workspace', - field=models.CharField(default=b'', max_length=1024), - preserve_default=True, - ), - migrations.AlterField( - model_name='condorjob', - name='condorpy_template_name', - field=models.CharField(max_length=1024, null=True, blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='condorjob', - name='executable', - field=models.CharField(max_length=1024), - preserve_default=True, - ), - migrations.AlterField( - model_name='condorjob', - name='tethys_job', - field=models.OneToOneField(primary_key=True, serialize=False, to='tethys_compute.TethysJob'), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='_status', - field=models.CharField(default=b'PEN', max_length=3, choices=[(b'PEN', b'Pending'), (b'SUB', b'Submitted'), (b'RUN', b'Running'), (b'COM', b'Complete'), (b'ERR', b'Error'), (b'ABT', b'Aborted'), (b'VAR', b'Various'), (b'VCP', b'Various-Complete')]), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='description', - field=models.CharField(default=b'', max_length=2048, blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='label', - field=models.CharField(max_length=1024), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='name', - field=models.CharField(max_length=1024), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0005_auto_20150914_1712.py b/tethys_compute/migrations/0005_auto_20150914_1712.py deleted file mode 100644 index 2f56da717..000000000 --- a/tethys_compute/migrations/0005_auto_20150914_1712.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0004_auto_20150812_1915'), - ] - - operations = [ - migrations.AlterField( - model_name='condorjob', - name='scheduler', - field=models.ForeignKey(blank=True, to='tethys_compute.Scheduler', null=True), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0006_auto_20151026_2142.py b/tethys_compute/migrations/0006_auto_20151026_2142.py deleted file mode 100644 index 2df6837b2..000000000 --- a/tethys_compute/migrations/0006_auto_20151026_2142.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0005_auto_20150914_1712'), - ] - - operations = [ - migrations.AlterField( - model_name='tethysjob', - name='extended_properties', - field=tethys_compute.utilities.DictionaryField(default=b'', blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='workspace', - field=models.CharField(default=b'', max_length=1024), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0006_auto_20151221_2207.py b/tethys_compute/migrations/0006_auto_20151221_2207.py deleted file mode 100644 index 2df6837b2..000000000 --- a/tethys_compute/migrations/0006_auto_20151221_2207.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0005_auto_20150914_1712'), - ] - - operations = [ - migrations.AlterField( - model_name='tethysjob', - name='extended_properties', - field=tethys_compute.utilities.DictionaryField(default=b'', blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='tethysjob', - name='workspace', - field=models.CharField(default=b'', max_length=1024), - preserve_default=True, - ), - ] diff --git a/tethys_compute/migrations/0007_merge.py b/tethys_compute/migrations/0007_merge.py deleted file mode 100644 index 98c987c78..000000000 --- a/tethys_compute/migrations/0007_merge.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9 on 2015-12-21 22:19 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0006_auto_20151221_2207'), - ('tethys_compute', '0006_auto_20151026_2142'), - ] - - operations = [ - ] diff --git a/tethys_compute/migrations/0008_start_condorjob_refactor.py b/tethys_compute/migrations/0008_start_condorjob_refactor.py deleted file mode 100644 index e2874e78a..000000000 --- a/tethys_compute/migrations/0008_start_condorjob_refactor.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-02-25 23:36 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion -import tethys_compute.utilities - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0007_merge'), - ] - - operations = [ - migrations.CreateModel( - name='CondorBase', - fields=[ - ('tethysjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.TethysJob')), - ('cluster_id', models.IntegerField(blank=True, default=0)), - ('remote_id', models.CharField(blank=True, max_length=32, null=True)), - ('scheduler', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tethys_compute.Scheduler')), - ], - bases=('tethys_compute.tethysjob',), - ), - migrations.CreateModel( - name='CondorPyJob', - fields=[ - ('condorpyjob_id', models.AutoField(primary_key=True, serialize=False)), - ('_attributes', tethys_compute.utilities.DictionaryField(default=b'')), - ('_num_jobs', models.IntegerField(default=1)), - ('_remote_input_files', tethys_compute.utilities.ListField(default=b'')), - ], - ), - migrations.AddField( - model_name='condorjob', - name='condorpyjob_ptr', - field=models.OneToOneField(auto_created=True, null=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyJob'), - preserve_default=False, - ), - migrations.AlterField( - model_name='condorjob', - name='executable', - field=models.CharField(max_length=1024, default=''), - preserve_default=True, - ), - migrations.RemoveField( - model_name='tethysjob', - name='_subclass', - ), - ] diff --git a/tethys_compute/migrations/0009_condorjob_data_migration.py b/tethys_compute/migrations/0009_condorjob_data_migration.py deleted file mode 100644 index 3197494e9..000000000 --- a/tethys_compute/migrations/0009_condorjob_data_migration.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-02-26 14:53 -from __future__ import unicode_literals - -from django.db import migrations - - -def migrate_condorjobs(apps, schema_editor): - """ - Copy data from old CondorJob model to new CondorBase and CondorPyJob models. - - Args: - apps: Historical version of the models from django.apps.registry.Apps - schema_editor: Instance of SchemaEditor for manual DB editing. - """ - CondorJob = apps.get_model('tethys_compute', 'CondorJob') - CondorBase = apps.get_model('tethys_compute', 'CondorBase') - CondorPyJob = apps.get_model('tethys_compute', 'CondorPyJob') - - condorjobs = CondorJob.objects.all() - - for condorjob in condorjobs: - condorbase = CondorBase(tethysjob_ptr=condorjob.tethys_job, - cluster_id=condorjob.cluster_id, - remote_id=condorjob.remote_id, - scheduler=condorjob.scheduler, - ) - tethysjob = condorbase.tethysjob_ptr - condorbase.creation_time = tethysjob.creation_time - condorbase.user_id = tethysjob.user_id - condorbase.save() - - condorpyjob = CondorPyJob(_attributes=condorjob.attributes, - _num_jobs=condorjob.num_jobs, - _remote_input_files=condorjob.remote_input_files) - condorpyjob.save() - - condorjob.condorbase_ptr = condorbase - condorjob.condorpyjob_ptr = condorpyjob - condorjob.save() - - tethysjob = condorjob.tethys_job - tethysjob._subclass = 'condorbase' - tethysjob.save() - - -def unmigrate_condorjobs(apps, schema_editor): - """ - Copy data from new CondorBase and CondorPyJob models back to old CondorJob model. - - Args: - apps: Historical version of the models from django.apps.registry.Apps - schema_editor: Instance of SchemaEditor for manual DB editing. - """ - CondorJob = apps.get_model('tethys_compute', 'CondorJob') - CondorBase = apps.get_model('tethys_compute', 'CondorBase') - - condorjobs = CondorJob.objects.all() - for condorjob in condorjobs: - condorbase = CondorBase.objects.get(pk=condorjob.tethys_job_id) - condorjob.cluster_id =condorbase.cluster_id - condorjob.remote_id = condorbase.remote_id - condorjob.scheduler = condorbase.scheduler - tethysjob = condorbase.tethysjob_ptr - - condorpyjob = condorjob.condorpyjob_ptr - condorjob.attributes = condorpyjob.attributes - condorjob.num_jobs = condorpyjob.num_jobs - condorjob.remote_input_files = condorpyjob.remote_input_files - if 'executable' in condorjob.attributes: - condorjob.executable = condorjob.attributes['executable'] - - condorjob.save() - - tethysjob._subclass = 'condorjob' - tethysjob.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0008_start_condorjob_refactor'), - ] - - operations = [ - migrations.RunPython(code=migrate_condorjobs, reverse_code=unmigrate_condorjobs) - ] diff --git a/tethys_compute/migrations/0010_finish_condorjob_refactor.py b/tethys_compute/migrations/0010_finish_condorjob_refactor.py deleted file mode 100644 index b80b7ca5e..000000000 --- a/tethys_compute/migrations/0010_finish_condorjob_refactor.py +++ /dev/null @@ -1,125 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.2 on 2016-02-26 14:54 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - -import tethys_compute - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0009_condorjob_data_migration'), - ] - - operations = [ - migrations.AddField( - model_name='tethysjob', - name='_process_results_function', - field=models.CharField(blank=True, max_length=1024, null=True), - ), - migrations.AddField( - model_name='tethysjob', - name='start_time', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.RemoveField( - model_name='condorjob', - name='cluster_id', - ), - migrations.RemoveField( - model_name='condorjob', - name='remote_id', - ), - migrations.RemoveField( - model_name='condorjob', - name='scheduler', - ), - migrations.RemoveField( - model_name='condorjob', - name='attributes', - ), - migrations.RemoveField( - model_name='condorjob', - name='num_jobs', - ), - migrations.RemoveField( - model_name='condorjob', - name='remote_input_files', - ), - migrations.RemoveField( - model_name='condorjob', - name='condorpy_template_name', - ), - migrations.RemoveField( - model_name='condorjob', - name='executable', - ), - migrations.RenameField( - model_name='condorjob', - old_name='tethys_job', - new_name='condorbase_ptr', - ), - migrations.AlterField( - model_name='condorjob', - name='condorbase_ptr', - field=models.OneToOneField(auto_created=True, primary_key=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, serialize=False, to='tethys_compute.CondorBase'), - preserve_default=False, - ), - migrations.AlterField( - model_name='condorjob', - name='condorpyjob_ptr', - field=models.OneToOneField(auto_created=True, null=False, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyJob'), - preserve_default=False, - ), - migrations.CreateModel( - name='CondorPyWorkflow', - fields=[ - ('condorpyworkflow_id', models.AutoField(primary_key=True, serialize=False)), - ('_max_jobs', tethys_compute.utilities.DictionaryField(blank=True, default=b'')), - ('_config', models.CharField(blank=True, max_length=1024, null=True)), - ], - ), - migrations.CreateModel( - name='CondorWorkflow', - fields=[ - ('condorpyworkflow_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyWorkflow')), - ('condorbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.CondorBase')), - ], - bases=('tethys_compute.condorbase', 'tethys_compute.condorpyworkflow'), - ), - migrations.CreateModel( - name='CondorWorkflowNode', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=1024)), - ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='node_set', to='tethys_compute.CondorPyWorkflow')), - ('pre_script', models.CharField(blank=True, max_length=1024, null=True)), - ('pre_script_args', models.CharField(blank=True, max_length=1024, null=True)), - ('post_script', models.CharField(blank=True, max_length=1024, null=True)), - ('post_script_args', models.CharField(blank=True, max_length=1024, null=True)), - ('variables', tethys_compute.utilities.DictionaryField(blank=True, default=b'')), - ('priority', models.IntegerField(blank=True, null=True)), - ('category', models.CharField(blank=True, max_length=128, null=True)), - ('retry', models.PositiveSmallIntegerField(blank=True, null=True)), - ('retry_unless_exit_value', models.IntegerField(blank=True, null=True)), - ('pre_skip', models.IntegerField(blank=True, null=True)), - ('abort_dag_on', models.IntegerField(blank=True, null=True)), - ('abort_dag_on_return_value', models.IntegerField(blank=True, null=True)), - ('dir', models.CharField(blank=True, max_length=1024, null=True)), - ('noop', models.BooleanField(default=False)), - ('done', models.BooleanField(default=False)), - ('parent_nodes', models.ManyToManyField(related_name='children_nodes', to='tethys_compute.CondorWorkflowNode')), - ], - ), - migrations.CreateModel( - name='CondorWorkflowJobNode', - fields=[ - ('condorpyjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyJob')), - ('condorworkflownode_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.CondorWorkflowNode')), - ], - bases=('tethys_compute.condorworkflownode', 'tethys_compute.condorpyjob'), - ), - ] diff --git a/tethys_compute/migrations/0011_delete_cluster.py b/tethys_compute/migrations/0011_delete_cluster.py deleted file mode 100644 index 5a2d234a6..000000000 --- a/tethys_compute/migrations/0011_delete_cluster.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11 on 2017-06-02 02:32 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0010_finish_condorjob_refactor'), - ] - - operations = [ - migrations.DeleteModel( - name='Cluster', - ), - ] diff --git a/tethys_compute/migrations/0012_delete_settings.py b/tethys_compute/migrations/0012_delete_settings.py deleted file mode 100644 index e4c00d6bb..000000000 --- a/tethys_compute/migrations/0012_delete_settings.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.2 on 2017-06-17 14:10 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_compute', '0011_delete_cluster'), - ] - - operations = [ - migrations.RemoveField( - model_name='setting', - name='category', - ), - migrations.DeleteModel( - name='Setting', - ), - migrations.DeleteModel( - name='SettingsCategory', - ), - ] diff --git a/tethys_config/migrations/0001_initial.py b/tethys_config/migrations/0001_initial.py deleted file mode 100644 index 0aa7a300b..000000000 --- a/tethys_config/migrations/0001_initial.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Setting', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=30)), - ('content', models.CharField(max_length=500)), - ('date_modified', models.DateTimeField(verbose_name=b'date modified')), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.CreateModel( - name='SettingsCategory', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=30)), - ], - options={ - }, - bases=(models.Model,), - ), - migrations.AddField( - model_name='setting', - name='category', - field=models.ForeignKey(to='tethys_config.SettingsCategory'), - preserve_default=True, - ), - ] diff --git a/tethys_config/migrations/0001_initial_20.py b/tethys_config/migrations/0001_initial_20.py index 6673eab4a..7645da6bd 100644 --- a/tethys_config/migrations/0001_initial_20.py +++ b/tethys_config/migrations/0001_initial_20.py @@ -4,12 +4,16 @@ from django.db import migrations, models import django.db.models.deletion -import tethys_config.init from ..init import initial_settings, reverse_init + class Migration(migrations.Migration): - replaces = [(b'tethys_config', '0001_initial'), (b'tethys_config', '0002_auto_20141029_1848'), (b'tethys_config', '0003_auto_20141223_2244'), (b'tethys_config', '0004_auto_20150424_2050'), (b'tethys_config', '0005_auto_20151023_1720')] + # replaces = [('tethys_config', '0001_initial'), + # ('tethys_config', '0002_auto_20141029_1848'), + # ('tethys_config', '0003_auto_20141223_2244'), + # ('tethys_config', '0004_auto_20150424_2050'), + # ('tethys_config', '0005_auto_20151023_1720')] initial = True @@ -18,48 +22,25 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Setting', + name='SettingsCategory', + options={'verbose_name': 'Settings Category', 'verbose_name_plural': 'Site Settings'}, fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=30)), - ('content', models.CharField(max_length=500)), - ('date_modified', models.DateTimeField(verbose_name=b'date modified')), + ('name', models.TextField(max_length=30)), ], ), migrations.CreateModel( - name='SettingsCategory', + name='Setting', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=30)), + ('name', models.TextField(max_length=30)), + ('content', models.TextField(blank=True, max_length=500)), + ('date_modified', models.DateTimeField(auto_now=True, verbose_name='date modified')), + ('category', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='tethys_config.SettingsCategory' + )), ], ), - migrations.AddField( - model_name='setting', - name='category', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tethys_config.SettingsCategory'), - ), - migrations.RunPython( - code=tethys_config.init.initial_settings, - reverse_code=tethys_config.init.reverse_init, - ), - migrations.AlterModelOptions( - name='settingscategory', - options={'verbose_name': 'Settings Category', 'verbose_name_plural': 'Site Settings'}, - ), - migrations.AlterField( - model_name='setting', - name='content', - field=models.TextField(blank=True, max_length=500), - ), - migrations.AlterField( - model_name='setting', - name='date_modified', - field=models.DateTimeField(auto_now=True, verbose_name=b'date modified'), - ), - migrations.AlterField( - model_name='setting', - name='name', - field=models.TextField(max_length=30), - ), migrations.RunPython(initial_settings, reverse_init), ] diff --git a/tethys_config/migrations/0002_auto_20141029_1848.py b/tethys_config/migrations/0002_auto_20141029_1848.py deleted file mode 100644 index ddc9f9ef5..000000000 --- a/tethys_config/migrations/0002_auto_20141029_1848.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -from ..init import initial_settings, reverse_init - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_config', '0001_initial'), - ] - - operations = [ - migrations.RunPython(initial_settings, reverse_init), - ] diff --git a/tethys_config/migrations/0003_auto_20141223_2244.py b/tethys_config/migrations/0003_auto_20141223_2244.py deleted file mode 100644 index c9adfaa57..000000000 --- a/tethys_config/migrations/0003_auto_20141223_2244.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_config', '0002_auto_20141029_1848'), - ] - - operations = [ - migrations.AlterModelOptions( - name='settingscategory', - options={'verbose_name': 'Settings Category', 'verbose_name_plural': 'Site Settings'}, - ), - migrations.AlterField( - model_name='setting', - name='content', - field=models.TextField(max_length=500, blank=True), - preserve_default=True, - ), - migrations.AlterField( - model_name='setting', - name='date_modified', - field=models.DateTimeField(auto_now=True, verbose_name=b'date modified'), - preserve_default=True, - ), - migrations.AlterField( - model_name='setting', - name='name', - field=models.TextField(max_length=30), - preserve_default=True, - ), - ] diff --git a/tethys_config/migrations/0004_auto_20150424_2050.py b/tethys_config/migrations/0004_auto_20150424_2050.py deleted file mode 100644 index 2d2f7195f..000000000 --- a/tethys_config/migrations/0004_auto_20150424_2050.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.utils import timezone -from django.db import models, migrations - - -def settings10to11(apps, schema_editor): - """ - Update with new settings introduced in 1.1 which include: - - * Apps Library Title - """ - # Figure out what time it is right now - now = timezone.now() - - # Get current settings - Setting = apps.get_model('tethys_config', 'Setting') - all_settings = Setting.objects.all() - - # Remove any settings that already exist - SettingsCategory = apps.get_model('tethys_config', 'SettingsCategory') - general_category = SettingsCategory.objects.get(name="General Settings") - - app_library_title = False - - for setting in all_settings: - if setting.name == 'Apps Library Title': - app_library_title = True - - if not app_library_title: - general_category.setting_set.create(name='Apps Library Title', - content='Apps Library', - date_modified=now) - - general_category.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_config', '0003_auto_20141223_2244'), - ] - - operations = [ - migrations.RunPython(settings10to11), - ] diff --git a/tethys_config/migrations/0005_auto_20151023_1720.py b/tethys_config/migrations/0005_auto_20151023_1720.py deleted file mode 100644 index 27e0e0b8f..000000000 --- a/tethys_config/migrations/0005_auto_20151023_1720.py +++ /dev/null @@ -1,107 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.utils import timezone -from django.db import models, migrations - - -def settings12to13(apps, schema_editor): - """ - Update with new settings introduced in 1.3 which include: - - * Text Color - * Hover Text Color - * Apps Library Background Color - * Logo Height and Padding Settings - """ - # Figure out what time it is right now - now = timezone.now() - - # Get current settings - Setting = apps.get_model('tethys_config', 'Setting') - all_settings = Setting.objects.all() - - # Remove any settings that already exist - SettingsCategory = apps.get_model('tethys_config', 'SettingsCategory') - general_category = SettingsCategory.objects.get(name="General Settings") - - primary_text_color = False - primary_hover_color = False - secondary_text_color = False - secondary_hover_color = False - background_color = False - brand_image_height = False - brand_image_width = False - brand_image_padding = False - - - for setting in all_settings: - if setting.name == 'Primary Text Color': - primary_text_color = True - if setting.name == 'Primary Text Hover Color': - primary_hover_color = True - if setting.name == 'Secondary Text Color': - secondary_text_color = True - if setting.name == 'Secondary Text Hover Color': - secondary_hover_color = True - if setting.name == 'Background Color': - background_color = True - if setting.name == 'Brand Image Height': - brand_image_height = True - if setting.name == 'Brand Image Width': - brand_image_width = True - if setting.name == 'Brand Image Padding': - brand_image_padding = True - - if not primary_text_color: - general_category.setting_set.create(name="Primary Text Color", - content="", - date_modified=now) - - if not primary_hover_color: - general_category.setting_set.create(name="Primary Text Hover Color", - content="", - date_modified=now) - - if not secondary_text_color: - general_category.setting_set.create(name="Secondary Text Color", - content="", - date_modified=now) - - if not secondary_hover_color: - general_category.setting_set.create(name="Secondary Text Hover Color", - content="", - date_modified=now) - - if not background_color: - general_category.setting_set.create(name="Background Color", - content="", - date_modified=now) - - if not brand_image_height: - general_category.setting_set.create(name="Brand Image Height", - content="", - date_modified=now) - - if not brand_image_width: - general_category.setting_set.create(name="Brand Image Width", - content="", - date_modified=now) - - if not brand_image_padding: - general_category.setting_set.create(name="Brand Image Padding", - content="", - date_modified=now) - - general_category.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_config', '0004_auto_20150424_2050'), - ] - - operations = [ - migrations.RunPython(settings12to13), - ] diff --git a/tethys_config/migrations/0006_auto_20170603_0419.py b/tethys_config/migrations/0006_auto_20170603_0419.py deleted file mode 100644 index fff0dcd4d..000000000 --- a/tethys_config/migrations/0006_auto_20170603_0419.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11 on 2017-06-03 04:19 -from __future__ import unicode_literals -from django.db import migrations -from django.utils import timezone - - -def settings14to20(apps, schema_editor): - """ - Update settings to be compatible with 2.0: - * Remove /static/ from all static files paths. - """ - # Figure out what time it is right now - now = timezone.now() - - # Get current settings - Setting = apps.get_model('tethys_config', 'Setting') - all_settings = Setting.objects.all() - - for setting in all_settings: - setting.content = setting.content.replace('/static/', '') - setting.content = setting.content.replace('static/', '') - setting.save() - - -def settings20to14(apps, schema_editor): - """ - Reverse updates applied while migrating from 2.0 to 1.4. - """ - # nothing to reverse really... - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_config', '0005_auto_20151023_1720'), - ] - - operations = [ - migrations.RunPython(settings14to20, settings20to14), - ] diff --git a/tethys_services/migrations/0001_initial.py b/tethys_services/migrations/0001_initial.py deleted file mode 100644 index 626e9e539..000000000 --- a/tethys_services/migrations/0001_initial.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='DatasetService', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(unique=True, max_length=30)), - ('engine', models.CharField(max_length=200)), - ('endpoint', models.CharField(max_length=1024)), - ('apikey', models.CharField(max_length=100, blank=True)), - ('username', models.CharField(max_length=100, blank=True)), - ('password', models.CharField(max_length=100, blank=True)), - ], - options={ - 'verbose_name': 'Dataset Service', - 'verbose_name_plural': 'Dataset Services', - }, - bases=(models.Model,), - ), - ] diff --git a/tethys_services/migrations/0001_initial_20.py b/tethys_services/migrations/0001_initial_20.py index e7e31328a..4ed1dda6b 100644 --- a/tethys_services/migrations/0001_initial_20.py +++ b/tethys_services/migrations/0001_initial_20.py @@ -2,16 +2,28 @@ # Generated by Django 1.11.2 on 2017-06-17 14:04 from __future__ import unicode_literals +from django.conf import settings from django.db import migrations, models import tethys_services.models + class Migration(migrations.Migration): - replaces = [(b'tethys_services', '0001_initial'), (b'tethys_services', '0002_auto_20150119_1756'), (b'tethys_services', '0003_spatialdatasetservice'), (b'tethys_services', '0004_webprocessingservice'), (b'tethys_services', '0005_auto_20150424_2126'), (b'tethys_services', '0006_auto_20150729_1551'), (b'tethys_services', '0007_spatialdatasetservice_public_endpoint'), (b'tethys_services', '0008_auto_20151023_1427'), (b'tethys_services', '0009_persistentstoreservice')] + # replaces = [('tethys_services', '0001_initial'), + # ('tethys_services', '0002_auto_20150119_1756'), + # ('tethys_services', '0003_spatialdatasetservice'), + # ('tethys_services', '0004_webprocessingservice'), + # ('tethys_services', '0005_auto_20150424_2126'), + # ('tethys_services', '0006_auto_20150729_1551'), + # ('tethys_services', '0007_spatialdatasetservice_public_endpoint'), + # ('tethys_services', '0008_auto_20151023_1427'), + # ('tethys_services', '0009_persistentstoreservice') + # ] initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -20,7 +32,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=30, unique=True)), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.CkanDatasetEngine', b'CKAN'), (b'tethys_dataset_services.engines.HydroShareDatasetEngine', b'HydroShare')], default=b'tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.CkanDatasetEngine', 'CKAN'), ('tethys_dataset_services.engines.HydroShareDatasetEngine', 'HydroShare')], default='tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), ('endpoint', models.CharField(max_length=1024)), ('apikey', models.CharField(blank=True, max_length=100)), ('username', models.CharField(blank=True, max_length=100)), @@ -36,7 +48,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=30, unique=True)), - ('engine', models.CharField(choices=[(b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', b'GeoServer')], default=b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', 'GeoServer')], default='tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), ('endpoint', models.CharField(max_length=1024)), ('apikey', models.CharField(blank=True, max_length=100)), ('username', models.CharField(blank=True, max_length=100)), @@ -96,11 +108,11 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=30, unique=True)), - ('host', models.CharField(default=b'localhost', max_length=255)), + ('host', models.CharField(default='localhost', max_length=255)), ('port', models.IntegerField(default=5435, validators=[tethys_services.models.validate_persistent_store_port])), ('username', models.CharField(blank=True, max_length=100)), ('password', models.CharField(blank=True, max_length=100)), - ('engine', models.CharField(choices=[(b'postgresql', b'PostgreSQL')], default=b'postgresql', max_length=50)), + ('engine', models.CharField(choices=[('postgresql', 'PostgreSQL')], default='postgresql', max_length=50)), ], options={ 'verbose_name': 'Persistent Store Service', diff --git a/tethys_services/migrations/0002_auto_20150119_1756.py b/tethys_services/migrations/0002_auto_20150119_1756.py deleted file mode 100644 index 83ffdd8a5..000000000 --- a/tethys_services/migrations/0002_auto_20150119_1756.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='datasetservice', - name='engine', - field=models.CharField(default=b'tethys_dataset_services.engines.CkanDatasetEngine', max_length=200, choices=[(b'tethys_dataset_services.engines.CkanDatasetEngine', b'CKAN'), (b'tethys_dataset_services.engines.HydroShareDatasetEngine', b'HydroShare')]), - preserve_default=True, - ), - ] diff --git a/tethys_services/migrations/0003_spatialdatasetservice.py b/tethys_services/migrations/0003_spatialdatasetservice.py deleted file mode 100644 index 547113f9e..000000000 --- a/tethys_services/migrations/0003_spatialdatasetservice.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0002_auto_20150119_1756'), - ] - - operations = [ - migrations.CreateModel( - name='SpatialDatasetService', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(unique=True, max_length=30)), - ('engine', models.CharField(default=b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200, choices=[(b'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', b'GeoServer')])), - ('endpoint', models.CharField(max_length=1024)), - ('apikey', models.CharField(max_length=100, blank=True)), - ('username', models.CharField(max_length=100, blank=True)), - ('password', models.CharField(max_length=100, blank=True)), - ], - options={ - 'verbose_name': 'Spatial Dataset Service', - 'verbose_name_plural': 'Spatial Dataset Services', - }, - bases=(models.Model,), - ), - ] diff --git a/tethys_services/migrations/0004_webprocessingservice.py b/tethys_services/migrations/0004_webprocessingservice.py deleted file mode 100644 index 19070b133..000000000 --- a/tethys_services/migrations/0004_webprocessingservice.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0003_spatialdatasetservice'), - ] - - operations = [ - migrations.CreateModel( - name='WebProcessingService', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(unique=True, max_length=30)), - ('endpoint', models.CharField(max_length=1024)), - ('username', models.CharField(max_length=100, blank=True)), - ('password', models.CharField(max_length=100, blank=True)), - ], - options={ - 'verbose_name': 'Web Processing Service', - 'verbose_name_plural': 'Web Processing Services', - }, - bases=(models.Model,), - ), - ] diff --git a/tethys_services/migrations/0005_auto_20150424_2126.py b/tethys_services/migrations/0005_auto_20150424_2126.py deleted file mode 100644 index a791a124f..000000000 --- a/tethys_services/migrations/0005_auto_20150424_2126.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -try: - from itertools import izip as zip -except ImportError: - pass - -from django.db import migrations, connection -from django.db.utils import ProgrammingError, InternalError - - -def query_to_dicts(query_string, *query_args): - """ - Utility that converts raw Django query to dictionary. - """ - cursor = connection.cursor() - cursor.execute(query_string, query_args) - col_names = [desc[0] for desc in cursor.description] - while True: - row = cursor.fetchone() - if row is None: - break - row_dict = dict(zip(col_names, row)) - yield row_dict - return - - -def migrate_to_services(apps, schema_editor): - """ - Move data from old tethys_datasets and tethys_wps tables to the new unified tethys_services tables if they exist. - """ - # Legacy table names - DATASET_SERVICE_TABLE = 'tethys_datasets_datasetservice' - SPATIAL_DATASET_SERVICE_TABLE = 'tethys_datasets_spatialdatasetservice' - WPS_TABLE = 'tethys_wps_webprocessingservice' - - # Use SQL literal to query legacy tables - generic_statement = 'SELECT * FROM {0}' - - # For dataset services - dataset_service_statement = generic_statement.format(DATASET_SERVICE_TABLE) - try: - results = query_to_dicts(dataset_service_statement) - DatasetService = apps.get_model('tethys_services', 'DatasetService') - - # Copy contents of old table to new table - for row in results: - d = DatasetService(name=row['name'], - engine=row['engine'], - endpoint=row['endpoint'], - apikey=row['apikey'], - username=row['username'], - password=row['password']) - d.save() - - except ProgrammingError: - pass - except InternalError: - pass - - # For spatial dataset services - spatial_dataset_service_statement = generic_statement.format(SPATIAL_DATASET_SERVICE_TABLE) - try: - results = query_to_dicts(spatial_dataset_service_statement) - SpatialDatasetService = apps.get_model('tethys_services', 'SpatialDatasetService') - - # Copy contents of old table to new table - for row in results: - d = SpatialDatasetService(name=row['name'], - engine=row['engine'], - endpoint=row['endpoint'], - apikey=row['apikey'], - username=row['username'], - password=row['password']) - d.save() - - except ProgrammingError: - pass - except InternalError: - pass - - # For web processing services - web_processing_service_statement = generic_statement.format(WPS_TABLE) - try: - results = query_to_dicts(web_processing_service_statement) - WebProcessingService = apps.get_model('tethys_services', 'WebProcessingService') - - # Copy contents of old table to new table - for row in results: - w = WebProcessingService(name=row['name'], - endpoint=row['endpoint'], - username=row['username'], - password=row['password']) - w.save() - - except ProgrammingError: - pass - except InternalError: - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0004_webprocessingservice'), - ] - - operations = [ - migrations.RunPython(migrate_to_services), - ] \ No newline at end of file diff --git a/tethys_services/migrations/0006_auto_20150729_1551.py b/tethys_services/migrations/0006_auto_20150729_1551.py deleted file mode 100644 index f38d165cd..000000000 --- a/tethys_services/migrations/0006_auto_20150729_1551.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_services.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0005_auto_20150424_2126'), - ] - - operations = [ - migrations.AlterField( - model_name='datasetservice', - name='endpoint', - field=models.CharField(max_length=1024, validators=[tethys_services.models.validate_dataset_service_endpoint]), - preserve_default=True, - ), - migrations.AlterField( - model_name='spatialdatasetservice', - name='endpoint', - field=models.CharField(max_length=1024, validators=[tethys_services.models.validate_spatial_dataset_service_endpoint]), - preserve_default=True, - ), - migrations.AlterField( - model_name='webprocessingservice', - name='endpoint', - field=models.CharField(max_length=1024, validators=[tethys_services.models.validate_wps_service_endpoint]), - preserve_default=True, - ), - ] diff --git a/tethys_services/migrations/0007_spatialdatasetservice_public_endpoint.py b/tethys_services/migrations/0007_spatialdatasetservice_public_endpoint.py deleted file mode 100644 index 57a315111..000000000 --- a/tethys_services/migrations/0007_spatialdatasetservice_public_endpoint.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_services.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0006_auto_20150729_1551'), - ] - - operations = [ - migrations.AddField( - model_name='spatialdatasetservice', - name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_dataset_service_endpoint]), - preserve_default=True, - ), - ] diff --git a/tethys_services/migrations/0008_auto_20151023_1427.py b/tethys_services/migrations/0008_auto_20151023_1427.py deleted file mode 100644 index 577db20d9..000000000 --- a/tethys_services/migrations/0008_auto_20151023_1427.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import tethys_services.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0007_spatialdatasetservice_public_endpoint'), - ] - - operations = [ - migrations.AddField( - model_name='datasetservice', - name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_dataset_service_endpoint]), - preserve_default=True, - ), - migrations.AddField( - model_name='webprocessingservice', - name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_wps_service_endpoint]), - preserve_default=True, - ), - migrations.AlterField( - model_name='spatialdatasetservice', - name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_spatial_dataset_service_endpoint]), - preserve_default=True, - ), - ] diff --git a/tethys_services/migrations/0009_persistentstoreservice.py b/tethys_services/migrations/0009_persistentstoreservice.py deleted file mode 100644 index 503ba42fd..000000000 --- a/tethys_services/migrations/0009_persistentstoreservice.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.8 on 2017-05-05 03:50 -from __future__ import unicode_literals - -from django.db import migrations, models -import tethys_services.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tethys_services', '0008_auto_20151023_1427'), - ] - - operations = [ - migrations.CreateModel( - name='PersistentStoreService', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=30, unique=True)), - ('host', models.CharField(default=b'localhost', max_length=255)), - ('port', models.IntegerField(default=5435, validators=[tethys_services.models.validate_persistent_store_port])), - ('username', models.CharField(blank=True, max_length=100)), - ('password', models.CharField(blank=True, max_length=100)), - ('engine', models.CharField(choices=[(b'postgresql', b'PostgreSQL')], default=b'postgresql', max_length=50)), - ], - options={ - 'verbose_name': 'Persistent Store Service', - 'verbose_name_plural': 'Persistent Store Services', - }, - ), - ] From 02971c607e294e1d52466b195c84d34244ac83cc Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Sat, 14 Apr 2018 03:10:09 -0500 Subject: [PATCH 171/215] More python 3 compatibility fixes --- tethys_apps/base/app_base.py | 2 ++ tethys_apps/migrations/0001_initial_20.py | 2 -- ...ethysextension.py => 0002_tethysextension.py} | 16 +++++++++++----- tethys_apps/utilities.py | 2 +- tethys_compute/migrations/0001_initial_20.py | 9 --------- tethys_config/context_processors.py | 10 +++++++++- tethys_config/init.py | 8 ++++---- tethys_config/migrations/0001_initial_20.py | 6 ------ tethys_services/migrations/0001_initial_20.py | 11 ----------- 9 files changed, 27 insertions(+), 39 deletions(-) rename tethys_apps/migrations/{0004_tethysextension.py => 0002_tethysextension.py} (51%) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 9ea1ba9e8..239bb9121 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -24,6 +24,8 @@ from .mixins import TethysBaseMixin from ..exceptions import TethysAppSettingDoesNotExist, TethysAppSettingNotAssigned +from past.builtins import basestring + tethys_log = logging.getLogger('tethys.app_base') diff --git a/tethys_apps/migrations/0001_initial_20.py b/tethys_apps/migrations/0001_initial_20.py index 285b77039..f4f258c2e 100644 --- a/tethys_apps/migrations/0001_initial_20.py +++ b/tethys_apps/migrations/0001_initial_20.py @@ -9,8 +9,6 @@ class Migration(migrations.Migration): - # replaces = [('tethys_apps', '0001_initial'), ('tethys_apps', '0002_tethysapp_tags'), ('tethys_apps', '0003_auto_20170505_0350')] - initial = True dependencies = [ diff --git a/tethys_apps/migrations/0004_tethysextension.py b/tethys_apps/migrations/0002_tethysextension.py similarity index 51% rename from tethys_apps/migrations/0004_tethysextension.py rename to tethys_apps/migrations/0002_tethysextension.py index 52b3765de..7f92e3831 100644 --- a/tethys_apps/migrations/0004_tethysextension.py +++ b/tethys_apps/migrations/0002_tethysextension.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2018-02-20 21:10 +# Generated by Django 1.11.10 on 2018-04-14 06:16 from __future__ import unicode_literals from django.db import migrations, models +import tethys_apps.base.mixins class Migration(migrations.Migration): @@ -16,11 +17,16 @@ class Migration(migrations.Migration): name='TethysExtension', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('package', models.CharField(default=b'', max_length=200, unique=True)), - ('name', models.CharField(default=b'', max_length=200)), - ('description', models.TextField(blank=True, default=b'', max_length=1000)), - ('root_url', models.CharField(default=b'', max_length=200)), + ('package', models.CharField(default='', max_length=200, unique=True)), + ('name', models.CharField(default='', max_length=200)), + ('description', models.TextField(blank=True, default='', max_length=1000)), + ('root_url', models.CharField(default='', max_length=200)), ('enabled', models.BooleanField(default=True)), ], + options={ + 'verbose_name': 'Tethys Extension', + 'verbose_name_plural': 'Installed Extensions', + }, + bases=(models.Model, tethys_apps.base.mixins.TethysBaseMixin), ), ] diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index e95fc75f4..046ad04b3 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -30,7 +30,7 @@ def get_directories_in_tethys(directory_names, with_app_name=False): """ # Determine the directories of tethys apps directory tethysapp_dir = safe_join(os.path.abspath(os.path.dirname(__file__)), 'tethysapp') - tethysapp_contents = os.walk(tethysapp_dir).next()[1] + tethysapp_contents = next(os.walk(tethysapp_dir))[1] potential_dirs = [safe_join(tethysapp_dir, item) for item in tethysapp_contents] diff --git a/tethys_compute/migrations/0001_initial_20.py b/tethys_compute/migrations/0001_initial_20.py index a3d3d17d1..8b6005e3a 100644 --- a/tethys_compute/migrations/0001_initial_20.py +++ b/tethys_compute/migrations/0001_initial_20.py @@ -12,15 +12,6 @@ class Migration(migrations.Migration): initial = True - # replaces = [('tethys_compute', '0001_initial'), ('tethys_compute', '0002_initialize_settings'), - # ('tethys_compute', '0003_auto_20150529_1651'), ('tethys_compute', '0004_auto_20150812_1915'), - # ('tethys_compute', '0005_auto_20150914_1712'), ('tethys_compute', '0006_auto_20151221_2207'), - # ('tethys_compute', '0006_auto_20151026_2142'), ('tethys_compute', '0007_merge'), - # ('tethys_compute', '0008_start_condorjob_refactor'), - # ('tethys_compute', '0009_condorjob_data_migration'), - # ('tethys_compute', '0010_finish_condorjob_refactor'), ('tethys_compute', '0011_delete_cluster'), - # ('tethys_compute', '0012_delete_settings')] - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] diff --git a/tethys_config/context_processors.py b/tethys_config/context_processors.py index 8886ee184..b9caf68b9 100644 --- a/tethys_config/context_processors.py +++ b/tethys_config/context_processors.py @@ -20,7 +20,15 @@ def tethys_global_settings_context(request): site_globals = Setting.as_dict() # Get terms and conditions - site_globals.update({'documents': TermsAndConditions.get_active_terms_list()}) + + # Grrr!!! TermsAndConditions has a different interface for Python 2 and 3 + try: + # for Python 3 + site_globals.update({'documents': TermsAndConditions.get_active_terms_list()}) + except AttributeError: + # for Python 3 + site_globals.update({'documents': TermsAndConditions.get_active_list(as_dict=False)}) + context = {'site_globals': site_globals} return context diff --git a/tethys_config/init.py b/tethys_config/init.py index 5ad1ec4d8..45f988f3e 100644 --- a/tethys_config/init.py +++ b/tethys_config/init.py @@ -28,7 +28,7 @@ def initial_settings(apps, schema_editor): date_modified=now) general_category.setting_set.create(name="Favicon", - content="/static/tethys_portal/images/default_favicon.png", + content="/tethys_portal/images/default_favicon.png", date_modified=now) general_category.setting_set.create(name="Brand Text", @@ -113,7 +113,7 @@ def initial_settings(apps, schema_editor): date_modified=now) home_category.setting_set.create(name="Feature 1 Image", - content="/static/tethys_portal/images/placeholder.gif", + content="/tethys_portal/images/placeholder.gif", date_modified=now) home_category.setting_set.create(name="Feature 2 Heading", @@ -126,7 +126,7 @@ def initial_settings(apps, schema_editor): date_modified=now) home_category.setting_set.create(name="Feature 2 Image", - content="/static/tethys_portal/images/placeholder.gif", + content="/tethys_portal/images/placeholder.gif", date_modified=now) home_category.setting_set.create(name="Feature 3 Heading", @@ -140,7 +140,7 @@ def initial_settings(apps, schema_editor): date_modified=now) home_category.setting_set.create(name="Feature 3 Image", - content="/static/tethys_portal/images/placeholder.gif", + content="/tethys_portal/images/placeholder.gif", date_modified=now) home_category.setting_set.create(name="Call to Action", diff --git a/tethys_config/migrations/0001_initial_20.py b/tethys_config/migrations/0001_initial_20.py index 7645da6bd..fb297e88a 100644 --- a/tethys_config/migrations/0001_initial_20.py +++ b/tethys_config/migrations/0001_initial_20.py @@ -9,12 +9,6 @@ class Migration(migrations.Migration): - # replaces = [('tethys_config', '0001_initial'), - # ('tethys_config', '0002_auto_20141029_1848'), - # ('tethys_config', '0003_auto_20141223_2244'), - # ('tethys_config', '0004_auto_20150424_2050'), - # ('tethys_config', '0005_auto_20151023_1720')] - initial = True dependencies = [ diff --git a/tethys_services/migrations/0001_initial_20.py b/tethys_services/migrations/0001_initial_20.py index 4ed1dda6b..e480c1b73 100644 --- a/tethys_services/migrations/0001_initial_20.py +++ b/tethys_services/migrations/0001_initial_20.py @@ -9,17 +9,6 @@ class Migration(migrations.Migration): - # replaces = [('tethys_services', '0001_initial'), - # ('tethys_services', '0002_auto_20150119_1756'), - # ('tethys_services', '0003_spatialdatasetservice'), - # ('tethys_services', '0004_webprocessingservice'), - # ('tethys_services', '0005_auto_20150424_2126'), - # ('tethys_services', '0006_auto_20150729_1551'), - # ('tethys_services', '0007_spatialdatasetservice_public_endpoint'), - # ('tethys_services', '0008_auto_20151023_1427'), - # ('tethys_services', '0009_persistentstoreservice') - # ] - initial = True dependencies = [ From 7cac07d4198f483dbb91b2f8a5b17d8ce5677286 Mon Sep 17 00:00:00 2001 From: nswain Date: Fri, 20 Apr 2018 14:09:02 -0600 Subject: [PATCH 172/215] Change permissions sync method on each app class to only sync permissions for that app, and not globally. --- tethys_apps/base/app_base.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 9ea1ba9e8..b4f1188f7 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -595,10 +595,13 @@ def register_app_permissions(self): ) # Remove any permissions that no longer exist - db_app_permissions = Permission.objects.filter(content_type=tethys_content_type).all() + db_app_permissions = Permission.objects.\ + filter(content_type=tethys_content_type).\ + filter(codename__icontains=perm_codename_prefix).\ + all() for db_app_permission in db_app_permissions: - # Delete the permission if the permission is no longer required by an app + # Delete the permission if the permission is no longer required by this app if db_app_permission.codename not in app_permissions: db_app_permission.delete() @@ -622,7 +625,7 @@ def register_app_permissions(self): p.save() # Remove any groups that no longer exist - db_groups = Group.objects.all() + db_groups = Group.objects.filter(name__icontains=perm_codename_prefix).all() db_apps = TethysApp.objects.all() db_app_names = [db_app.package for db_app in db_apps] @@ -632,7 +635,7 @@ def register_app_permissions(self): # Only perform maintenance on groups that belong to Tethys Apps if (len(db_group_name_parts) > 1) and (db_group_name_parts[0] in db_app_names): - # Delete groups that is no longer required by an app + # Delete groups that is no longer required by this app if db_group.name not in app_groups: db_group.delete() From dce9f1d0417238c938fc68a1cfa77cd7b6ff84a8 Mon Sep 17 00:00:00 2001 From: sdc50 Date: Fri, 20 Apr 2018 15:09:41 -0500 Subject: [PATCH 173/215] Update README.rst --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 39c5ee292..be296e4f9 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,7 @@ Tethys Platform =============== +.. image:: https://travis-ci.org/tethysplatform/tethys.svg?branch=release + :target: https://travis-ci.org/tethysplatform/tethys Tethys Platform provides both a development environment and a hosting environment for water resources web apps. From ee02f582ab46470172e7ab3e8dfb62852b0a0aea Mon Sep 17 00:00:00 2001 From: sdc50 Date: Fri, 20 Apr 2018 15:11:24 -0500 Subject: [PATCH 174/215] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index be296e4f9..6fb0960fe 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,6 @@ Tethys Platform =============== -.. image:: https://travis-ci.org/tethysplatform/tethys.svg?branch=release +.. image:: https://travis-ci.org/tethysplatform/tethys.svg?branch=master :target: https://travis-ci.org/tethysplatform/tethys Tethys Platform provides both a development environment and a hosting environment for water resources web apps. From 5a79e9e2e6346baa31d3c1a59bd81cc088c9350c Mon Sep 17 00:00:00 2001 From: nswain Date: Mon, 14 May 2018 12:45:51 -0600 Subject: [PATCH 175/215] Rename select2 class for tethys gizmos to not conflict with select2 naming. --- tethys_gizmos/static/tethys_gizmos/js/select_input.js | 2 +- tethys_gizmos/templates/tethys_gizmos/gizmos/select_input.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tethys_gizmos/static/tethys_gizmos/js/select_input.js b/tethys_gizmos/static/tethys_gizmos/js/select_input.js index eedb41c52..93c84d900 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/select_input.js +++ b/tethys_gizmos/static/tethys_gizmos/js/select_input.js @@ -46,7 +46,7 @@ var TETHYS_SELECT_INPUT = (function() { // the DOM tree finishes loading $(function() { // Initialize any select2 elements - initSelectInput($('.select2')); + initSelectInput($('.tethys-select2')); }); return public_interface; diff --git a/tethys_gizmos/templates/tethys_gizmos/gizmos/select_input.html b/tethys_gizmos/templates/tethys_gizmos/gizmos/select_input.html index 111825aaa..ccccbe8fe 100644 --- a/tethys_gizmos/templates/tethys_gizmos/gizmos/select_input.html +++ b/tethys_gizmos/templates/tethys_gizmos/gizmos/select_input.html @@ -3,7 +3,7 @@ {% endif %}
{% if display_text %}{% endif %} - + {% for option, value in options %} + {% if option in initial or value in initial %} + + {% else %} + + {% endif %} + {% endfor %} + + {% if error %} +

{{ error }}

+ {% endif %} +
diff --git a/tests/extensions/tethysext-test_extension/tethysext/test_extension/templates/test_extension/.gitkeep b/tests/extensions/tethysext-test_extension/tethysext/test_extension/templates/test_extension/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/extensions/tethysext-test_extension/tethysext/test_extension/templates/test_extension/home.html b/tests/extensions/tethysext-test_extension/tethysext/test_extension/templates/test_extension/home.html new file mode 100644 index 000000000..d4216dca9 --- /dev/null +++ b/tests/extensions/tethysext-test_extension/tethysext/test_extension/templates/test_extension/home.html @@ -0,0 +1,5 @@ +

Hello, World!

+
    +
  • {{ var1 }}
  • +
  • {{ var2 }}
  • +
\ No newline at end of file diff --git a/tests/extensions/tethysext-test_extension/tethysext/test_extension/tests/__init__.py b/tests/extensions/tethysext-test_extension/tethysext/test_extension/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/extensions/tethysext-test_extension/tethysext/test_extension/tests/tests.py b/tests/extensions/tethysext-test_extension/tethysext/test_extension/tests/tests.py new file mode 100644 index 000000000..be2b5e263 --- /dev/null +++ b/tests/extensions/tethysext-test_extension/tethysext/test_extension/tests/tests.py @@ -0,0 +1,155 @@ +# Most of your test classes should inherit from TethysTestCase +from tethys_sdk.testing import TethysTestCase + +# Use if you'd like a simplified way to test rendered HTML templates. +# You likely need to install BeautifulSoup, as it is not included by default in Tethys Platform +# 1. Open a terminal +# 2. Enter command ". /usr/lib/tethys/bin/activate" to activate the Tethys python environment +# 3. Enter command "pip install beautifulsoup4" +# For help, see https://www.crummy.com/software/BeautifulSoup/bs4/doc/ +# from bs4 import BeautifulSoup + +""" +To run any tests: + 1. Open a terminal + 2. Enter command "t" to activate the Tethys python environment + 3. In settings.py make sure that the tethys_default database user is set to tethys_super or is a super user of the database + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'tethys_default', + 'USER': 'tethys_super', + 'PASSWORD': 'pass', + 'HOST': '127.0.0.1', + 'PORT': '5435' + } + } + 4. Enter tethys test command. + The general form is: "tethys test -f tethysext....." + See below for specific examples + + To run all tests across this extension: + Test command: "tethys test -f tethysext.test_extension" + + To run all tests in this file: + Test command: "tethys test -f tethysext.test_extension.tests.tests" + + To run tests in the TestExtensionTestCase class: + Test command: "tethys test -f tethysext.test_extension.tests.tests.TestExtensionTestCase" + + To run only the test_if_tethys_platform_is_great function in the TestExtensionTestCase class: + Test command: "tethys test -f tethysext.test_extension.tests.tests.TestExtensionTestCase.test_if_tethys_platform_is_great" + +To learn more about writing tests, see: + https://docs.djangoproject.com/en/1.9/topics/testing/overview/#writing-tests + https://docs.python.org/2.7/library/unittest.html#module-unittest +""" # noqa: E501 + + +class TestExtensionTestCase(TethysTestCase): + """ + In this class you may define as many functions as you'd like to test different aspects of your extension. + Each function must start with the word "test" for it to be recognized and executed during testing. + You could also create multiple TethysTestCase classes within this or other python files to organize your tests. + """ + + def set_up(self): + """ + This function is not required, but can be used if any environmental setup needs to take place before + execution of each test function. Thus, if you have multiple test that require the same setup to run, + place that code here. + + If you are testing against a controller that check for certain user info, you can create a fake test user and + get a test client, like so: + + #The test client simulates a browser that can navigate your extension's url endpoints + self.c = self.get_test_client() + self.user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") + # To create a super_user, use "self.create_test_superuser(*params)" with the same params + + # To force a login for the test user + self.c.force_login(self.user) + + # If for some reason you do not want to force a login, you can use the following: + login_success = self.c.login(username="joe", password="secret") + + NOTE: You do not have place these functions here, but if they are not placed here and are needed + then they must be placed at the beginning of your individual test functions. Also, if a certain + setup does not apply to all of your functions, you should either place it directly in each + function it applies to, or maybe consider creating a new test file or test class to group similar + tests. + """ + pass + + def tear_down(self): + """ + This function is not required, but should be used if you need to tear down any environmental setup + that took place before execution of the test functions. + + NOTE: You do not have to set these functions up here, but if they are not placed here and are needed + then they must be placed at the very end of your individual test functions. Also, if certain + tearDown code does not apply to all of your functions, you should either place it directly in each + function it applies to, or maybe consider creating a new test file or test class to group similar + tests. + """ + pass + + def is_tethys_platform_great(self): + return True + + def test_if_tethys_platform_is_great(self): + """ + This is an example test function that can be modified to test a specific aspect of your extension. + It is required that the function name begins with the word "test" or it will not be executed. + Generally, the code written here will consist of many assert methods. + A list of assert methods is included here for reference or to get you started: + assertEqual(a, b) a == b + assertNotEqual(a, b) a != b + assertTrue(x) bool(x) is True + assertFalse(x) bool(x) is False + assertIs(a, b) a is b + assertIsNot(a, b) a is not b + assertIsNone(x) x is None + assertIsNotNone(x) x is not None + assertIn(a, b) a in b + assertNotIn(a, b) a not in b + assertIsInstance(a, b) isinstance(a, b) + assertNotIsInstance(a, b) !isinstance(a, b) + Learn more about assert methods here: + https://docs.python.org/2.7/library/unittest.html#assert-methods + """ + + self.assertEqual(self.is_tethys_platform_great(), True) + self.assertNotEqual(self.is_tethys_platform_great(), False) + self.assertTrue(self.is_tethys_platform_great()) + self.assertFalse(not self.is_tethys_platform_great()) + self.assertIs(self.is_tethys_platform_great(), True) + self.assertIsNot(self.is_tethys_platform_great(), False) + + def test_a_controller(self): + """ + This is an example test function of how you might test a controller that returns an HTML template rendered + with context variables. + """ + + # If all test functions were testing controllers or required a test client for another reason, the following + # 3 lines of code could be placed once in the set_up function. Note that in that case, each variable should be + # prepended with "self." (i.e. self.c = ...) to make those variables "global" to this test class and able to be + # used in each separate test function. + c = self.get_test_client() + user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") + c.force_login(user) + + # Have the test client "browse" to your page + response = c.get('/extensions/test-extension/foo/') # The final '/' is essential for all pages/controllers + + # Test that the request processed correctly (with a 200 status code) + self.assertEqual(response.status_code, 200) + + ''' + NOTE: Next, you would likely test that your context variables returned as expected. That would look + something like the following: + ''' + + context = response.context + self.assertEqual(context['my_integer'], 10) diff --git a/tests/factories/__init__.py b/tests/factories/__init__.py new file mode 100644 index 000000000..504f3f581 --- /dev/null +++ b/tests/factories/__init__.py @@ -0,0 +1,8 @@ +""" +******************************************************************************** +* Name: __init__.py +* Author: nswain +* Created On: May 15, 2018 +* Copyright: (c) Aquaveo 2018 +******************************************************************************** +""" diff --git a/tests/factories/django_user.py b/tests/factories/django_user.py new file mode 100644 index 000000000..862223e14 --- /dev/null +++ b/tests/factories/django_user.py @@ -0,0 +1,40 @@ +""" +******************************************************************************** +* Name: django_user +* Author: nswain +* Created On: May 15, 2018 +* Copyright: (c) Aquaveo 2018 +******************************************************************************** +""" +import datetime +from hashlib import md5 +import factory +from django.contrib.auth.models import User + + +class UserFactory(factory.Factory): + """ + Creates a new ``User`` object. + Username will be a random 30 character md5 value. + Email will be ``userN@example.com`` with ``N`` being a counter. + Password will be ``test123`` by default. + """ + class Meta: + model = User + abstract = False + + username = factory.LazyAttribute( + lambda x: md5(datetime.datetime.now().strftime('%Y%,%d%H%M%S').encode('utf-8')).hexdigest()[0:30] + ) + email = factory.Sequence(lambda n: 'user{0}@example.com'.format(n)) + + @classmethod + def _prepare(cls, create, **kwargs): + password = 'test123' + if 'password' in kwargs: + password = kwargs.pop('password') + user = super(UserFactory, cls).prepare(create, **kwargs) + user.set_password(password) + if create: + user.save() + return user diff --git a/tests/gui_tests/test_tethys_portal/test_authentication.py b/tests/gui_tests/test_tethys_portal/test_authentication.py index 0dfe60720..fd593966d 100644 --- a/tests/gui_tests/test_tethys_portal/test_authentication.py +++ b/tests/gui_tests/test_tethys_portal/test_authentication.py @@ -2,6 +2,7 @@ from selenium.webdriver.firefox.webdriver import WebDriver from django.contrib.auth.models import User + class AuthenticationTests(StaticLiveServerTestCase): @classmethod @@ -25,4 +26,4 @@ def test_login(self): username_input.send_keys(self.user.username) password_input = self.selenium.find_element_by_name("password") password_input.send_keys(self.user_pass) - self.selenium.find_element_by_name('login-submit').click() \ No newline at end of file + self.selenium.find_element_by_name('login-submit').click() diff --git a/tests/intermediate_tests/__init__.py b/tests/intermediate_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/intermediate_tests/test_tethys_services/__init__.py b/tests/intermediate_tests/test_tethys_services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/intermediate_tests/test_tethys_services/test_backends/__init__.py b/tests/intermediate_tests/test_tethys_services/test_backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_services/test_hydroshare_backend.py b/tests/intermediate_tests/test_tethys_services/test_backends/test_hydroshare.py similarity index 94% rename from tests/unit_tests/test_tethys_services/test_hydroshare_backend.py rename to tests/intermediate_tests/test_tethys_services/test_backends/test_hydroshare.py index a41a18a3b..5e38d25c7 100644 --- a/tests/unit_tests/test_tethys_services/test_hydroshare_backend.py +++ b/tests/intermediate_tests/test_tethys_services/test_backends/test_hydroshare.py @@ -42,12 +42,12 @@ def setUp(self): self.access_token = str(uuid.uuid4()) self.refresh_token = str(uuid.uuid4()) - self.expires_in = random.randint(1, 30*60*60) # 1 sec to 30 days + self.expires_in = random.randint(1, 30 * 60 * 60) # 1 sec to 30 days self.token_type = "bearer" self.scope = "read write" - self.social_username="drew" - self.social_email="drew@byu.edu" + self.social_username = "drew" + self.social_email = "drew@byu.edu" def tearDown(self): pass @@ -58,7 +58,7 @@ def test_oauth_create_new_user(self, m): # expect for only 1 user: anonymous user self.assertEqual(User.objects.all().count(), 1) - username_new, social, backend=self.run_oauth(m) + username_new, social, backend = self.run_oauth(m) # expect for 2 users: anonymous and newly created social user self.assertEqual(User.objects.all().count(), 2) @@ -113,9 +113,11 @@ def test_oauth_connection_to_user(self, m): # expect for only 1 user: anonymous user self.assertEqual(User.objects.all().count(), 1) # manually create a new user named self.social_username - user_sherry = User.objects.create_user(username="sherry", - email="sherry@byu.edu", - password='top_secret') + user_sherry = User.objects.create_user( + username="sherry", + email="sherry@byu.edu", + password='top_secret' + ) logger.debug(user_sherry.is_authenticated()) logger.debug(user_sherry.is_active) diff --git a/tests/unit_tests/test_tethys_apps/test_admin.py b/tests/unit_tests/test_tethys_apps/test_admin.py new file mode 100644 index 000000000..094d19418 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_admin.py @@ -0,0 +1,166 @@ +import unittest +import mock + +from tethys_apps.admin import TethysAppSettingInline, CustomSettingInline, DatasetServiceSettingInline, \ + SpatialDatasetServiceSettingInline, WebProcessingServiceSettingInline, PersistentStoreConnectionSettingInline, \ + PersistentStoreDatabaseSettingInline, TethysAppAdmin, TethysExtensionAdmin + +from tethys_apps.models import (TethysApp, + TethysExtension, + CustomSetting, + DatasetServiceSetting, + SpatialDatasetServiceSetting, + WebProcessingServiceSetting, + PersistentStoreConnectionSetting, + PersistentStoreDatabaseSetting) + + +class TestTethysAppAdmin(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_TethysAppSettingInline(self): + expected_template = 'tethys_portal/admin/edit_inline/tabular.html' + TethysAppSettingInline.model = mock.MagicMock() + ret = TethysAppSettingInline(mock.MagicMock(), mock.MagicMock()) + self.assertEquals(expected_template, ret.template) + + def test_has_delete_permission(self): + TethysAppSettingInline.model = mock.MagicMock() + ret = TethysAppSettingInline(mock.MagicMock(), mock.MagicMock()) + self.assertFalse(ret.has_delete_permission(mock.MagicMock())) + + def test_has_add_permission(self): + TethysAppSettingInline.model = mock.MagicMock() + ret = TethysAppSettingInline(mock.MagicMock(), mock.MagicMock()) + self.assertFalse(ret.has_add_permission(mock.MagicMock())) + + def test_CustomSettingInline(self): + expected_readonly_fields = ('name', 'description', 'type', 'required') + expected_fields = ('name', 'description', 'type', 'value', 'required') + expected_model = CustomSetting + + ret = CustomSettingInline(mock.MagicMock(), mock.MagicMock()) + + self.assertEquals(expected_readonly_fields, ret.readonly_fields) + self.assertEquals(expected_fields, ret.fields) + self.assertEquals(expected_model, ret.model) + + def test_DatasetServiceSettingInline(self): + expected_readonly_fields = ('name', 'description', 'required', 'engine') + expected_fields = ('name', 'description', 'dataset_service', 'engine', 'required') + expected_model = DatasetServiceSetting + + ret = DatasetServiceSettingInline(mock.MagicMock(), mock.MagicMock()) + + self.assertEquals(expected_readonly_fields, ret.readonly_fields) + self.assertEquals(expected_fields, ret.fields) + self.assertEquals(expected_model, ret.model) + + def test_SpatialDatasetServiceSettingInline(self): + expected_readonly_fields = ('name', 'description', 'required', 'engine') + expected_fields = ('name', 'description', 'spatial_dataset_service', 'engine', 'required') + expected_model = SpatialDatasetServiceSetting + + ret = SpatialDatasetServiceSettingInline(mock.MagicMock(), mock.MagicMock()) + + self.assertEquals(expected_readonly_fields, ret.readonly_fields) + self.assertEquals(expected_fields, ret.fields) + self.assertEquals(expected_model, ret.model) + + def test_WebProcessingServiceSettingInline(self): + expected_readonly_fields = ('name', 'description', 'required') + expected_fields = ('name', 'description', 'web_processing_service', 'required') + expected_model = WebProcessingServiceSetting + + ret = WebProcessingServiceSettingInline(mock.MagicMock(), mock.MagicMock()) + + self.assertEquals(expected_readonly_fields, ret.readonly_fields) + self.assertEquals(expected_fields, ret.fields) + self.assertEquals(expected_model, ret.model) + + def test_PersistentStoreConnectionSettingInline(self): + expected_readonly_fields = ('name', 'description', 'required') + expected_fields = ('name', 'description', 'persistent_store_service', 'required') + expected_model = PersistentStoreConnectionSetting + + ret = PersistentStoreConnectionSettingInline(mock.MagicMock(), mock.MagicMock()) + + self.assertEquals(expected_readonly_fields, ret.readonly_fields) + self.assertEquals(expected_fields, ret.fields) + self.assertEquals(expected_model, ret.model) + + def test_PersistentStoreDatabaseSettingInline(self): + expected_readonly_fields = ('name', 'description', 'required', 'spatial', 'initialized') + expected_fields = ('name', 'description', 'spatial', 'initialized', 'persistent_store_service', 'required') + expected_model = PersistentStoreDatabaseSetting + + ret = PersistentStoreDatabaseSettingInline(mock.MagicMock(), mock.MagicMock()) + + self.assertEquals(expected_readonly_fields, ret.readonly_fields) + self.assertEquals(expected_fields, ret.fields) + self.assertEquals(expected_model, ret.model) + + # Need to check + def test_PersistentStoreDatabaseSettingInline_get_queryset(self): + obj = PersistentStoreDatabaseSettingInline(mock.MagicMock(), mock.MagicMock()) + mock_request = mock.MagicMock() + obj.get_queryset(mock_request) + + def test_TethysAppAdmin(self): + expected_readonly_fields = ('package',) + expected_fields = ('package', 'name', 'description', 'tags', 'enabled', 'show_in_apps_library', + 'enable_feedback') + expected_inlines = [CustomSettingInline, + PersistentStoreConnectionSettingInline, + PersistentStoreDatabaseSettingInline, + DatasetServiceSettingInline, + SpatialDatasetServiceSettingInline, + WebProcessingServiceSettingInline] + + ret = TethysAppAdmin(mock.MagicMock(), mock.MagicMock()) + + self.assertEquals(expected_readonly_fields, ret.readonly_fields) + self.assertEquals(expected_fields, ret.fields) + self.assertEquals(expected_inlines, ret.inlines) + + def test_TethysAppAdmin_has_delete_permission(self): + ret = TethysAppAdmin(mock.MagicMock(), mock.MagicMock()) + self.assertFalse(ret.has_delete_permission(mock.MagicMock())) + + def test_TethysAppAdmin_has_add_permission(self): + ret = TethysAppAdmin(mock.MagicMock(), mock.MagicMock()) + self.assertFalse(ret.has_add_permission(mock.MagicMock())) + + def test_TethysExtensionAdmin(self): + expected_readonly_fields = ('package', 'name', 'description') + expected_fields = ('package', 'name', 'description', 'enabled') + + ret = TethysExtensionAdmin(mock.MagicMock(), mock.MagicMock()) + + self.assertEquals(expected_readonly_fields, ret.readonly_fields) + self.assertEquals(expected_fields, ret.fields) + + def test_TethysExtensionAdmin_has_delete_permission(self): + ret = TethysExtensionAdmin(mock.MagicMock(), mock.MagicMock()) + self.assertFalse(ret.has_delete_permission(mock.MagicMock())) + + def test_TethysExtensionAdmin_has_add_permission(self): + ret = TethysExtensionAdmin(mock.MagicMock(), mock.MagicMock()) + self.assertFalse(ret.has_add_permission(mock.MagicMock())) + + def test_admin_site_register_tethys_app_admin(self): + from django.contrib import admin + registry = admin.site._registry + self.assertIn(TethysApp, registry) + self.assertIsInstance(registry[TethysApp], TethysAppAdmin) + + def test_admin_site_register_tethys_app_extension(self): + from django.contrib import admin + registry = admin.site._registry + self.assertIn(TethysExtension, registry) + self.assertIsInstance(registry[TethysExtension], TethysExtensionAdmin) diff --git a/tests/unit_tests/test_tethys_apps/test_app_installation.py b/tests/unit_tests/test_tethys_apps/test_app_installation.py new file mode 100644 index 000000000..75467cfd2 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_app_installation.py @@ -0,0 +1,290 @@ +import unittest +import mock +import os +import sys +import tethys_apps.app_installation as tethys_app_installation + +if sys.version_info[0] < 3: + callable_mock_path = '__builtin__.callable' +else: + callable_mock_path = 'builtins.callable' + + +class TestAppInstallation(unittest.TestCase): + def setUp(self): + self.src_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + self.root = os.path.join(self.src_dir, 'tethys_apps', 'tethysapp', 'test_app', 'public') + + def tearDown(self): + pass + + def test_find_resource_files(self): + ret = tethys_app_installation.find_resource_files(self.root) + main_js = False + icon_gif = False + main_css = False + if any('/js/main.js' in s for s in ret): + main_js = True + if any('/images/icon.gif' in s for s in ret): + icon_gif = True + if any('/css/main.css' in s for s in ret): + main_css = True + + self.assertTrue(main_js) + self.assertTrue(icon_gif) + self.assertTrue(main_css) + + def test_get_tethysapp_directory(self): + ret = tethys_app_installation.get_tethysapp_directory() + self.assertIn('tethys_apps/tethysapp', ret) + + @mock.patch('tethys_apps.app_installation.install') + @mock.patch('tethys_apps.app_installation.subprocess') + @mock.patch('tethys_apps.app_installation.get_tethysapp_directory') + @mock.patch('tethys_apps.app_installation.shutil') + @mock.patch('tethys_apps.app_installation.pretty_output') + def test__run_install(self, mock_pretty_output, mock_shutil, mock_getdir, mock_subprocess, mock_install): + # mock the self input + mock_self = mock.MagicMock(app_package='tethys_apps', app_package_dir='/test_app/', dependencies='foo') + + # call the method for testing + tethys_app_installation._run_install(self=mock_self) + + # check the method call + mock_getdir.assert_called() + + # check the user notification + mock_pretty_output.assert_called() + + # check the input arguments for shutil.copytree method + shutil_call_args = mock_shutil.copytree.call_args_list + self.assertEquals('/test_app/', shutil_call_args[0][0][0]) + + # check the input arguments for subprocess.call method + process_call_args = mock_subprocess.call.call_args_list + self.assertEquals('pip', process_call_args[0][0][0][0]) + self.assertEquals('install', process_call_args[0][0][0][1]) + self.assertEquals('f', process_call_args[0][0][0][2]) + + # check the install call + mock_install.run.assert_called_with(mock_self) + + @mock.patch('tethys_apps.app_installation.develop') + @mock.patch('tethys_apps.app_installation.subprocess') + @mock.patch('tethys_apps.app_installation.os') + @mock.patch(callable_mock_path) + @mock.patch('tethys_apps.app_installation.pretty_output') + @mock.patch('tethys_apps.app_installation.get_tethysapp_directory') + def test__run_develop(self, mock_getdir, mock_pretty_output, mock_callable, mock_os, mock_subprocess, + mock_develop): + + # mock the self input + mock_self = mock.MagicMock(app_package='tethys_apps', app_package_dir='/test_app/', dependencies='foo') + + # call the method for testing + tethys_app_installation._run_develop(self=mock_self) + + # check the method call + mock_getdir.assert_called() + + # check the user notification + mock_pretty_output.assert_called() + + # mock callable method + mock_callable.return_value = True + + # check the input arguments for os.symlink method + symlink_call_args = mock_os.symlink.call_args_list + self.assertEquals('/test_app/', symlink_call_args[0][0][0]) + + # check the input arguments for subprocess.call method + process_call_args = mock_subprocess.call.call_args_list + self.assertEquals('pip', process_call_args[0][0][0][0]) + self.assertEquals('install', process_call_args[0][0][0][1]) + self.assertEquals('f', process_call_args[0][0][0][2]) + + # check the develop call + mock_develop.run.assert_called_with(mock_self) + + def test_custom_install_command(self): + app_package = 'tethys_apps' + app_package_dir = '/test_app/' + dependencies = 'foo' + + ret = tethys_app_installation.custom_install_command(app_package, app_package_dir, dependencies) + + self.assertEquals('tethys_apps', ret.app_package) + self.assertEquals('/test_app/', ret.app_package_dir) + self.assertEquals('foo', ret.dependencies) + self.assertEquals('tethys_apps.app_installation', ret.__module__) + + def test_custom_develop_command(self): + app_package = 'tethys_apps1' + app_package_dir = '/test_app/' + dependencies = 'foo' + + ret = tethys_app_installation.custom_develop_command(app_package, app_package_dir, dependencies) + + self.assertEquals('tethys_apps1', ret.app_package) + self.assertEquals('/test_app/', ret.app_package_dir) + self.assertEquals('foo', ret.dependencies) + self.assertEquals('tethys_apps.app_installation', ret.__module__) + + @mock.patch('tethys_apps.app_installation.shutil.copytree') + @mock.patch('tethys_apps.app_installation.install') + @mock.patch('tethys_apps.app_installation.subprocess') + @mock.patch('tethys_apps.app_installation.get_tethysapp_directory') + @mock.patch('tethys_apps.app_installation.shutil') + @mock.patch('tethys_apps.app_installation.pretty_output') + def test__run_install_exception(self, mock_pretty_output, mock_shutil, mock_getdir, mock_subprocess, mock_install, + mock_copy_tree): + # mock the self input + mock_self = mock.MagicMock(app_package='tethys_apps', app_package_dir='/test_app/', dependencies='foo') + + mock_copy_tree.side_effect = Exception, True + + # call the method for testing + tethys_app_installation._run_install(self=mock_self) + + # check the method call + mock_getdir.assert_called() + + # check the user notification + mock_pretty_output.assert_called() + + # check the input arguments for shutil.copytree method + shutil_call_args = mock_shutil.copytree.call_args_list + self.assertEquals('/test_app/', shutil_call_args[0][0][0]) + + mock_shutil.rmtree.assert_called() + + mock_shutil.copytree.assert_called() + + # check the input arguments for subprocess.call method + process_call_args = mock_subprocess.call.call_args_list + self.assertEquals('pip', process_call_args[0][0][0][0]) + self.assertEquals('install', process_call_args[0][0][0][1]) + self.assertEquals('f', process_call_args[0][0][0][2]) + + # check the install call + mock_install.run.assert_called_with(mock_self) + + @mock.patch('tethys_apps.app_installation.os.remove') + @mock.patch('tethys_apps.app_installation.shutil.rmtree') + @mock.patch('tethys_apps.app_installation.shutil.copytree') + @mock.patch('tethys_apps.app_installation.install') + @mock.patch('tethys_apps.app_installation.subprocess') + @mock.patch('tethys_apps.app_installation.get_tethysapp_directory') + @mock.patch('tethys_apps.app_installation.shutil') + @mock.patch('tethys_apps.app_installation.pretty_output') + def test__run_install_exception_rm_tree_exception(self, mock_pretty_output, mock_shutil, mock_getdir, + mock_subprocess, mock_install, mock_copy_tree, mock_remove_tree, + mock_os_remove_tree): + # mock the self input + mock_self = mock.MagicMock(app_package='tethys_apps', app_package_dir='/test_app/', dependencies='foo') + + mock_copy_tree.side_effect = Exception, True + + mock_remove_tree.side_effect = Exception + + # call the method for testing + tethys_app_installation._run_install(self=mock_self) + + # check the method call + mock_getdir.assert_called() + + # check the user notification + mock_pretty_output.assert_called() + + # check the input arguments for shutil.copytree method + shutil_call_args = mock_shutil.copytree.call_args_list + self.assertEquals('/test_app/', shutil_call_args[0][0][0]) + + mock_os_remove_tree.assert_called() + + mock_shutil.copytree.assert_called() + + # check the input arguments for subprocess.call method + process_call_args = mock_subprocess.call.call_args_list + self.assertEquals('pip', process_call_args[0][0][0][0]) + self.assertEquals('install', process_call_args[0][0][0][1]) + self.assertEquals('f', process_call_args[0][0][0][2]) + + # check the install call + mock_install.run.assert_called_with(mock_self) + + @mock.patch('tethys_apps.app_installation.ctypes') + @mock.patch('tethys_apps.app_installation.os.path.isdir') + @mock.patch(callable_mock_path) + @mock.patch('tethys_apps.app_installation.pretty_output') + @mock.patch('tethys_apps.app_installation.get_tethysapp_directory') + def test_run_develop_windows(self, mock_getdir, mock_pretty_output, mock_callable, mock_os_path_isdir, mock_ctypes): + # mock the self input + mock_self = mock.MagicMock(app_package='tethys_apps', app_package_dir='/test_app/', dependencies='foo') + + # mock callable method + mock_callable.return_value = False + + mock_csl = mock.MagicMock(argtypes=mock.MagicMock(), restype=mock.MagicMock()) + + mock_ctypes.windll.kernel32.CreateSymbolicLinkW = mock_csl + + mock_os_path_isdir.return_value = True + + mock_csl.return_value = 0 + mock_ctypes.WinError = Exception + + # call the method for testing + self.assertRaises(Exception, tethys_app_installation._run_develop, mock_self) + + # check the method call + mock_getdir.assert_called() + + # check the user notification + mock_pretty_output.assert_called() + + @mock.patch('tethys_apps.app_installation.shutil.rmtree') + @mock.patch('tethys_apps.app_installation.getattr') + @mock.patch('tethys_apps.app_installation.develop') + @mock.patch('tethys_apps.app_installation.subprocess') + @mock.patch('tethys_apps.app_installation.os') + @mock.patch('tethys_apps.app_installation.pretty_output') + @mock.patch('tethys_apps.app_installation.get_tethysapp_directory') + def test__run_develop_exception(self, mock_getdir, mock_pretty_output, mock_os, mock_subprocess, + mock_develop, mock_getattr, mock_rm_tree): + + mock_destination = mock.MagicMock() + mock_os.path.join.return_value = mock_destination + + # mock the self input + mock_self = mock.MagicMock(app_package='tethys_apps', app_package_dir='/test_app/', dependencies='foo') + + mock_getattr.side_effect = Exception + + mock_rm_tree.side_effect = Exception + + # call the method for testing + tethys_app_installation._run_develop(self=mock_self) + + # check the method call + mock_getdir.assert_called() + + # check the user notification + mock_pretty_output.assert_called() + + mock_rm_tree.assert_called_with(mock_destination) + + mock_os.remove.assert_called_with(mock_destination) + + # check the input arguments for os.symlink method + symlink_call_args = mock_os.symlink.call_args_list + self.assertEquals('/test_app/', symlink_call_args[0][0][0]) + + # check the input arguments for subprocess.call method + process_call_args = mock_subprocess.call.call_args_list + self.assertEquals('pip', process_call_args[0][0][0][0]) + self.assertEquals('install', process_call_args[0][0][0][1]) + self.assertEquals('f', process_call_args[0][0][0][2]) + + # check the develop call + mock_develop.run.assert_called_with(mock_self) diff --git a/tests/unit_tests/test_tethys_apps/test_apps.py b/tests/unit_tests/test_tethys_apps/test_apps.py new file mode 100644 index 000000000..8b94da330 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_apps.py @@ -0,0 +1,22 @@ +import unittest +import mock +import tethys_apps +from tethys_apps.apps import TethysAppsConfig + + +class TestApps(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_TethysAppsConfig(self): + self.assertEqual('tethys_apps', TethysAppsConfig.name) + self.assertEqual('Tethys Apps', TethysAppsConfig.verbose_name) + + @mock.patch('tethys_apps.apps.SingletonHarvester') + def test_ready(self, mock_singleton_harvester): + tethys_app_config_obj = TethysAppsConfig('tethys_apps', tethys_apps) + tethys_app_config_obj.ready() + mock_singleton_harvester().harvest.assert_called() diff --git a/tests/unit_tests/test_tethys_apps/test_base/__init__.py b/tests/unit_tests/test_tethys_apps/test_base/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py new file mode 100644 index 000000000..6a51abb3a --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py @@ -0,0 +1,903 @@ +import unittest +import tethys_apps.base.app_base as tethys_app_base +import mock + +from django.test import RequestFactory +from tests.factories.django_user import UserFactory +from django.core.exceptions import ObjectDoesNotExist +from tethys_apps.exceptions import TethysAppSettingDoesNotExist, TethysAppSettingNotAssigned +from types import FunctionType +from tethys_apps.base.permissions import Permission, PermissionGroup + + +class TethysAppChild(tethys_app_base.TethysAppBase): + """ + Tethys app class for Test App. + """ + + name = 'Test App' + index = 'test_app:home' + icon = 'test_app/images/icon.gif' + package = 'test_app' + root_url = 'test-app' + color = '#2c3e50' + description = 'Place a brief description of your app here.' + + +class TestTethysBase(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_url_maps(self): + result = tethys_app_base.TethysBase().url_maps() + self.assertEqual([], result) + + @mock.patch('tethys_apps.base.app_base.url') + @mock.patch('tethys_apps.base.app_base.TethysBaseMixin') + def test_url_patterns(self, mock_tbm, mock_url): + app = tethys_app_base.TethysBase() + app._namespace = 'foo' + url_map = mock.MagicMock(controller='test_app.controllers.home', url='test-url') + url_map.name = 'home' + app.url_maps = mock.MagicMock(return_value=[url_map]) + mock_tbm.return_value = mock.MagicMock(url_maps='test-app') + + # Execute + result = app.url_patterns + # Check url call at django_url = url... + rts_call_args = mock_url.call_args_list + self.assertEqual('test-url', rts_call_args[0][0][0]) + self.assertIn('name', rts_call_args[0][1]) + self.assertEqual('home', rts_call_args[0][1]['name']) + self.assertIn('foo', result) + self.assertIsInstance(rts_call_args[0][0][1], FunctionType) + + @mock.patch('tethys_apps.base.app_base.url') + @mock.patch('tethys_apps.base.app_base.TethysBaseMixin') + def test_url_patterns_no_basestring(self, mock_tbm, mock_url): + app = tethys_app_base.TethysBase() + # controller_mock = mock.MagicMock() + + def test_func(): + return '' + + url_map = mock.MagicMock(controller=test_func, url='test-app') + url_map.name = 'home' + app.url_maps = mock.MagicMock(return_value=[url_map]) + mock_tbm.return_value = mock.MagicMock(url_maps='test-app') + + # Execute + app.url_patterns + + # Check url call at django_url = url... + rts_call_args = mock_url.call_args_list + self.assertEqual('test-app', rts_call_args[0][0][0]) + self.assertIn('name', rts_call_args[0][1]) + self.assertEqual('home', rts_call_args[0][1]['name']) + self.assertIs(rts_call_args[0][0][1], test_func) + + @mock.patch('tethys_apps.base.app_base.tethys_log') + @mock.patch('tethys_apps.base.app_base.TethysBaseMixin') + def test_url_patterns_import_error(self, mock_tbm, mock_log): + mock_error = mock_log.error + app = tethys_app_base.TethysBase() + url_map = mock.MagicMock(controller='1module.1function', url='test-app') + url_map.name = 'home' + app.url_maps = mock.MagicMock(return_value=[url_map]) + mock_tbm.return_value = mock.MagicMock(url_maps='test-app') + + # assertRaises needs a callable, not a property + def test_url_patterns(): + return app.url_patterns + + # Check Error Message + self.assertRaises(ImportError, test_url_patterns) + rts_call_args = mock_error.call_args_list + error_message = 'The following error occurred while trying to import' \ + ' the controller function "1module.1function"' + self.assertIn(error_message, rts_call_args[0][0][0]) + + @mock.patch('tethys_apps.base.app_base.tethys_log') + @mock.patch('tethys_apps.base.app_base.TethysBaseMixin') + def test_url_patterns_attribute_error(self, mock_tbm, mock_log): + mock_error = mock_log.error + app = tethys_app_base.TethysBase() + url_map = mock.MagicMock(controller='test_app.controllers.home1', url='test-app') + url_map.name = 'home' + app.url_maps = mock.MagicMock(return_value=[url_map]) + mock_tbm.return_value = mock.MagicMock(url_maps='test-app') + + # assertRaises needs a callable, not a property + def test_url_patterns(): + return app.url_patterns + + # Check Error Message + self.assertRaises(AttributeError, test_url_patterns) + rts_call_args = mock_error.call_args_list + error_message = 'The following error occurred while trying to access' \ + ' the controller function "test_app.controllers.home1"' + self.assertIn(error_message, rts_call_args[0][0][0]) + + def test_sync_with_tethys_db(self): + self.assertRaises(NotImplementedError, tethys_app_base.TethysBase().sync_with_tethys_db) + + def test_remove_from_db(self): + self.assertRaises(NotImplementedError, tethys_app_base.TethysBase().remove_from_db) + + +class TestTethysExtensionBase(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test__unicode__(self): + result = tethys_app_base.TethysExtensionBase().__unicode__() + self.assertEqual('', result) + + def test__repr__(self): + result = tethys_app_base.TethysExtensionBase().__repr__() + self.assertEqual('', result) + + def test_url_maps(self): + result = tethys_app_base.TethysExtensionBase().url_maps() + self.assertEqual([], result) + + @mock.patch('tethys_apps.models.TethysExtension') + def test_sync_with_tethys_db(self, mock_te): + mock_te.objects.filter().all.return_value = [] + + tethys_app_base.TethysExtensionBase().sync_with_tethys_db() + + mock_te.assert_called_with(description='', name='', package='', root_url='') + mock_te().save.assert_called() + + @mock.patch('django.conf.settings') + @mock.patch('tethys_apps.models.TethysExtension') + def test_sync_with_tethys_db_exists(self, mock_te, mock_ds): + mock_ds.DEBUG = True + ext = tethys_app_base.TethysExtensionBase() + ext.root_url = 'test_url' + mock_te2 = mock.MagicMock() + mock_te.objects.filter().all.return_value = [mock_te2] + ext.sync_with_tethys_db() + + # Check_result + self.assertTrue(mock_te2.save.call_count == 2) + + @mock.patch('tethys_apps.base.app_base.tethys_log') + @mock.patch('tethys_apps.models.TethysExtension') + def test_sync_with_tethys_db_exists_log_error(self, mock_te, mock_log): + mock_error = mock_log.error + ext = tethys_app_base.TethysExtensionBase() + ext.root_url = 'test_url' + mock_te.objects.filter().all.side_effect = Exception('test_error') + ext.sync_with_tethys_db() + + # Check_result + rts_call_args = mock_error.call_args_list + self.assertEqual('test_error', rts_call_args[0][0][0].args[0]) + + +class TestTethysAppBase(unittest.TestCase): + def setUp(self): + self.app = tethys_app_base.TethysAppBase() + self.user = UserFactory() + self.request_factory = RequestFactory() + self.fake_name = 'fake_name' + + def tearDown(self): + pass + + def test__unicode__(self): + result = tethys_app_base.TethysAppBase().__unicode__() + self.assertEqual('', result) + + def test__repr__(self): + result = tethys_app_base.TethysAppBase().__repr__() + self.assertEqual('', result) + + def test_custom_settings(self): + self.assertIsNone(tethys_app_base.TethysAppBase().custom_settings()) + + def test_persistent_store_settings(self): + self.assertIsNone(tethys_app_base.TethysAppBase().persistent_store_settings()) + + def test_dataset_service_settings(self): + self.assertIsNone(tethys_app_base.TethysAppBase().dataset_service_settings()) + + def test_spatial_dataset_service_settings(self): + self.assertIsNone(tethys_app_base.TethysAppBase().spatial_dataset_service_settings()) + + def test_web_processing_service_settings(self): + self.assertIsNone(tethys_app_base.TethysAppBase().web_processing_service_settings()) + + def test_handoff_handlers(self): + self.assertIsNone(tethys_app_base.TethysAppBase().handoff_handlers()) + + def test_permissions(self): + self.assertIsNone(tethys_app_base.TethysAppBase().permissions()) + + @mock.patch('guardian.shortcuts.get_perms') + @mock.patch('guardian.shortcuts.remove_perm') + @mock.patch('guardian.shortcuts.assign_perm') + @mock.patch('tethys_apps.models.TethysApp') + @mock.patch('django.contrib.auth.models.Group') + @mock.patch('django.contrib.auth.models.Permission') + def test_register_app_permissions(self, mock_dp, mock_dg, mock_ta, mock_asg, mock_rem, mock_get): + group_name = 'test_group' + create_test_perm = Permission(name='create_test', description='test_create') + delete_test_perm = Permission(name='delete_test', description='test_delete') + group_perm = PermissionGroup(name=group_name, permissions=[create_test_perm, delete_test_perm]) + self.app.permissions = mock.MagicMock(return_value=[create_test_perm, group_perm]) + + # Mock db_app_permissions + db_app_permission = mock.MagicMock(codename='test_code') + mock_perm_query = mock_dp.objects.filter().filter().all + mock_perm_query.return_value = [db_app_permission] + + # Mock Group.objects.filter + db_group = mock.MagicMock() + db_group.name = 'test_app_name:group' + + mock_group = mock_dg.objects.filter().all + mock_group.return_value = [db_group] + + # Mock TethysApp.objects.all() + db_app = mock.MagicMock(package='test_app_name') + + mock_toa = mock_ta.objects.all + mock_toa.return_value = [db_app] + + # Mock TethysApp.objects.get() + mock_ta_get = mock_ta.objects.get + mock_ta_get.return_value = 'test_get' + + # Mock Group.objects.get() + mock_group_get = mock_dg.objects.get + mock_group_get.return_value = group_name + + # Mock get permission get_perms(g, db_app) + mock_get.return_value = ['create_test'] + + # Execute + self.app.register_app_permissions() + + # Check if db_app_permission.delete() is called + db_app_permission.delete.assert_called_with() + + # Check if p.saved is called in perm + mock_dp.objects.get().save.assert_called_with() + + # Check if db_group.delete() is called + db_group.delete.assert_called_with() + + # Check if remove_perm(p, g, db_app) is called + mock_rem.assert_called_with('create_test', group_name, 'test_get') + + # Check if assign_perm(p, g, db_app) is called + mock_asg.assert_called_with(':delete_test', group_name, 'test_get') + + @mock.patch('guardian.shortcuts.get_perms') + @mock.patch('guardian.shortcuts.remove_perm') + @mock.patch('guardian.shortcuts.assign_perm') + @mock.patch('tethys_apps.models.TethysApp') + @mock.patch('django.contrib.auth.models.Group') + @mock.patch('django.contrib.auth.models.Permission') + def test_register_app_permissions_except_permission(self, mock_dp, mock_dg, mock_ta, mock_asg, mock_rem, mock_get): + group_name = 'test_group' + create_test_perm = Permission(name='create_test', description='test_create') + delete_test_perm = Permission(name='delete_test', description='test_delete') + group_perm = PermissionGroup(name=group_name, permissions=[create_test_perm, delete_test_perm]) + self.app.permissions = mock.MagicMock(return_value=[create_test_perm, group_perm]) + + # Mock Permission.objects.filter + db_app_permission = mock.MagicMock(codename='test_code') + mock_perm_query = mock_dp.objects.filter().filter().all + mock_perm_query.return_value = [db_app_permission] + + # Mock Permission.DoesNotExist + mock_dp.DoesNotExist = Exception + # Mock Permission.objects.get + mock_perm_get = mock_dp.objects.get + mock_perm_get.side_effect = Exception + + # Mock Group.objects.filter + db_group = mock.MagicMock() + db_group.name = 'test_app_name:group' + + mock_group = mock_dg.objects.filter().all + mock_group.return_value = [db_group] + + # Mock TethysApp.objects.all() + db_app = mock.MagicMock(package='test_app_name') + + mock_toa = mock_ta.objects.all + mock_toa.return_value = [db_app] + + # Mock TethysApp.objects.get() + mock_ta_get = mock_ta.objects.get + mock_ta_get.return_value = 'test_get' + + # Mock Permission.DoesNotExist + mock_dg.DoesNotExist = Exception + + # Mock Permission.objects.get + mock_group_get = mock_dg.objects.get + mock_group_get.side_effect = Exception + + # Execute + self.app.register_app_permissions() + + # Check if Permission in Permission.DoesNotExist is called + rts_call_args = mock_dp.call_args_list + + codename_check = [] + name_check = [] + for i in range(len(rts_call_args)): + codename_check.append(rts_call_args[i][1]['codename']) + name_check.append(rts_call_args[i][1]['name']) + + self.assertIn(':create_test', codename_check) + self.assertIn(' | test_create', name_check) + + # Check if db_group.delete() is called + db_group.delete.assert_called_with() + + # Check if Permission is called inside DoesNotExist + # Get the TethysApp content type + from django.contrib.contenttypes.models import ContentType + tethys_content_type = ContentType.objects.get( + app_label='tethys_apps', + model='tethysapp' + ) + mock_dp.assert_any_call(codename=':create_test', content_type=tethys_content_type, + name=' | test_create') + + # Check if p.save() is called inside DoesNotExist + mock_dp().save.assert_called() + + # Check if Group in Group.DoesNotExist is called + rts_call_args = mock_dg.call_args_list + self.assertEqual(':test_group', rts_call_args[0][1]['name']) + + # Check if Group(name=group) is called + mock_dg.assert_called_with(name=':test_group') + + # Check if g.save() is called + mock_dg().save.assert_called() + + # Check if assign_perm(p, g, db_app) is called + rts_call_args = mock_asg.call_args_list + check_list = [] + for i in range(len(rts_call_args)): + for j in [0, 2]: # only get first and last element to check + check_list.append(rts_call_args[i][0][j]) + + self.assertIn(':create_test', check_list) + self.assertIn('test_get', check_list) + self.assertIn(':delete_test', check_list) + self.assertIn('test_get', check_list) + + def test_job_templates(self): + self.assertIsNone(tethys_app_base.TethysAppBase().job_templates()) + + @mock.patch('tethys_apps.base.app_base.HandoffManager') + def test_get_handoff_manager(self, mock_hom): + mock_hom.return_value = 'test_handoff' + self.assertEqual('test_handoff', self.app.get_handoff_manager()) + + @mock.patch('tethys_sdk.jobs.JobManager') + def test_get_job_manager(self, mock_jm): + mock_jm.return_value = 'test_job_manager' + self.assertEqual('test_job_manager', self.app.get_job_manager()) + + @mock.patch('tethys_apps.base.app_base.TethysWorkspace') + def test_get_user_workspace(self, mock_tws): + user = self.user + self.app.get_user_workspace(user) + + # Check result + rts_call_args = mock_tws.call_args_list + self.assertIn('workspaces', rts_call_args[0][0][0]) + self.assertIn('user_workspaces', rts_call_args[0][0][0]) + self.assertIn(user.username, rts_call_args[0][0][0]) + + @mock.patch('tethys_apps.base.app_base.TethysWorkspace') + def test_get_user_workspace_http(self, mock_tws): + from django.http import HttpRequest + request = HttpRequest() + request.user = self.user + + self.app.get_user_workspace(request) + + # Check result + rts_call_args = mock_tws.call_args_list + self.assertIn('workspaces', rts_call_args[0][0][0]) + self.assertIn('user_workspaces', rts_call_args[0][0][0]) + self.assertIn(self.user.username, rts_call_args[0][0][0]) + + @mock.patch('tethys_apps.base.app_base.TethysWorkspace') + def test_get_user_workspace_none(self, mock_tws): + self.app.get_user_workspace(None) + + # Check result + rts_call_args = mock_tws.call_args_list + self.assertIn('workspaces', rts_call_args[0][0][0]) + self.assertIn('user_workspaces', rts_call_args[0][0][0]) + self.assertIn('anonymous_user', rts_call_args[0][0][0]) + + def test_get_user_workspace_error(self): + self.assertRaises(ValueError, self.app.get_user_workspace, user=['test']) + + @mock.patch('tethys_apps.base.app_base.TethysWorkspace') + def test_get_app_workspace(self, mock_tws): + self.app.get_app_workspace() + + # Check result + rts_call_args = mock_tws.call_args_list + self.assertIn('workspaces', rts_call_args[0][0][0]) + self.assertIn('app_workspace', rts_call_args[0][0][0]) + self.assertNotIn('user_workspaces', rts_call_args[0][0][0]) + + @mock.patch('tethys_apps.models.TethysApp') + def test_get_custom_setting(self, mock_ta): + setting_name = 'fake_setting' + result = TethysAppChild.get_custom_setting(name=setting_name) + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + mock_ta.objects.get().custom_settings.get.assert_called_with(name=setting_name) + mock_ta.objects.get().custom_settings.get().get_value.assert_called() + self.assertEqual(mock_ta.objects.get().custom_settings.get().get_value(), result) + + @mock.patch('tethys_apps.base.app_base.TethysAppSettingDoesNotExist') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_custom_setting_object_not_exist(self, mock_ta, mock_tas_dne): + mock_db_app = mock_ta.objects.get + mock_db_app.return_value = mock.MagicMock() + + mock_custom_settings = mock_ta.objects.get().custom_settings.get + mock_custom_settings.side_effect = ObjectDoesNotExist + + mock_tas_dne.return_value = TypeError + self.assertRaises(TypeError, self.app.get_custom_setting, name='test') + + mock_tas_dne.assert_called_with('CustomTethysAppSetting', 'test', '') + + @mock.patch('tethys_apps.models.TethysApp') + def test_get_dataset_service(self, mock_ta): + TethysAppChild.get_dataset_service(name=self.fake_name) + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + mock_ta.objects.get().dataset_services_settings.get.assert_called_with(name=self.fake_name) + mock_ta.objects.get().dataset_services_settings.get().\ + get_value.assert_called_with(as_endpoint=False, as_engine=False, as_public_endpoint=False) + + @mock.patch('tethys_apps.base.app_base.TethysAppSettingDoesNotExist') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_dataset_service_object_not_exist(self, mock_ta, mock_tas_dne): + mock_dss = mock_ta.objects.get().dataset_services_settings.get + mock_dss.side_effect = ObjectDoesNotExist + + mock_tas_dne.return_value = TypeError + self.assertRaises(TypeError, TethysAppChild.get_dataset_service, name=self.fake_name) + + mock_tas_dne.assert_called_with('DatasetServiceSetting', self.fake_name, TethysAppChild.name) + + @mock.patch('tethys_apps.models.TethysApp') + def test_get_spatial_dataset_service(self, mock_ta): + TethysAppChild.get_spatial_dataset_service(name=self.fake_name) + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + mock_ta.objects.get().spatial_dataset_service_settings.get.assert_called_with(name=self.fake_name) + mock_ta.objects.get().spatial_dataset_service_settings.get().\ + get_value.assert_called_with(as_endpoint=False, as_engine=False, as_public_endpoint=False, + as_wfs=False, as_wms=False) + + @mock.patch('tethys_apps.base.app_base.TethysAppSettingDoesNotExist') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_spatial_dataset_service_object_not_exist(self, mock_ta, mock_tas_dne): + mock_sdss = mock_ta.objects.get().spatial_dataset_service_settings.get + mock_sdss.side_effect = ObjectDoesNotExist + + mock_tas_dne.return_value = TypeError + self.assertRaises(TypeError, TethysAppChild.get_spatial_dataset_service, name=self.fake_name) + + mock_tas_dne.assert_called_with('SpatialDatasetServiceSetting', self.fake_name, TethysAppChild.name) + + @mock.patch('tethys_apps.models.TethysApp') + def test_get_web_processing_service(self, mock_ta): + TethysAppChild.get_web_processing_service(name=self.fake_name) + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + mock_ta.objects.get().wps_services_settings.objects.get.assert_called_with(name=self.fake_name) + mock_ta.objects.get().wps_services_settings.objects.get().get_value.\ + assert_called_with(as_public_endpoint=False, as_endpoint=False, as_engine=False) + + @mock.patch('tethys_apps.base.app_base.TethysAppSettingDoesNotExist') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_web_processing_service_object_not_exist(self, mock_ta, mock_tas_dne): + mock_wss = mock_ta.objects.get().wps_services_settings.objects.get + mock_wss.side_effect = ObjectDoesNotExist + + mock_tas_dne.return_value = TypeError + self.assertRaises(TypeError, TethysAppChild.get_web_processing_service, name=self.fake_name) + + mock_tas_dne.assert_called_with('WebProcessingServiceSetting', self.fake_name, TethysAppChild.name) + + @mock.patch('tethys_apps.models.TethysApp') + def test_get_persistent_store_connection(self, mock_ta): + TethysAppChild.get_persistent_store_connection(name=self.fake_name) + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + mock_ta.objects.get().persistent_store_connection_settings.get.assert_called_with(name=self.fake_name) + mock_ta.objects.get().persistent_store_connection_settings.get().get_value.\ + assert_called_with(as_engine=True, as_sessionmaker=False, as_url=False) + + @mock.patch('tethys_apps.base.app_base.TethysAppSettingDoesNotExist') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_persistent_store_connection_object_not_exist(self, mock_ta, mock_tas_dne): + mock_sdss = mock_ta.objects.get().persistent_store_connection_settings.get + mock_sdss.side_effect = ObjectDoesNotExist + + mock_tas_dne.return_value = TypeError + self.assertRaises(TypeError, TethysAppChild.get_persistent_store_connection, name=self.fake_name) + + mock_tas_dne.assert_called_with('PersistentStoreConnectionSetting', self.fake_name, TethysAppChild.name) + + @mock.patch('tethys_apps.base.app_base.tethys_log') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_persistent_store_connection_not_assign(self, mock_ta, mock_log): + mock_sdss = mock_ta.objects.get().persistent_store_connection_settings.get + mock_sdss.side_effect = TethysAppSettingNotAssigned + + # Execute + TethysAppChild.get_persistent_store_connection(name=self.fake_name) + + # Check log + rts_call_args = mock_log.warn.call_args_list + self.assertIn('Tethys app setting is not assigned.', rts_call_args[0][0][0]) + check_string = 'PersistentStoreConnectionSetting named "{}" has not been assigned'. format(self.fake_name) + self.assertIn(check_string, rts_call_args[0][0][0]) + + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_persistent_store_database(self, mock_ta, mock_ite): + mock_ite.return_value = False + TethysAppChild.get_persistent_store_database(name=self.fake_name) + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + + # Check ps_database_settings.get(name=verified_name) is called + mock_ta.objects.get().persistent_store_database_settings.get.assert_called_with(name=self.fake_name) + mock_ta.objects.get().persistent_store_database_settings.get().get_value.\ + assert_called_with(as_engine=True, as_sessionmaker=False, as_url=False, with_db=True) + + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_persistent_store_database_object_does_not_exist(self, mock_ta, mock_ite): + mock_ite.return_value = False + mock_ta.objects.get().persistent_store_database_settings.get.side_effect = ObjectDoesNotExist + + # Check Raise + self.assertRaises(TethysAppSettingDoesNotExist, TethysAppChild.get_persistent_store_database, + name=self.fake_name) + + @mock.patch('tethys_apps.base.app_base.tethys_log') + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_persistent_store_database_not_assigned(self, mock_ta, mock_ite, mock_log): + mock_ite.return_value = False + mock_ta.objects.get().persistent_store_database_settings.get.side_effect = TethysAppSettingNotAssigned + + TethysAppChild.get_persistent_store_database(name=self.fake_name) + + # Check log + rts_call_args = mock_log.warn.call_args_list + self.assertIn('Tethys app setting is not assigned.', rts_call_args[0][0][0]) + check_string = 'PersistentStoreDatabaseSetting named "{}" has not been assigned'. format(self.fake_name) + self.assertIn(check_string, rts_call_args[0][0][0]) + + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_create_persistent_store(self, mock_ta, mock_ite): + mock_ite.return_value = False + result = TethysAppChild.create_persistent_store(db_name='example_db', connection_name='primary') + + # Check ps_connection_settings.get(name=connection_name) is called + mock_ta.objects.get().persistent_store_connection_settings.get.assert_called_with(name='primary') + + # Check db_app.persistent_store_database_settings.get(name=verified_db_name) is called + mock_ta.objects.get().persistent_store_database_settings.get.assert_called_with(name='example_db') + + # Check db_setting.save() is called + mock_ta.objects.get().persistent_store_database_settings.get().save.assert_called() + + # Check Create the new database is called + mock_ta.objects.get().persistent_store_database_settings.get().create_persistent_store_database.\ + assert_called_with(force_first_time=False, refresh=False) + + # Check result is true + self.assertTrue(result) + + @mock.patch('tethys_apps.base.app_base.get_test_db_name') + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_create_persistent_store_testing_env(self, mock_ta, mock_ite, mock_tdn): + mock_ite.return_value = True + mock_tdn.return_value = 'verified_db_name' + TethysAppChild.create_persistent_store(db_name='example_db', connection_name=None) + + # Check get_test_db_name(db_name) is called + mock_tdn.assert_called_with('example_db') + + rts_call_args = mock_ta.objects.get().persistent_store_database_settings.get.call_args_list + # Check ps_connection_settings.get(name=connection_name) is called + self.assertEqual({'name': 'example_db'}, rts_call_args[0][1]) + + # Check db_app.persistent_store_database_settings.get(name=verified_db_name) is called + self.assertEqual({'name': 'verified_db_name'}, rts_call_args[1][1]) + + # Check db_setting.save() is called + mock_ta.objects.get().persistent_store_database_settings.get().save.assert_called() + + # Check Create the new database is called + mock_ta.objects.get().persistent_store_database_settings.get().create_persistent_store_database.\ + assert_called_with(force_first_time=False, refresh=False) + + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_create_persistent_store_no_connection_name(self, _, mock_ite): + mock_ite.return_value = False + self.assertRaises(ValueError, TethysAppChild.create_persistent_store, db_name='example_db', + connection_name=None) + + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_create_persistent_store_no_connection_object_not_exist_testing_env(self, mock_ta, mock_ite): + # Need to test in testing env to test the connection_name is None case + mock_ite.return_value = True + mock_ta.objects.get().persistent_store_database_settings.get.side_effect = ObjectDoesNotExist + + self.assertRaises(TethysAppSettingDoesNotExist, TethysAppChild.create_persistent_store, db_name='example_db', + connection_name=None) + + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_create_persistent_store_connection_object_not_exist_testing_env(self, mock_ta, mock_ite): + # Need to test in testing env to test the connection_name is None case + mock_ite.return_value = True + mock_ta.objects.get().persistent_store_connection_settings.get.side_effect = ObjectDoesNotExist + + self.assertRaises(TethysAppSettingDoesNotExist, TethysAppChild.create_persistent_store, db_name='example_db', + connection_name='test_con') + + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting') + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_create_persistent_store_object_not_exist(self, mock_ta, mock_ite, mock_psd): + # Need to test in testing env to test the connection_name is None case + mock_ite.return_value = False + mock_ta.objects.get().persistent_store_database_settings.get.side_effect = ObjectDoesNotExist + + # Execute + TethysAppChild.create_persistent_store(db_name='example_db', connection_name='test_con') + + # Check if PersistentStoreDatabaseSetting is called + mock_psd.assert_called_with(description='', dynamic=True, initializer='', name='example_db', + required=False, spatial=False) + + # Check if db_setting is called + db_setting = mock_psd() + mock_ta.objects.get().add_settings.assert_called_with((db_setting,)) + + # Check if save is called + mock_ta.objects.get().save.assert_called() + + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_drop_persistent_store(self, mock_ta, mock_ite): + mock_ite.return_value = False + result = TethysAppChild.drop_persistent_store(name='example_store') + + # Check if TethysApp.objects.get(package=cls.package) is called + mock_ta.objects.get.assert_called_with(package='test_app') + + # Check if ps_database_settings.get(name=verified_name) is called + mock_ta.objects.get().persistent_store_database_settings.get.assert_called_with(name='example_store') + + # Check if drop the persistent store is called + mock_ta.objects.get().persistent_store_database_settings.get().drop_persistent_store_database.assert_called() + + # Check if remove the database setting is called + mock_ta.objects.get().persistent_store_database_settings.get().delete.assert_called() + + # Check result return True + self.assertTrue(result) + + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_drop_persistent_store_object_does_not_exist(self, mock_ta, mock_ite): + mock_ite.return_value = False + mock_ta.objects.get().persistent_store_database_settings.get.side_effect = ObjectDoesNotExist + result = TethysAppChild.drop_persistent_store(name='example_store') + + # Check result return True + self.assertTrue(result) + + @mock.patch('tethys_apps.models.TethysApp') + def test_list_persistent_store_databases_dynamic(self, mock_ta): + mock_settings = mock_ta.objects.get().persistent_store_database_settings.filter + setting1 = mock.MagicMock() + setting1.name = 'test1' + setting2 = mock.MagicMock() + setting2.name = 'test2' + mock_settings.return_value = [setting1, setting2] + + result = TethysAppChild.list_persistent_store_databases(dynamic_only=True) + + # Check TethysApp.objects.get is called + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + + # Check filter is called + mock_ta.objects.get().persistent_store_database_settings.filter.\ + assert_called_with(persistentstoredatabasesetting__dynamic=True) + + # Check result + self.assertEqual(['test1', 'test2'], result) + + @mock.patch('tethys_apps.models.TethysApp') + def test_list_persistent_store_databases_static(self, mock_ta): + mock_settings = mock_ta.objects.get().persistent_store_database_settings.filter + setting1 = mock.MagicMock() + setting1.name = 'test1' + setting2 = mock.MagicMock() + setting2.name = 'test2' + mock_settings.return_value = [setting1, setting2] + + result = TethysAppChild.list_persistent_store_databases(static_only=True) + + # Check TethysApp.objects.get is called + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + + # Check filter is called + mock_ta.objects.get().persistent_store_database_settings.filter.\ + assert_called_with(persistentstoredatabasesetting__dynamic=False) + + # Check result + self.assertEqual(['test1', 'test2'], result) + + @mock.patch('tethys_apps.models.TethysApp') + def test_list_persistent_store_connections(self, mock_ta): + setting1 = mock.MagicMock() + setting1.name = 'test1' + setting2 = mock.MagicMock() + setting2.name = 'test2' + mock_ta.objects.get().persistent_store_connection_settings = [setting1, setting2] + + result = TethysAppChild.list_persistent_store_connections() + + # Check TethysApp.objects.get is called + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + + # Check result + self.assertEqual(['test1', 'test2'], result) + + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_persistent_store_exists(self, mock_ta, mock_ite): + mock_ite.return_value = False + result = TethysAppChild.persistent_store_exists(name='test_store') + + # Check TethysApp.objects.get is called + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + + # Check if ps_database_settings.get is called + mock_ta.objects.get().persistent_store_database_settings.get.assert_called_with(name='test_store') + + # Check if database exists is called + mock_ta.objects.get().persistent_store_database_settings.get().persistent_store_database_exists.assert_called() + + # Check if result True + self.assertTrue(result) + + @mock.patch('tethys_apps.base.app_base.is_testing_environment') + @mock.patch('tethys_apps.models.TethysApp') + def test_persistent_store_exists_object_does_not_exist(self, mock_ta, mock_ite): + mock_ite.return_value = False + mock_ta.objects.get().persistent_store_database_settings.get.side_effect = ObjectDoesNotExist + result = TethysAppChild.persistent_store_exists(name='test_store') + + # Check TethysApp.objects.get is called + mock_ta.objects.get.assert_called_with(package=TethysAppChild.package) + + # Check if ps_database_settings.get is called + mock_ta.objects.get().persistent_store_database_settings.get.assert_called_with(name='test_store') + + # Check if result False + self.assertFalse(result) + + @mock.patch('django.conf.settings') + @mock.patch('tethys_apps.models.TethysApp') + def test_sync_with_tethys_db(self, mock_ta, _): + mock_ta.objects.filter().all.return_value = [] + self.app.name = 'n' + self.app.package = 'p' + self.app.description = 'd' + self.app.enable_feedback = 'e' + self.app.feedback_emails = 'f' + self.app.index = 'in' + self.app.icon = 'ic' + self.app.root_url = 'r' + self.app.color = 'c' + self.app.tags = 't' + self.app.sync_with_tethys_db() + + # Check if TethysApp.objects.filter is called + mock_ta.objects.filter().all.assert_called() + + # Check if TethysApp is called + mock_ta.assert_called_with(color='c', description='d', enable_feedback='e', feedback_emails='f', + icon='ic', index='in', name='n', package='p', root_url='r', tags='t') + + # Check if save is called 2 times + self.assertTrue(mock_ta().save.call_count == 2) + + # Check if add_settings is called 5 times + self.assertTrue(mock_ta().add_settings.call_count == 5) + + @mock.patch('django.conf.settings') + @mock.patch('tethys_apps.models.TethysApp') + def test_sync_with_tethys_db_in_db(self, mock_ta, mock_ds): + mock_ds.DEBUG = True + mock_app = mock.MagicMock() + mock_ta.objects.filter().all.return_value = [mock_app] + self.app.sync_with_tethys_db() + + # Check if TethysApp.objects.filter is called + mock_ta.objects.filter().all.assert_called() + + # Check if save is called 2 times + self.assertTrue(mock_app.save.call_count == 2) + + # Check if add_settings is called 5 times + self.assertTrue(mock_app.add_settings.call_count == 5) + + @mock.patch('django.conf.settings') + @mock.patch('tethys_apps.models.TethysApp') + def test_sync_with_tethys_db_more_than_one(self, mock_ta, mock_ds): + mock_ds.DEBUG = True + mock_app = mock.MagicMock() + mock_ta.objects.filter().all.return_value = [mock_app, mock_app] + self.app.sync_with_tethys_db() + + # Check if TethysApp.objects.filter is called + mock_ta.objects.filter().all.assert_called() + + # Check if is not called + mock_app.save.assert_not_called() + + # Check if is not called + mock_app.add_settings.assert_not_called() + + @mock.patch('tethys_apps.base.app_base.tethys_log') + @mock.patch('tethys_apps.models.TethysApp') + def test_sync_with_tethys_db_exception(self, mock_ta, mock_log): + mock_ta.objects.filter().all.side_effect = Exception + self.app.sync_with_tethys_db() + + mock_log.error.assert_called() + + @mock.patch('tethys_apps.models.TethysApp') + def test_remove_from_db(self, mock_ta): + self.app.remove_from_db() + + # Check if delete is called + mock_ta.objects.filter().delete.assert_called() + + @mock.patch('tethys_apps.base.app_base.tethys_log') + @mock.patch('tethys_apps.models.TethysApp') + def test_remove_from_db_2(self, mock_ta, mock_log): + mock_ta.objects.filter().delete.side_effect = Exception + self.app.remove_from_db() + + # Check tethys log error + mock_log.error.assert_called() diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_controller.py b/tests/unit_tests/test_tethys_apps/test_base/test_controller.py new file mode 100644 index 000000000..5f6051bef --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_controller.py @@ -0,0 +1,22 @@ +import unittest +import mock +import tethys_apps.base.controller as tethys_controller + + +class TestController(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_app_controller_maker(self): + root_url = 'test_root_url' + result = tethys_controller.app_controller_maker(root_url) + self.assertEqual(result.root_url, root_url) + + @mock.patch('django.views.generic.View.as_view') + def test_TethysController(self, mock_as_view): + kwargs = {'foo': 'bar'} + tethys_controller.TethysController.as_controller(**kwargs) + mock_as_view.assert_called_with(**kwargs) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_function_extractor.py b/tests/unit_tests/test_tethys_apps/test_base/test_function_extractor.py new file mode 100644 index 000000000..4c31db0b6 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_function_extractor.py @@ -0,0 +1,53 @@ +import unittest +import types +import tethys_apps.base.function_extractor as tethys_function_extractor + + +def test_func(): + pass + + +class TestTethysFunctionExtractor(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_init(self): + path = 'tethysapp-test_app.tethysapp.test_app.controller.home' + result = tethys_function_extractor.TethysFunctionExtractor(path=path) + + # Check Result + self.assertEqual(path, result.path) + self.assertEqual('tethys_apps.tethysapp', result.prefix) + + def test_init_func(self): + result = tethys_function_extractor.TethysFunctionExtractor(path=test_func) + + # Check Result + self.assertIs(test_func, result.function) + self.assertTrue(result.valid) + + def test_valid(self): + path = 'test_app.model.test_initializer' + result = tethys_function_extractor.TethysFunctionExtractor(path=path).valid + + # Check Result + self.assertTrue(result) + + def test_function(self): + path = 'test_app.model.test_initializer' + result = tethys_function_extractor.TethysFunctionExtractor(path=path).function + + # Check Result + self.assertIsInstance(result, types.FunctionType) + + def test_function_error(self): + path = 'test_app1.foo' + app = tethys_function_extractor.TethysFunctionExtractor(path=path, throw=True) + + def test_function_import(): + return app.function + + self.assertRaises(ImportError, test_function_import) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_handoff.py b/tests/unit_tests/test_tethys_apps/test_base/test_handoff.py new file mode 100644 index 000000000..298dc1cbe --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_handoff.py @@ -0,0 +1,277 @@ +from __future__ import print_function +import unittest +import tethys_apps.base.handoff as tethys_handoff +from types import FunctionType +import mock + + +def test_function(*args): + + if args is not None: + arg_list = [] + for arg in args: + arg_list.append(arg) + return arg_list + else: + return '' + + +class TestHandoffManager(unittest.TestCase): + def setUp(self): + self.hm = tethys_handoff.HandoffManager + + def tearDown(self): + pass + + def test_init(self): + # Mock app + app = mock.MagicMock() + + # Mock handoff_handlers + handlers = mock.MagicMock(name='handler_name') + app.handoff_handlers.return_value = handlers + + # mock _get_valid_handlers + self.hm._get_valid_handlers = mock.MagicMock(return_value=['valid_handler']) + result = tethys_handoff.HandoffManager(app=app) + + # Check result + self.assertEqual(app, result.app) + self.assertEqual(handlers, result.handlers) + self.assertEqual(['valid_handler'], result.valid_handlers) + + def test_repr(self): + # Mock app + app = mock.MagicMock() + + # Mock handoff_handlers + handlers = mock.MagicMock() + handlers.name = 'test_handler' + app.handoff_handlers.return_value = [handlers] + + # mock _get_valid_handlers + self.hm._get_valid_handlers = mock.MagicMock(return_value=['valid_handler']) + result = tethys_handoff.HandoffManager(app=app).__repr__() + check_string = "".format(app, handlers.name) + + self.assertEqual(check_string, result) + + def test_get_capabilities(self): + # Mock app + app = mock.MagicMock() + + # Mock _get_handoff_manager_for_app + manager = mock.MagicMock(valid_handlers='test_handlers') + self.hm._get_handoff_manager_for_app = mock.MagicMock(return_value=manager) + + result = tethys_handoff.HandoffManager(app=app).get_capabilities(app_name='test_app') + + # Check Result + self.assertEqual('test_handlers', result) + + def test_get_capabilities_external(self): + # Mock app + app = mock.MagicMock() + + # Mock _get_handoff_manager_for_app + handler1 = mock.MagicMock() + handler1.internal = False + handler2 = mock.MagicMock() + # Do not write out handler2 + handler2.internal = True + manager = mock.MagicMock(valid_handlers=[handler1, handler2]) + self.hm._get_handoff_manager_for_app = mock.MagicMock(return_value=manager) + + result = tethys_handoff.HandoffManager(app=app).get_capabilities(app_name='test_app', external_only=True) + + # Check Result + self.assertEqual([handler1], result) + + @mock.patch('tethys_apps.base.handoff.json') + def test_get_capabilities_json(self, mock_json): + # Mock app + app = mock.MagicMock() + + # Mock HandoffHandler.__json + + handler1 = mock.MagicMock(name='test_name') + manager = mock.MagicMock(valid_handlers=[handler1]) + self.hm._get_handoff_manager_for_app = mock.MagicMock(return_value=manager) + + tethys_handoff.HandoffManager(app=app).get_capabilities(app_name='test_app', jsonify=True) + + # Check Result + rts_call_args = mock_json.dumps.call_args_list + self.assertEqual('test_name', rts_call_args[0][0][0][0]['_mock_name']) + + def test_get_handler(self): + app = mock.MagicMock() + + # Mock _get_handoff_manager_for_app + handler1 = mock.MagicMock() + handler1.name = 'handler1' + manager = mock.MagicMock(valid_handlers=[handler1]) + self.hm._get_handoff_manager_for_app = mock.MagicMock(return_value=manager) + + result = tethys_handoff.HandoffManager(app=app).get_handler(handler_name='handler1') + + self.assertEqual('handler1', result.name) + + def test_handoff(self): + from django.http import HttpRequest + request = HttpRequest() + + # Mock app + app = mock.MagicMock() + app.name = 'test_app_name' + + # Mock _get_handoff_manager_for_app + handler1 = mock.MagicMock() + handler1().internal = False + manager = mock.MagicMock(get_handler=handler1) + self.hm._get_handoff_manager_for_app = mock.MagicMock(return_value=manager) + + result = tethys_handoff.HandoffManager(app=app).handoff(request=request, handler_name='test_handler') + + # 302 code is for redirect + self.assertEqual(302, result.status_code) + + @mock.patch('tethys_apps.base.handoff.HttpResponseBadRequest') + def test_handoff_type_error(self, mock_hrbr): + from django.http import HttpRequest + request = HttpRequest() + + # Mock app + app = mock.MagicMock() + app.name = 'test_app_name' + + # Mock _get_handoff_manager_for_app + handler1 = mock.MagicMock() + handler1().internal = False + handler1().side_effect = TypeError('test message') + manager = mock.MagicMock(get_handler=handler1) + self.hm._get_handoff_manager_for_app = mock.MagicMock(return_value=manager) + + tethys_handoff.HandoffManager(app=app).handoff(request=request, handler_name='test_handler') + rts_call_args = mock_hrbr.call_args_list + + # Check result + self.assertIn('HTTP 400 Bad Request: test message.', rts_call_args[0][0][0]) + + @mock.patch('tethys_apps.base.handoff.HttpResponseBadRequest') + def test_handoff_error(self, mock_hrbr): + from django.http import HttpRequest + request = HttpRequest() + # + # # Mock app + app = mock.MagicMock() + app.name = 'test_app_name' + + # Mock _get_handoff_manager_for_app + handler1 = mock.MagicMock() + # Ask Nathan is this how the test should be. because internal = True has + # nothing to do with the error message. + handler1().internal = True + handler1().side_effect = TypeError('test message') + mapp = mock.MagicMock() + mapp.name = 'test manager name' + manager = mock.MagicMock(get_handler=handler1, app=mapp) + self.hm._get_handoff_manager_for_app = mock.MagicMock(return_value=manager) + + tethys_handoff.HandoffManager(app=app).handoff(request=request, handler_name='test_handler') + rts_call_args = mock_hrbr.call_args_list + + # Check result + check_message = "HTTP 400 Bad Request: No handoff handler '{0}' for app '{1}' found".\ + format('test manager name', 'test_handler') + self.assertIn(check_message, rts_call_args[0][0][0]) + + @mock.patch('tethys_apps.base.handoff.print') + def test_get_valid_handlers(self, mock_print): + app = mock.MagicMock(package='test_app') + + # Mock handoff_handlers + # Mock handoff_handlers + handler1 = mock.MagicMock(handler='my_first_app.controllers.my_handler', valid=True) + handler2 = mock.MagicMock(handler='controllers:home', valid=False) + + # Cover Import Error Case + handler3 = mock.MagicMock(handler='controllers1:home1', valid=False) + + app.handoff_handlers.return_value = [handler1, handler2, handler3] + # mock _get_valid_handlers + + result = tethys_handoff.HandoffManager(app=app)._get_valid_handlers() + + # Check result + self.assertEqual('my_first_app.controllers.my_handler', result[0].handler) + self.assertEqual('controllers:home', result[1].handler) + + check_message = 'DEPRECATION WARNING: The handler attribute of a HandoffHandler should now be in the form:' \ + ' "my_first_app.controllers.my_handler". The form "handoff:my_handler" is now deprecated.' + mock_print.assert_called_with(check_message) + + +class TestHandoffHandler(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_init(self): + result = tethys_handoff.HandoffHandler(name='test_name', handler='test_app.handoff.csv', internal=True) + + # Check Result + self.assertEqual('test_name', result.name) + self.assertEqual('test_app.handoff.csv', result.handler) + self.assertTrue(result.internal) + self.assertIs(type(result.function), FunctionType) + + def test_repr(self): + result = tethys_handoff.HandoffHandler(name='test_name', handler='test_app.handoff.csv', + internal=True).__repr__() + + # Check Result + check_string = '' + self.assertEqual(check_string, result) + + def test_dict_json_arguments(self): + tethys_handoff.HandoffHandler.arguments = ['test_json', 'request'] + result = tethys_handoff.HandoffHandler(name='test_name', handler='test_app.handoff.csv', + internal=True).__dict__() + + # Check Result + check_dict = {'name': 'test_name', 'arguments': ['test_json']} + self.assertIsInstance(result, dict) + self.assertEqual(check_dict, result) + + def test_arguments(self): + result = tethys_handoff.HandoffHandler(name='test_name', handler='test_app.handoff.csv', internal=True)\ + .arguments + + self.assertEqual(['request', 'csv_url'], result) + + +class TestGetHandoffManagerFroApp(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_not_app_name(self): + app = mock.MagicMock() + result = tethys_handoff.HandoffManager(app=app)._get_handoff_manager_for_app(app_name=None) + + self.assertEqual(app, result.app) + + @mock.patch('tethys_apps.base.handoff.tethys_apps') + def test_with_app(self, mock_ta): + app = mock.MagicMock(package='test_app') + app.get_handoff_manager.return_value = 'test_manager' + mock_ta.harvester.SingletonAppHarvester().apps = [app] + result = tethys_handoff.HandoffManager(app=app)._get_handoff_manager_for_app(app_name='test_app') + + # Check result + self.assertEqual('test_manager', result) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_mixins.py b/tests/unit_tests/test_tethys_apps/test_base/test_mixins.py new file mode 100644 index 000000000..b3f7b0875 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_mixins.py @@ -0,0 +1,15 @@ +import unittest +import tethys_apps.base.mixins as tethys_mixins + + +class TestTethysBaseMixin(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_TethysBaseMixin(self): + result = tethys_mixins.TethysBaseMixin() + result.root_url = 'test-url' + self.assertEqual('test_url', result.namespace) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_permissions.py b/tests/unit_tests/test_tethys_apps/test_base/test_permissions.py new file mode 100644 index 000000000..eb459213e --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_permissions.py @@ -0,0 +1,94 @@ +import unittest +import tethys_apps.base.permissions as tethys_permission +import mock + +from django.test import RequestFactory +from tests.factories.django_user import UserFactory + + +class TestPermission(unittest.TestCase): + def setUp(self): + self.name = 'test_name' + self.description = 'test_description' + self.check_string = ''.\ + format(self.name, self.description) + + def tearDown(self): + pass + + def test_init(self): + result = tethys_permission.Permission(name=self.name, description=self.description) + self.assertEqual(self.name, result.name) + self.assertEqual(self.description, result.description) + + def test_repr(self): + result = tethys_permission.Permission(name=self.name, description=self.description)._repr() + + # Check Result + self.assertEqual(self.check_string, result) + + def test_str(self): + result = tethys_permission.Permission(name=self.name, description=self.description).__str__() + + # Check Result + self.assertEqual(self.check_string, result) + + def test_repr_(self): + result = tethys_permission.Permission(name=self.name, description=self.description).__repr__() + + # Check Result + self.assertEqual(self.check_string, result) + + +class TestPermissionGroup(unittest.TestCase): + def setUp(self): + self.user = UserFactory() + self.request_factory = RequestFactory() + self.name = 'test_name' + self.permissions = ['foo', 'bar'] + self.check_string = ''.format(self.name) + + def tearDown(self): + pass + + def test_init(self): + result = tethys_permission.PermissionGroup(name=self.name, permissions=['foo', 'bar']) + + self.assertEqual(self.name, result.name) + self.assertEqual(self.permissions, result.permissions) + + def test_repr(self): + result = tethys_permission.PermissionGroup(name=self.name)._repr() + + # Check Result + self.assertEqual(self.check_string, result) + + def test_str(self): + result = tethys_permission.PermissionGroup(name=self.name).__str__() + + # Check Result + self.assertEqual(self.check_string, result) + + def test_repr_(self): + result = tethys_permission.PermissionGroup(name=self.name).__repr__() + + # Check Result + self.assertEqual(self.check_string, result) + + @mock.patch('tethys_apps.utilities.get_active_app') + def test_has_permission(self, mock_app): + request = self.request_factory + self.user.has_perm = mock.MagicMock() + request.user = self.user + mock_app.return_value = mock.MagicMock(package='test_package') + result = tethys_permission.has_permission(request=request, perm='test_perm') + self.assertTrue(result) + + @mock.patch('tethys_apps.utilities.get_active_app') + def test_has_permission_no(self, mock_app): + request = self.request_factory + self.user.has_perm = mock.MagicMock(return_value=False) + request.user = self.user + mock_app.return_value = mock.MagicMock(package='test_package') + result = tethys_permission.has_permission(request=request, perm='test_perm') + self.assertFalse(result) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_testing/__init__.py b/tests/unit_tests/test_tethys_apps/test_base/test_testing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_testing/test_environment.py b/tests/unit_tests/test_tethys_apps/test_base/test_testing/test_environment.py new file mode 100644 index 000000000..d83c19c93 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_testing/test_environment.py @@ -0,0 +1,32 @@ +import unittest +import tethys_apps.base.testing.environment as base_environment +from os import environ + + +class TestEnvironment(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_set_testing_environment(self): + base_environment.set_testing_environment(True) + self.assertEqual('true', environ['TETHYS_TESTING_IN_PROGRESS']) + + result = base_environment.is_testing_environment() + self.assertEqual('true', result) + + base_environment.set_testing_environment(False) + self.assertIsNone(environ.get('TETHYS_TESTING_IN_PROGRESS')) + + result = base_environment.is_testing_environment() + self.assertIsNone(result) + + def test_get_test_db_name(self): + expected_result = 'tethys-testing_test' + result = base_environment.get_test_db_name('test') + self.assertEqual(expected_result, result) + + result = base_environment.get_test_db_name('tethys-testing_test') + self.assertEqual(expected_result, result) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_testing/test_testing.py b/tests/unit_tests/test_tethys_apps/test_base/test_testing/test_testing.py new file mode 100644 index 000000000..8f9dffcb0 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_testing/test_testing.py @@ -0,0 +1,163 @@ +import unittest +import tethys_apps.base.testing.testing as base_testing +import mock +from tethys_apps.base.app_base import TethysAppBase + + +class TestClass(): + pass + + +def bypass_init(self): + pass + + +class TestTethysTestCase(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.harvester.SingletonHarvester') + def test_setup(self, mock_harvest): + base_testing.TethysTestCase.__init__ = bypass_init + t = base_testing.TethysTestCase() + t.set_up = mock.MagicMock() + t.setUp() + + t.set_up.assert_called() + mock_harvest().harvest.assert_called() + + def test_set_up(self): + base_testing.TethysTestCase.__init__ = bypass_init + t = base_testing.TethysTestCase() + t.set_up() + + def test_teardown(self): + base_testing.TethysTestCase.__init__ = bypass_init + t = base_testing.TethysTestCase() + t.tear_down = mock.MagicMock() + t.tearDown() + + t.tear_down.assert_called() + + def test_tear_down(self): + base_testing.TethysTestCase.__init__ = bypass_init + t = base_testing.TethysTestCase() + t.tear_down() + + @mock.patch('tethys_apps.base.testing.testing.TethysAppBase.create_persistent_store') + @mock.patch('tethys_apps.models.TethysApp') + @mock.patch('tethys_apps.base.testing.testing.is_testing_environment') + def test_create_test_persistent_stores_for_app(self, mock_ite, mock_ta, mock_cps): + mock_ite.return_value = True + app_class = TethysAppBase + + # mock_db_setting + db_setting = mock.MagicMock(spatial='test_spatial', initializer='test_init') + db_setting.name = 'test_name' + db_app = mock.MagicMock(persistent_store_database_settings=[db_setting]) + mock_ta.objects.get.return_value = db_app + + # Execute + base_testing.TethysTestCase.create_test_persistent_stores_for_app(app_class) + + # Check Result + mock_ta.objects.get.assert_called_with(package='') + mock_cps.assert_called_with(connection_name=None, db_name='test_name', force_first_time=True, + initializer='test_init', refresh=True, spatial='test_spatial') + + @mock.patch('tethys_apps.base.testing.testing.is_testing_environment') + def test_create_test_persistent_stores_for_app_not_testing_env(self, mock_ite): + mock_ite.return_value = False + app_class = TethysAppBase + self.assertRaises(EnvironmentError, base_testing.TethysTestCase.create_test_persistent_stores_for_app, + app_class) + + @mock.patch('tethys_apps.base.testing.testing.is_testing_environment') + def test_create_test_persistent_stores_for_app_not_subclass(self, mock_ite): + mock_ite.return_value = True + app_class = TestClass + self.assertRaises(TypeError, base_testing.TethysTestCase.create_test_persistent_stores_for_app, app_class) + + @mock.patch('tethys_apps.base.testing.testing.TethysAppBase.create_persistent_store') + @mock.patch('tethys_apps.models.TethysApp') + @mock.patch('tethys_apps.base.testing.testing.is_testing_environment') + def test_create_test_persistent_stores_for_app_not_success(self, mock_ite, mock_ta, mock_cps): + mock_ite.return_value = True + app_class = TethysAppBase + + # mock_db_setting + db_setting = mock.MagicMock() + db_app = mock.MagicMock(persistent_store_database_settings=[db_setting]) + mock_ta.objects.get.return_value = db_app + + # mock create_peristent_store + mock_cps.return_value = False + + # Execute + self.assertRaises(SystemError, base_testing.TethysTestCase.create_test_persistent_stores_for_app, app_class) + + @mock.patch('tethys_apps.base.testing.testing.TethysAppBase.drop_persistent_store') + @mock.patch('tethys_apps.base.testing.testing.get_test_db_name') + @mock.patch('tethys_apps.base.testing.testing.TethysAppBase.list_persistent_store_databases') + @mock.patch('tethys_apps.base.testing.testing.is_testing_environment') + def test_destroy_test_persistent_stores_for_app(self, mock_ite, mock_lps, mock_get, mock_dps): + mock_ite.return_value = True + app_class = TethysAppBase + + mock_lps.return_value = ['db_name'] + mock_get.return_value = 'test_db_name' + # Execute + base_testing.TethysTestCase.destroy_test_persistent_stores_for_app(app_class) + + # Check mock called + mock_get.assert_called_with('db_name') + mock_dps.assert_called_with('test_db_name') + + @mock.patch('tethys_apps.base.testing.testing.is_testing_environment') + def test_destroy_test_persistent_stores_for_app_not_testing_env(self, mock_ite): + mock_ite.return_value = False + app_class = TethysAppBase + self.assertRaises(EnvironmentError, base_testing.TethysTestCase.destroy_test_persistent_stores_for_app, + app_class) + + @mock.patch('tethys_apps.base.testing.testing.is_testing_environment') + def test_destroy_test_persistent_stores_for_app_not_subclass(self, mock_ite): + mock_ite.return_value = True + app_class = TestClass + # Execute + self.assertRaises(TypeError, base_testing.TethysTestCase.destroy_test_persistent_stores_for_app, app_class) + + @mock.patch('django.contrib.auth.models.User') + def test_create_test_user(self, mock_user): + mock_user.objects.create_user = mock.MagicMock(return_value='test_create_user') + + result = base_testing.TethysTestCase.create_test_user(username='test_user', password='test_pass', + email='test_e') + + # Check result + self.assertEqual('test_create_user', result) + mock_user.objects.create_user.assert_called_with(username='test_user', password='test_pass', email='test_e') + + @mock.patch('django.contrib.auth.models.User') + def test_create_test_super_user(self, mock_user): + mock_user.objects.create_superuser = mock.MagicMock(return_value='test_create_super_user') + + result = base_testing.TethysTestCase.create_test_superuser(username='test_user', password='test_pass', + email='test_e') + + # Check result + self.assertEqual('test_create_super_user', result) + mock_user.objects.create_superuser.assert_called_with(username='test_user', password='test_pass', + email='test_e') + + @mock.patch('tethys_apps.base.testing.testing.Client') + def test_get_test_client(self, mock_client): + mock_client.return_value = 'test_get_client' + + result = base_testing.TethysTestCase.get_test_client() + + # Check result + self.assertEqual('test_get_client', result) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_url_map.py b/tests/unit_tests/test_tethys_apps/test_base/test_url_map.py new file mode 100644 index 000000000..61ad9798a --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_url_map.py @@ -0,0 +1,64 @@ +import unittest +import tethys_apps.base.url_map as base_url_map + + +class TestUrlMap(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_UrlMapBase(self): + name = 'test_name' + url = '/example/resource/{variable_name}/' + expected_url = r'^example/resource/(?P[0-9A-Za-z-_.]+)//$' + controller = 'test_controller' + + result = base_url_map.UrlMapBase(name=name, url=url, controller=controller) + + # Check Result + self.assertEqual(name, result.name) + self.assertEqual(expected_url, result.url) + self.assertEqual(controller, result.controller) + + # TEST regex-case1 + regex = '[0-9A-Z]+' + expected_url = '^example/resource/(?P[0-9A-Z]+)//$' + + result = base_url_map.UrlMapBase(name=name, url=url, controller=controller, regex=regex) + self.assertEqual(expected_url, result.url) + + # TEST regex-case2 + regex = ['[0-9A-Z]+', '[0-8A-W]+'] + url = '/example/resource/{variable_name}/{variable_name2}/' + expected_url = '^example/resource/(?P[0-9A-Z]+)/(?P[0-8A-W]+)//$' + + result = base_url_map.UrlMapBase(name=name, url=url, controller=controller, regex=regex) + self.assertEqual(expected_url, result.url) + + # TEST regex-case3 + regex = ['[0-9A-Z]+'] + url = '/example/resource/{variable_name}/{variable_name2}/' + expected_url = '^example/resource/(?P[0-9A-Z]+)/(?P[0-9A-Z]+)//$' + + result = base_url_map.UrlMapBase(name=name, url=url, controller=controller, regex=regex) + self.assertEqual(expected_url, result.url) + + # TEST __repre__ + expected_result = '[0-9A-Za-z-_.]+)/' \ + '(?P[0-9A-Za-z-_.]+)//$, controller=test_controller>' + result = base_url_map.UrlMapBase(name=name, url=url, controller=controller).__repr__() + self.assertEqual(expected_result, result) + + # TEST empty url + regex = ['[0-9A-Z]+'] + url = '' + expected_url = '^$' + + result = base_url_map.UrlMapBase(name=name, url=url, controller=controller, regex=regex) + self.assertEqual(expected_url, result.url) + + def test_UrlMapBase_value_error(self): + self.assertRaises(ValueError, base_url_map.UrlMapBase, name='1', url='2', + controller='3', regex={'1': '2'}) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py b/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py new file mode 100644 index 000000000..f0a73cd00 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py @@ -0,0 +1,86 @@ +import unittest +import tethys_apps.base.workspace as base_workspace +import os +import shutil + + +class TestUrlMap(unittest.TestCase): + def setUp(self): + self.root = os.path.abspath(os.path.dirname(__file__)) + self.test_root = os.path.join(self.root, 'test_workspace') + self.test_root_a = os.path.join(self.test_root, 'test_workspace_a') + self.test_root2 = os.path.join(self.root, 'test_workspace2') + + def tearDown(self): + if os.path.isdir(self.test_root): + shutil.rmtree(self.test_root) + if os.path.isdir(self.test_root2): + shutil.rmtree(self.test_root2) + + def test_TethysWorkspace(self): + # Test Create new workspace folder test_workspace + result = base_workspace.TethysWorkspace(path=self.test_root) + workspace = ''.format(self.test_root) + + # Create new folder inside test_workspace + base_workspace.TethysWorkspace(path=self.test_root_a) + + # Create new folder test_workspace2 + base_workspace.TethysWorkspace(path=self.test_root2) + + self.assertEqual(result.__repr__(), workspace) + self.assertEqual(result.path, self.test_root) + + # Create Files + file_list = ['test1.txt', 'test2.txt'] + for file_name in file_list: + # Create file + open(os.path.join(self.test_root, file_name), 'a').close() + + # Test files with full path + result = base_workspace.TethysWorkspace(path=self.test_root).files(full_path=True) + for file_name in file_list: + self.assertIn(os.path.join(self.test_root, file_name), result) + + # Test files without full path + result = base_workspace.TethysWorkspace(path=self.test_root).files() + for file_name in file_list: + self.assertIn(file_name, result) + + # Test Directories with full path + result = base_workspace.TethysWorkspace(path=self.root).directories(full_path=True) + self.assertIn(self.test_root, result) + self.assertIn(self.test_root2, result) + + # Test Directories without full path + result = base_workspace.TethysWorkspace(path=self.root).directories() + self.assertIn('test_workspace', result) + self.assertIn('test_workspace2', result) + self.assertNotIn(self.test_root, result) + self.assertNotIn(self.test_root2, result) + + # Test Remove file + base_workspace.TethysWorkspace(path=self.test_root).remove('test2.txt') + + # Verify that the file has been remove + self.assertFalse(os.path.isfile(os.path.join(self.test_root, 'test2.txt'))) + + # Test Remove Directory + base_workspace.TethysWorkspace(path=self.root).remove(self.test_root2) + + # Verify that the Directory has been remove + self.assertFalse(os.path.isdir(self.test_root2)) + + # Test Clear + base_workspace.TethysWorkspace(path=self.test_root).clear() + + # Verify that the Directory has been remove + self.assertFalse(os.path.isdir(self.test_root_a)) + + # Verify that the File has been remove + self.assertFalse(os.path.isfile(os.path.join(self.test_root, 'test1.txt'))) + + # Test don't allow overwriting the path property + workspace = base_workspace.TethysWorkspace(path=self.test_root) + workspace.path = 'foo' + self.assertEqual(self.test_root, workspace.path) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/__init__.py b/tests/unit_tests/test_tethys_apps/test_cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test__init__.py b/tests/unit_tests/test_tethys_apps/test_cli/test__init__.py new file mode 100644 index 000000000..2a1ff640b --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test__init__.py @@ -0,0 +1,1176 @@ +import sys +import unittest +import mock +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +from tethys_apps.cli import tethys_command + + +class TethysCommandTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.cli.scaffold_command') + def test_scaffold_subcommand(self, mock_scaffold_command): + testargs = ['tethys', 'scaffold', 'foo'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_scaffold_command.assert_called() + call_args = mock_scaffold_command.call_args_list + self.assertEqual('foo', call_args[0][0][0].name) + self.assertEqual('default', call_args[0][0][0].template) + self.assertFalse(call_args[0][0][0].overwrite) + self.assertFalse(call_args[0][0][0].extension) + self.assertFalse(call_args[0][0][0].use_defaults) + + @mock.patch('tethys_apps.cli.scaffold_command') + def test_scaffold_subcommand_with_options(self, mock_scaffold_command): + testargs = ['tethys', 'scaffold', 'foo', '-e', '-t', 'my_template', '-o', '-d'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_scaffold_command.assert_called() + call_args = mock_scaffold_command.call_args_list + self.assertEqual('foo', call_args[0][0][0].name) + self.assertEqual('my_template', call_args[0][0][0].template) + self.assertTrue(call_args[0][0][0].overwrite) + self.assertTrue(call_args[0][0][0].extension) + self.assertTrue(call_args[0][0][0].use_defaults) + + @mock.patch('tethys_apps.cli.scaffold_command') + def test_scaffold_subcommand_with_verbose_options(self, mock_scaffold_command): + testargs = ['tethys', 'scaffold', 'foo', '--extension', '--template', 'my_template', '--overwrite', + '--defaults'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_scaffold_command.assert_called() + call_args = mock_scaffold_command.call_args_list + self.assertEqual('foo', call_args[0][0][0].name) + self.assertEqual('my_template', call_args[0][0][0].template) + self.assertTrue(call_args[0][0][0].overwrite) + self.assertTrue(call_args[0][0][0].extension) + self.assertTrue(call_args[0][0][0].use_defaults) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.scaffold_command') + def test_scaffold_subcommand_help(self, mock_scaffold_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'scaffold', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_scaffold_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--template', mock_stdout.getvalue()) + self.assertIn('--extension', mock_stdout.getvalue()) + self.assertIn('--defaults', mock_stdout.getvalue()) + self.assertIn('--overwrite', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.generate_command') + def test_generate_subcommand_settings_defaults(self, mock_gen_command): + testargs = ['tethys', 'gen', 'settings'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_gen_command.assert_called() + call_args = mock_gen_command.call_args_list + self.assertEqual(None, call_args[0][0][0].allowed_host) + self.assertEqual(None, call_args[0][0][0].allowed_hosts) + self.assertEqual('75M', call_args[0][0][0].client_max_body_size) + self.assertEqual('pass', call_args[0][0][0].db_password) + self.assertEqual(5436, call_args[0][0][0].db_port) + self.assertEqual('tethys_default', call_args[0][0][0].db_username) + self.assertEqual(None, call_args[0][0][0].directory) + self.assertFalse(call_args[0][0][0].overwrite) + self.assertFalse(call_args[0][0][0].production) + self.assertEqual('settings', call_args[0][0][0].type) + self.assertEqual(10, call_args[0][0][0].uwsgi_processes) + + @mock.patch('tethys_apps.cli.generate_command') + def test_generate_subcommand_settings_directory(self, mock_gen_command): + testargs = ['tethys', 'gen', 'settings', '--directory', '/tmp/foo/bar'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_gen_command.assert_called() + call_args = mock_gen_command.call_args_list + self.assertEqual(None, call_args[0][0][0].allowed_host) + self.assertEqual(None, call_args[0][0][0].allowed_hosts) + self.assertEqual('75M', call_args[0][0][0].client_max_body_size) + self.assertEqual('pass', call_args[0][0][0].db_password) + self.assertEqual(5436, call_args[0][0][0].db_port) + self.assertEqual('tethys_default', call_args[0][0][0].db_username) + self.assertEqual('/tmp/foo/bar', call_args[0][0][0].directory) + self.assertFalse(call_args[0][0][0].overwrite) + self.assertFalse(call_args[0][0][0].production) + self.assertEqual('settings', call_args[0][0][0].type) + self.assertEqual(10, call_args[0][0][0].uwsgi_processes) + + @mock.patch('tethys_apps.cli.generate_command') + def test_generate_subcommand_apache_settings_verbose_options(self, mock_gen_command): + testargs = ['tethys', 'gen', 'apache', '-d', '/tmp/foo/bar', '--allowed-host', '127.0.0.1', + '--allowed-hosts', 'localhost', '--client-max-body-size', '123M', '--uwsgi-processes', '9', + '--db-username', 'foo_user', '--db-password', 'foo_pass', '--db-port', '5555', + '--production', '--overwrite'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_gen_command.assert_called() + call_args = mock_gen_command.call_args_list + self.assertEqual('127.0.0.1', call_args[0][0][0].allowed_host) + self.assertEqual('localhost', call_args[0][0][0].allowed_hosts) + self.assertEqual('123M', call_args[0][0][0].client_max_body_size) + self.assertEqual('foo_pass', call_args[0][0][0].db_password) + self.assertEqual('5555', call_args[0][0][0].db_port) + self.assertEqual('foo_user', call_args[0][0][0].db_username) + self.assertEqual('/tmp/foo/bar', call_args[0][0][0].directory) + self.assertTrue(call_args[0][0][0].overwrite) + self.assertTrue(call_args[0][0][0].production) + self.assertEqual('apache', call_args[0][0][0].type) + self.assertEqual('9', call_args[0][0][0].uwsgi_processes) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.generate_command') + def test_generate_subcommand_help(self, mock_gen_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'gen', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_gen_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--directory', mock_stdout.getvalue()) + self.assertIn('--allowed-host', mock_stdout.getvalue()) + self.assertIn('--client-max-body-size', mock_stdout.getvalue()) + self.assertIn('--uwsgi-processes', mock_stdout.getvalue()) + self.assertIn('--db-username', mock_stdout.getvalue()) + self.assertIn('--db-password', mock_stdout.getvalue()) + self.assertIn('--db-port', mock_stdout.getvalue()) + self.assertIn('--production', mock_stdout.getvalue()) + self.assertIn('--overwrite', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.manage_command') + def test_manage_subcommand_start(self, mock_manage_command): + testargs = ['tethys', 'manage', 'start'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_manage_command.assert_called() + call_args = mock_manage_command.call_args_list + self.assertEqual('start', call_args[0][0][0].command) + self.assertEqual(False, call_args[0][0][0].force) + self.assertEqual(None, call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].noinput) + self.assertEqual(None, call_args[0][0][0].port) + + @mock.patch('tethys_apps.cli.manage_command') + def test_manage_subcommand_start_options(self, mock_manage_command): + testargs = ['tethys', 'manage', 'start', '-m', '/foo/bar/manage.py', '-p', '5555', '-f'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_manage_command.assert_called() + call_args = mock_manage_command.call_args_list + self.assertEqual('start', call_args[0][0][0].command) + self.assertEqual(True, call_args[0][0][0].force) + self.assertEqual('/foo/bar/manage.py', call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].noinput) + self.assertEqual('5555', call_args[0][0][0].port) + + @mock.patch('tethys_apps.cli.manage_command') + def test_manage_subcommand_start_verbose_options(self, mock_manage_command): + testargs = ['tethys', 'manage', 'start', '--manage', '/foo/bar/manage.py', '--port', '5555', '--force', + '--noinput'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_manage_command.assert_called() + call_args = mock_manage_command.call_args_list + self.assertEqual('start', call_args[0][0][0].command) + self.assertEqual(True, call_args[0][0][0].force) + self.assertEqual('/foo/bar/manage.py', call_args[0][0][0].manage) + self.assertEqual(True, call_args[0][0][0].noinput) + self.assertEqual('5555', call_args[0][0][0].port) + + @mock.patch('tethys_apps.cli.manage_command') + def test_manage_subcommand_syncdb(self, mock_manage_command): + testargs = ['tethys', 'manage', 'syncdb'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_manage_command.assert_called() + call_args = mock_manage_command.call_args_list + self.assertEqual('syncdb', call_args[0][0][0].command) + self.assertEqual(False, call_args[0][0][0].force) + self.assertEqual(None, call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].noinput) + self.assertEqual(None, call_args[0][0][0].port) + + @mock.patch('tethys_apps.cli.manage_command') + def test_manage_subcommand_collectstatic(self, mock_manage_command): + testargs = ['tethys', 'manage', 'collectstatic'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_manage_command.assert_called() + call_args = mock_manage_command.call_args_list + self.assertEqual('collectstatic', call_args[0][0][0].command) + self.assertEqual(False, call_args[0][0][0].force) + self.assertEqual(None, call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].noinput) + self.assertEqual(None, call_args[0][0][0].port) + + @mock.patch('tethys_apps.cli.manage_command') + def test_manage_subcommand_collectworkspaces(self, mock_manage_command): + testargs = ['tethys', 'manage', 'collectworkspaces'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_manage_command.assert_called() + call_args = mock_manage_command.call_args_list + self.assertEqual('collectworkspaces', call_args[0][0][0].command) + self.assertEqual(False, call_args[0][0][0].force) + self.assertEqual(None, call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].noinput) + self.assertEqual(None, call_args[0][0][0].port) + + @mock.patch('tethys_apps.cli.manage_command') + def test_manage_subcommand_collectall(self, mock_manage_command): + testargs = ['tethys', 'manage', 'collectall'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_manage_command.assert_called() + call_args = mock_manage_command.call_args_list + self.assertEqual('collectall', call_args[0][0][0].command) + self.assertEqual(False, call_args[0][0][0].force) + self.assertEqual(None, call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].noinput) + self.assertEqual(None, call_args[0][0][0].port) + + @mock.patch('tethys_apps.cli.manage_command') + def test_manage_subcommand_createsuperuser(self, mock_manage_command): + testargs = ['tethys', 'manage', 'createsuperuser'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_manage_command.assert_called() + call_args = mock_manage_command.call_args_list + self.assertEqual('createsuperuser', call_args[0][0][0].command) + self.assertEqual(False, call_args[0][0][0].force) + self.assertEqual(None, call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].noinput) + self.assertEqual(None, call_args[0][0][0].port) + + @mock.patch('tethys_apps.cli.manage_command') + def test_manage_subcommand_sync(self, mock_manage_command): + testargs = ['tethys', 'manage', 'sync'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_manage_command.assert_called() + call_args = mock_manage_command.call_args_list + self.assertEqual('sync', call_args[0][0][0].command) + self.assertEqual(False, call_args[0][0][0].force) + self.assertEqual(None, call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].noinput) + self.assertEqual(None, call_args[0][0][0].port) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.manage_command') + def test_manage_subcommand_help(self, mock_manage_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'manage', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_manage_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--manage', mock_stdout.getvalue()) + self.assertIn('--port', mock_stdout.getvalue()) + self.assertIn('--noinput', mock_stdout.getvalue()) + self.assertIn('--force', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.scheduler_create_command') + def test_scheduler_create_command_options(self, mock_scheduler_create_command): + testargs = ['tethys', 'schedulers', 'create', '-n', 'foo_name', '-e', 'http://foo.foo_endpoint', + '-u', 'foo_user', '-p', 'foo_pass', '-k', 'private_foo_pass'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_scheduler_create_command.assert_called() + call_args = mock_scheduler_create_command.call_args_list + self.assertEqual('http://foo.foo_endpoint', call_args[0][0][0].endpoint) + self.assertEqual('foo_name', call_args[0][0][0].name) + self.assertEqual('foo_pass', call_args[0][0][0].password) + self.assertEqual('private_foo_pass', call_args[0][0][0].private_key_pass) + self.assertEqual(None, call_args[0][0][0].private_key_path) + self.assertEqual('foo_user', call_args[0][0][0].username) + + @mock.patch('tethys_apps.cli.scheduler_create_command') + def test_scheduler_create_command_verbose_options(self, mock_scheduler_create_command): + testargs = ['tethys', 'schedulers', 'create', '--name', 'foo_name', '--endpoint', 'http://foo.foo_endpoint', + '--username', 'foo_user', '--private-key-path', 'private_foo_path'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_scheduler_create_command.assert_called() + call_args = mock_scheduler_create_command.call_args_list + self.assertEqual('http://foo.foo_endpoint', call_args[0][0][0].endpoint) + self.assertEqual('foo_name', call_args[0][0][0].name) + self.assertEqual(None, call_args[0][0][0].password) + self.assertEqual(None, call_args[0][0][0].private_key_pass) + self.assertEqual('private_foo_path', call_args[0][0][0].private_key_path) + self.assertEqual('foo_user', call_args[0][0][0].username) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.scheduler_create_command') + def test_scheduler_create_command_help(self, mock_scheduler_create_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'schedulers', 'create', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_scheduler_create_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--name', mock_stdout.getvalue()) + self.assertIn('--endpoint', mock_stdout.getvalue()) + self.assertIn('--username', mock_stdout.getvalue()) + self.assertIn('--password', mock_stdout.getvalue()) + self.assertIn('--private-key-path', mock_stdout.getvalue()) + self.assertIn('--private-key-pass', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.schedulers_list_command') + def test_scheduler_list_command(self, mock_scheduler_list_command): + testargs = ['tethys', 'schedulers', 'list'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_scheduler_list_command.assert_called() + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.schedulers_list_command') + def test_scheduler_list_command_help(self, mock_scheduler_list_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'schedulers', 'list', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_scheduler_list_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.schedulers_remove_command') + def test_scheduler_remove_command(self, mock_scheduler_remove_command): + testargs = ['tethys', 'schedulers', 'remove', 'foo_name'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_scheduler_remove_command.assert_called() + call_args = mock_scheduler_remove_command.call_args_list + self.assertEqual(False, call_args[0][0][0].force) + self.assertEqual('foo_name', call_args[0][0][0].scheduler_name) + + @mock.patch('tethys_apps.cli.schedulers_remove_command') + def test_scheduler_remove_command_options(self, mock_scheduler_remove_command): + testargs = ['tethys', 'schedulers', 'remove', 'foo_name', '-f'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_scheduler_remove_command.assert_called() + call_args = mock_scheduler_remove_command.call_args_list + self.assertEqual(True, call_args[0][0][0].force) + self.assertEqual('foo_name', call_args[0][0][0].scheduler_name) + + @mock.patch('tethys_apps.cli.schedulers_remove_command') + def test_scheduler_remove_command_verbose_options(self, mock_scheduler_remove_command): + testargs = ['tethys', 'schedulers', 'remove', 'foo_name', '--force'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_scheduler_remove_command.assert_called() + call_args = mock_scheduler_remove_command.call_args_list + self.assertEqual(True, call_args[0][0][0].force) + self.assertEqual('foo_name', call_args[0][0][0].scheduler_name) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.schedulers_remove_command') + def test_scheduler_list_command_help_2(self, mock_scheduler_remove_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'schedulers', 'remove', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_scheduler_remove_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--force', mock_stdout.getvalue()) + self.assertIn('scheduler_name', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.services_remove_persistent_command') + def test_services_remove_persistent_command(self, mock_services_remove_persistent_command): + testargs = ['tethys', 'services', 'remove', 'persistent', 'foo_service_uid'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_remove_persistent_command.assert_called() + call_args = mock_services_remove_persistent_command.call_args_list + self.assertEqual(False, call_args[0][0][0].force) + self.assertEqual('foo_service_uid', call_args[0][0][0].service_uid) + + @mock.patch('tethys_apps.cli.services_remove_persistent_command') + def test_services_remove_persistent_command_options(self, mock_services_remove_persistent_command): + testargs = ['tethys', 'services', 'remove', 'persistent', '-f', 'foo_service_uid'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_remove_persistent_command.assert_called() + call_args = mock_services_remove_persistent_command.call_args_list + self.assertEqual(True, call_args[0][0][0].force) + self.assertEqual('foo_service_uid', call_args[0][0][0].service_uid) + + @mock.patch('tethys_apps.cli.services_remove_persistent_command') + def test_services_remove_persistent_command_verbose_options(self, mock_services_remove_persistent_command): + testargs = ['tethys', 'services', 'remove', 'persistent', '--force', 'foo_service_uid'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_remove_persistent_command.assert_called() + call_args = mock_services_remove_persistent_command.call_args_list + self.assertEqual(True, call_args[0][0][0].force) + self.assertEqual('foo_service_uid', call_args[0][0][0].service_uid) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.services_remove_persistent_command') + def test_services_remove_persistent_command_help(self, mock_services_remove_persistent_command, mock_exit, + mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'services', 'remove', 'persistent', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_services_remove_persistent_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--force', mock_stdout.getvalue()) + self.assertIn('service_uid', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.services_remove_spatial_command') + def test_services_remove_spatial_command(self, mock_services_remove_spatial_command): + testargs = ['tethys', 'services', 'remove', 'spatial', 'foo_service_uid'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_remove_spatial_command.assert_called() + call_args = mock_services_remove_spatial_command.call_args_list + self.assertEqual(False, call_args[0][0][0].force) + self.assertEqual('foo_service_uid', call_args[0][0][0].service_uid) + + @mock.patch('tethys_apps.cli.services_remove_spatial_command') + def test_services_remove_spatial_command_options(self, mock_services_remove_spatial_command): + testargs = ['tethys', 'services', 'remove', 'spatial', '-f', 'foo_service_uid'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_remove_spatial_command.assert_called() + call_args = mock_services_remove_spatial_command.call_args_list + self.assertEqual(True, call_args[0][0][0].force) + self.assertEqual('foo_service_uid', call_args[0][0][0].service_uid) + + @mock.patch('tethys_apps.cli.services_remove_spatial_command') + def test_services_remove_spatial_command_verbose_options(self, mock_services_remove_spatial_command): + testargs = ['tethys', 'services', 'remove', 'spatial', '--force', 'foo_service_uid'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_remove_spatial_command.assert_called() + call_args = mock_services_remove_spatial_command.call_args_list + self.assertEqual(True, call_args[0][0][0].force) + self.assertEqual('foo_service_uid', call_args[0][0][0].service_uid) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.services_remove_spatial_command') + def test_services_remove_spatial_command_help(self, mock_services_remove_spatial_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'services', 'remove', 'spatial', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_services_remove_spatial_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--force', mock_stdout.getvalue()) + self.assertIn('service_uid', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.services_create_persistent_command') + def test_services_create_persistent_command_options(self, mock_services_create_persistent_command): + testargs = ['tethys', 'services', 'create', 'persistent', '-n', 'foo_name', '-c', 'foo:pass@foo.bar:5555'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_create_persistent_command.assert_called() + call_args = mock_services_create_persistent_command.call_args_list + self.assertEqual('foo:pass@foo.bar:5555', call_args[0][0][0].connection) + self.assertEqual('foo_name', call_args[0][0][0].name) + + @mock.patch('tethys_apps.cli.services_create_persistent_command') + def test_services_create_persistent_command_verbose_options(self, mock_services_create_persistent_command): + testargs = ['tethys', 'services', 'create', 'persistent', '--name', 'foo_name', + '--connection', 'foo:pass@foo.bar:5555'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_create_persistent_command.assert_called() + call_args = mock_services_create_persistent_command.call_args_list + self.assertEqual('foo:pass@foo.bar:5555', call_args[0][0][0].connection) + self.assertEqual('foo_name', call_args[0][0][0].name) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.services_create_persistent_command') + def test_services_create_persistent_command_help(self, mock_services_create_persistent_command, mock_exit, + mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'services', 'create', 'persistent', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_services_create_persistent_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--name', mock_stdout.getvalue()) + self.assertIn('--connection', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.services_create_spatial_command') + def test_services_create_spatial_command_options(self, mock_services_create_spatial_command): + testargs = ['tethys', 'services', 'create', 'spatial', '-n', 'foo_name', '-c', 'foo:pass@http://foo.bar:5555'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_create_spatial_command.assert_called() + call_args = mock_services_create_spatial_command.call_args_list + self.assertEqual(None, call_args[0][0][0].apikey) + self.assertEqual('foo:pass@http://foo.bar:5555', call_args[0][0][0].connection) + self.assertEqual('foo_name', call_args[0][0][0].name) + self.assertEqual(None, call_args[0][0][0].public_endpoint) + + @mock.patch('tethys_apps.cli.services_create_spatial_command') + def test_services_create_spatial_command_verbose_options(self, mock_services_create_spatial_command): + testargs = ['tethys', 'services', 'create', 'spatial', '--name', 'foo_name', + '--connection', 'foo:pass@http://foo.bar:5555', '--public-endpoint', 'foo.bar:1234', + '--apikey', 'foo_apikey'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_create_spatial_command.assert_called() + call_args = mock_services_create_spatial_command.call_args_list + self.assertEqual('foo_apikey', call_args[0][0][0].apikey) + self.assertEqual('foo:pass@http://foo.bar:5555', call_args[0][0][0].connection) + self.assertEqual('foo_name', call_args[0][0][0].name) + self.assertEqual('foo.bar:1234', call_args[0][0][0].public_endpoint) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.services_create_spatial_command') + def test_services_create_spatial_command_help(self, mock_services_create_spatial_command, mock_exit, + mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'services', 'create', 'spatial', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_services_create_spatial_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--name', mock_stdout.getvalue()) + self.assertIn('--connection', mock_stdout.getvalue()) + self.assertIn('--public-endpoint', mock_stdout.getvalue()) + self.assertIn('--apikey', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.services_list_command') + def test_services_list_command(self, mock_services_list_command): + testargs = ['tethys', 'services', 'list'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_list_command.assert_called() + call_args = mock_services_list_command.call_args_list + self.assertEqual(False, call_args[0][0][0].persistent) + self.assertEqual(False, call_args[0][0][0].spatial) + + @mock.patch('tethys_apps.cli.services_list_command') + def test_services_list_command_options(self, mock_services_list_command): + testargs = ['tethys', 'services', 'list', '-p'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_list_command.assert_called() + call_args = mock_services_list_command.call_args_list + self.assertEqual(True, call_args[0][0][0].persistent) + self.assertEqual(False, call_args[0][0][0].spatial) + + @mock.patch('tethys_apps.cli.services_list_command') + def test_services_list_command_verbose_options(self, mock_services_list_command): + testargs = ['tethys', 'services', 'list', '--spatial'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_services_list_command.assert_called() + call_args = mock_services_list_command.call_args_list + self.assertEqual(False, call_args[0][0][0].persistent) + self.assertEqual(True, call_args[0][0][0].spatial) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.services_list_command') + def test_services_list_command_help(self, mock_services_list_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'services', 'list', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_services_list_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--persistent', mock_stdout.getvalue()) + self.assertIn('--spatial', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.app_settings_list_command') + def test_app_settings_list_command(self, mock_app_settings_list_command): + testargs = ['tethys', 'app_settings', 'list', 'foo_app'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_app_settings_list_command.assert_called() + call_args = mock_app_settings_list_command.call_args_list + self.assertEqual('foo_app', call_args[0][0][0].app) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.app_settings_list_command') + def test_app_settings_list_command_help(self, mock_app_settings_list_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'app_settings', 'list', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_app_settings_list_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.app_settings_create_ps_database_command') + def test_app_settings_create_command_options(self, mock_app_settings_create_command): + testargs = ['tethys', 'app_settings', 'create', '-a', 'foo_app_package', '-n', 'foo', '-d', 'foo description', + 'ps_database', '-s', '-y'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_app_settings_create_command.assert_called() + call_args = mock_app_settings_create_command.call_args_list + self.assertEqual('foo_app_package', call_args[0][0][0].app) + self.assertEqual('foo description', call_args[0][0][0].description) + self.assertEqual(True, call_args[0][0][0].dynamic) + self.assertEqual(False, call_args[0][0][0].initialized) + self.assertEqual(None, call_args[0][0][0].initializer) + self.assertEqual('foo', call_args[0][0][0].name) + self.assertEqual(False, call_args[0][0][0].required) + self.assertEqual(True, call_args[0][0][0].spatial) + + @mock.patch('tethys_apps.cli.app_settings_create_ps_database_command') + def test_app_settings_create_command_verbose_options(self, mock_app_settings_create_command): + testargs = ['tethys', 'app_settings', 'create', '--app', 'foo_app_package', '--name', 'foo', '--description', + 'foo description', 'ps_database', '--spatial', '--dynamic'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_app_settings_create_command.assert_called() + call_args = mock_app_settings_create_command.call_args_list + self.assertEqual('foo_app_package', call_args[0][0][0].app) + self.assertEqual('foo description', call_args[0][0][0].description) + self.assertEqual(True, call_args[0][0][0].dynamic) + self.assertEqual(False, call_args[0][0][0].initialized) + self.assertEqual(None, call_args[0][0][0].initializer) + self.assertEqual('foo', call_args[0][0][0].name) + self.assertEqual(False, call_args[0][0][0].required) + self.assertEqual(True, call_args[0][0][0].spatial) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.app_settings_create_ps_database_command') + def test_app_settings_create_command_help(self, mock_app_settings_create_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'app_settings', 'create', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_app_settings_create_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--app', mock_stdout.getvalue()) + self.assertIn('--name', mock_stdout.getvalue()) + self.assertIn('--description', mock_stdout.getvalue()) + self.assertIn('--required', mock_stdout.getvalue()) + self.assertIn('--initializer', mock_stdout.getvalue()) + self.assertIn('--initialized', mock_stdout.getvalue()) + self.assertIn('{ps_database}', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.app_settings_remove_command') + def test_app_settings_create_command_options_2(self, mock_app_settings_remove_command): + testargs = ['tethys', 'app_settings', 'remove', '-n', 'foo', '-f', 'foo_app'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_app_settings_remove_command.assert_called() + call_args = mock_app_settings_remove_command.call_args_list + self.assertEqual('foo_app', call_args[0][0][0].app) + self.assertEqual(True, call_args[0][0][0].force) + self.assertEqual('foo', call_args[0][0][0].name) + + @mock.patch('tethys_apps.cli.app_settings_remove_command') + def test_app_settings_create_command_verbose_options_2(self, mock_app_settings_remove_command): + testargs = ['tethys', 'app_settings', 'remove', '--name', 'foo', '--force', 'foo_app'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_app_settings_remove_command.assert_called() + call_args = mock_app_settings_remove_command.call_args_list + self.assertEqual('foo_app', call_args[0][0][0].app) + self.assertEqual(True, call_args[0][0][0].force) + self.assertEqual('foo', call_args[0][0][0].name) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.app_settings_remove_command') + def test_app_settings_create_command_help_2(self, mock_app_settings_remove_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'app_settings', 'remove', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_app_settings_remove_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('', mock_stdout.getvalue()) + self.assertIn('--name', mock_stdout.getvalue()) + self.assertIn('--force', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.link_command') + def test_link_command(self, mock_link_command): + testargs = ['tethys', 'link', 'spatial:foo_service', 'foo_package:database:foo_2'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_link_command.assert_called() + call_args = mock_link_command.call_args_list + self.assertEqual('spatial:foo_service', call_args[0][0][0].service) + self.assertEqual('foo_package:database:foo_2', call_args[0][0][0].setting) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.link_command') + def test_link_command_help(self, mock_link_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'link', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_link_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('service', mock_stdout.getvalue()) + self.assertIn('setting', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.tstc') + def test_test_command(self, mock_test_command): + testargs = ['tethys', 'test'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_test_command.assert_called() + call_args = mock_test_command.call_args_list + self.assertEqual(False, call_args[0][0][0].coverage) + self.assertEqual(False, call_args[0][0][0].coverage_html) + self.assertEqual(None, call_args[0][0][0].file) + self.assertEqual(False, call_args[0][0][0].gui) + self.assertEqual(False, call_args[0][0][0].unit) + + @mock.patch('tethys_apps.cli.tstc') + def test_test_command_options(self, mock_test_command): + testargs = ['tethys', 'test', '-c', '-C', '-u', '-g', '-f', 'foo.bar'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_test_command.assert_called() + call_args = mock_test_command.call_args_list + self.assertEqual(True, call_args[0][0][0].coverage) + self.assertEqual(True, call_args[0][0][0].coverage_html) + self.assertEqual('foo.bar', call_args[0][0][0].file) + self.assertEqual(True, call_args[0][0][0].gui) + self.assertEqual(True, call_args[0][0][0].unit) + + @mock.patch('tethys_apps.cli.tstc') + def test_test_command_options_verbose(self, mock_test_command): + testargs = ['tethys', 'test', '--coverage', '--coverage-html', '--unit', '--gui', '--file', 'foo.bar'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_test_command.assert_called() + call_args = mock_test_command.call_args_list + self.assertEqual(True, call_args[0][0][0].coverage) + self.assertEqual(True, call_args[0][0][0].coverage_html) + self.assertEqual('foo.bar', call_args[0][0][0].file) + self.assertEqual(True, call_args[0][0][0].gui) + self.assertEqual(True, call_args[0][0][0].unit) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.tstc') + def test_test_command_help(self, mock_test_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'test', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_test_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--coverage', mock_stdout.getvalue()) + self.assertIn('--coverage-html', mock_stdout.getvalue()) + self.assertIn('--unit', mock_stdout.getvalue()) + self.assertIn('--gui', mock_stdout.getvalue()) + self.assertIn('--file', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.uc') + def test_uninstall_command(self, mock_uninstall_command): + testargs = ['tethys', 'uninstall', 'foo_app'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_uninstall_command.assert_called() + call_args = mock_uninstall_command.call_args_list + self.assertEqual('foo_app', call_args[0][0][0].app_or_extension) + self.assertEqual(False, call_args[0][0][0].is_extension) + + @mock.patch('tethys_apps.cli.uc') + def test_uninstall_command_options(self, mock_uninstall_command): + testargs = ['tethys', 'uninstall', '-e', 'foo_ext'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_uninstall_command.assert_called() + call_args = mock_uninstall_command.call_args_list + self.assertEqual('foo_ext', call_args[0][0][0].app_or_extension) + self.assertEqual(True, call_args[0][0][0].is_extension) + + @mock.patch('tethys_apps.cli.uc') + def test_uninstall_command_verbose_options(self, mock_uninstall_command): + testargs = ['tethys', 'uninstall', '--extension', 'foo_ext'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_uninstall_command.assert_called() + call_args = mock_uninstall_command.call_args_list + self.assertEqual('foo_ext', call_args[0][0][0].app_or_extension) + self.assertEqual(True, call_args[0][0][0].is_extension) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.uc') + def test_uninstall_command_help(self, mock_uninstall_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'uninstall', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_uninstall_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--extension', mock_stdout.getvalue()) + self.assertIn('app_or_extension', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.lc') + def test_list_command(self, mock_list_command): + testargs = ['tethys', 'list'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_list_command.assert_called() + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.lc') + def test_list_command_help(self, mock_list_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'list', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_list_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.syc') + def test_syncstores_command_single(self, mock_syncstores_command): + testargs = ['tethys', 'syncstores', 'foo_app1'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_syncstores_command.assert_called() + call_args = mock_syncstores_command.call_args_list + self.assertEqual(['foo_app1'], call_args[0][0][0].app) + self.assertEqual(None, call_args[0][0][0].database) + self.assertEqual(False, call_args[0][0][0].firstime) + self.assertEqual(False, call_args[0][0][0].firsttime) + self.assertEqual(None, call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].refresh) + + @mock.patch('tethys_apps.cli.syc') + def test_syncstores_command_multiple(self, mock_syncstores_command): + testargs = ['tethys', 'syncstores', 'foo_app1', 'foo_app2', 'foo_app3'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_syncstores_command.assert_called() + call_args = mock_syncstores_command.call_args_list + self.assertEqual(['foo_app1', 'foo_app2', 'foo_app3'], call_args[0][0][0].app) + self.assertEqual(None, call_args[0][0][0].database) + self.assertEqual(False, call_args[0][0][0].firstime) + self.assertEqual(False, call_args[0][0][0].firsttime) + self.assertEqual(None, call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].refresh) + + @mock.patch('tethys_apps.cli.syc') + def test_syncstores_command_all(self, mock_syncstores_command): + testargs = ['tethys', 'syncstores', 'all'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_syncstores_command.assert_called() + call_args = mock_syncstores_command.call_args_list + self.assertEqual(['all'], call_args[0][0][0].app) + self.assertEqual(None, call_args[0][0][0].database) + self.assertEqual(False, call_args[0][0][0].firstime) + self.assertEqual(False, call_args[0][0][0].firsttime) + self.assertEqual(None, call_args[0][0][0].manage) + self.assertEqual(False, call_args[0][0][0].refresh) + + @mock.patch('tethys_apps.cli.syc') + def test_syncstores_command_options(self, mock_syncstores_command): + testargs = ['tethys', 'syncstores', '-r', '-f', '-d', 'foo_db', '-m', '/foo/bar/manage.py', 'foo_app1'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_syncstores_command.assert_called() + call_args = mock_syncstores_command.call_args_list + self.assertEqual(['foo_app1'], call_args[0][0][0].app) + self.assertEqual('foo_db', call_args[0][0][0].database) + self.assertEqual(False, call_args[0][0][0].firstime) + self.assertEqual(True, call_args[0][0][0].firsttime) + self.assertEqual('/foo/bar/manage.py', call_args[0][0][0].manage) + self.assertEqual(True, call_args[0][0][0].refresh) + + @mock.patch('tethys_apps.cli.syc') + def test_syncstores_command_verbose_options(self, mock_syncstores_command): + testargs = ['tethys', 'syncstores', '--refresh', '--firsttime', '--database', 'foo_db', + '--manage', '/foo/bar/manage.py', 'foo_app1'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_syncstores_command.assert_called() + call_args = mock_syncstores_command.call_args_list + self.assertEqual(['foo_app1'], call_args[0][0][0].app) + self.assertEqual('foo_db', call_args[0][0][0].database) + self.assertEqual(False, call_args[0][0][0].firstime) + self.assertEqual(True, call_args[0][0][0].firsttime) + self.assertEqual('/foo/bar/manage.py', call_args[0][0][0].manage) + self.assertEqual(True, call_args[0][0][0].refresh) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.syc') + def test_syncstores_command_help(self, mock_syncstores_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'syncstores', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_syncstores_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('app', mock_stdout.getvalue()) + self.assertIn('--refresh', mock_stdout.getvalue()) + self.assertIn('--firsttime', mock_stdout.getvalue()) + self.assertIn('--database', mock_stdout.getvalue()) + self.assertIn('--manage', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.cli.docker_command') + def test_docker_command(self, mock_docker_command): + testargs = ['tethys', 'docker', 'init'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_docker_command.assert_called() + call_args = mock_docker_command.call_args_list + self.assertEqual(False, call_args[0][0][0].boot2docker) + self.assertEqual('init', call_args[0][0][0].command) + self.assertEqual(None, call_args[0][0][0].containers) + self.assertEqual(False, call_args[0][0][0].defaults) + + @mock.patch('tethys_apps.cli.docker_command') + def test_docker_command_options(self, mock_docker_command): + testargs = ['tethys', 'docker', 'start', '-c', 'postgis', 'geoserver'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_docker_command.assert_called() + call_args = mock_docker_command.call_args_list + self.assertEqual(False, call_args[0][0][0].boot2docker) + self.assertEqual('start', call_args[0][0][0].command) + self.assertEqual(['postgis', 'geoserver'], call_args[0][0][0].containers) + self.assertEqual(False, call_args[0][0][0].defaults) + + @mock.patch('tethys_apps.cli.docker_command') + def test_docker_command_verbose_options(self, mock_docker_command): + testargs = ['tethys', 'docker', 'stop', '--defaults', '--boot2docker'] + + with mock.patch.object(sys, 'argv', testargs): + tethys_command() + + mock_docker_command.assert_called() + call_args = mock_docker_command.call_args_list + self.assertEqual(True, call_args[0][0][0].boot2docker) + self.assertEqual('stop', call_args[0][0][0].command) + self.assertEqual(None, call_args[0][0][0].containers) + self.assertEqual(True, call_args[0][0][0].defaults) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + @mock.patch('tethys_apps.cli.docker_command') + def test_docker_command_help(self, mock_docker_command, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', 'docker', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_docker_command.assert_not_called() + mock_exit.assert_called_with(0) + + self.assertIn('--help', mock_stdout.getvalue()) + self.assertIn('--defaults', mock_stdout.getvalue()) + self.assertIn('--containers', mock_stdout.getvalue()) + self.assertIn('--boot2docker', mock_stdout.getvalue()) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_app_settings_command.py b/tests/unit_tests/test_tethys_apps/test_cli/test_app_settings_command.py new file mode 100644 index 000000000..199eba86a --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_app_settings_command.py @@ -0,0 +1,246 @@ +import unittest +import mock +from django.core.exceptions import ObjectDoesNotExist +import tethys_apps.cli.app_settings_commands as cli_app_settings_command + + +class TestCliAppSettingsCommand(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.models.TethysApp') + @mock.patch('tethys_apps.models.PersistentStoreConnectionSetting') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting') + @mock.patch('tethys_apps.models.SpatialDatasetServiceSetting') + @mock.patch('tethys_apps.cli.app_settings_commands.pretty_output') + def test_app_settings_list_command(self, mock_pretty_output, MockSdss, MockPsds, MockPscs, MockTethysApp): + # mock the args + mock_arg = mock.MagicMock(app='foo') + + # mock the PersistentStoreConnectionSetting filter return value + MockPscs.objects.filter.return_value = [mock.MagicMock()] + # mock the PersistentStoreDatabaseSetting filter return value + MockPsds.objects.filter.return_value = [mock.MagicMock()] + # mock the SpatialDatasetServiceSetting filter return value + MockSdss.objects.filter.return_value = [mock.MagicMock()] + + cli_app_settings_command.app_settings_list_command(mock_arg) + + MockTethysApp.objects.get(package='foo').return_value = mock_arg.app + + # check TethysApp.object.get method is called with app + MockTethysApp.objects.get.assert_called_with(package='foo') + + # get the app name from mock_ta + app = MockTethysApp.objects.get() + + # check PersistentStoreConnectionSetting.objects.filter method is called with 'app' + MockPscs.objects.filter.assert_called_with(tethys_app=app) + # check PersistentStoreDatabaseSetting.objects.filter method is called with 'app' + MockPsds.objects.filter.assert_called_with(tethys_app=app) + # check SpatialDatasetServiceSetting.objects.filter is called with 'app' + MockSdss.objects.filter.assert_called_with(tethys_app=app) + + # get the called arguments from the mock print + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertIn('Unlinked Settings:', po_call_args[0][0][0]) + self.assertIn('None', po_call_args[1][0][0]) + self.assertIn('Linked Settings:', po_call_args[2][0][0]) + self.assertIn('Name', po_call_args[3][0][0]) + + @mock.patch('tethys_apps.models.TethysApp') + @mock.patch('tethys_apps.models.PersistentStoreConnectionSetting') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting') + @mock.patch('tethys_apps.models.SpatialDatasetServiceSetting') + @mock.patch('tethys_apps.cli.app_settings_commands.pretty_output') + @mock.patch('tethys_apps.cli.app_settings_commands.type') + def test_app_settings_list_command_unlink_settings(self, mock_type, mock_pretty_output, MockSdss, MockPsds, + MockPscs, MockTethysApp): + # mock the args + mock_arg = mock.MagicMock(app='foo') + + # mock the PersistentStoreConnectionSetting filter return value + pscs = MockPscs() + pscs.name = 'n001' + pscs.pk = 'p001' + pscs.persistent_store_service = '' + del pscs.spatial_dataset_service + MockPscs.objects.filter.return_value = [pscs] + + # mock the PersistentStoreDatabaseSetting filter return value + psds = MockPsds() + psds.name = 'n002' + psds.pk = 'p002' + psds.persistent_store_service = '' + del psds.spatial_dataset_service + MockPsds.objects.filter.return_value = [psds] + + # mock the Spatial Dataset ServiceSetting filter return value + sdss = MockSdss() + sdss.name = 'n003' + sdss.pk = 'p003' + sdss.spatial_dataset_service = '' + del sdss.persistent_store_service + MockSdss.objects.filter.return_value = [sdss] + + MockTethysApp.objects.get(package='foo').return_value = mock_arg.app + + def mock_type_func(obj): + if obj is pscs: + return MockPscs + elif obj is psds: + return MockPsds + elif obj is sdss: + return MockSdss + + mock_type.side_effect = mock_type_func + + cli_app_settings_command.app_settings_list_command(mock_arg) + + # check TethysApp.object.get method is called with app + MockTethysApp.objects.get.assert_called_with(package='foo') + + # get the app name from mock_ta + app = MockTethysApp.objects.get() + + # check PersistentStoreConnectionSetting.objects.filter method is called with 'app' + MockPscs.objects.filter.assert_called_with(tethys_app=app) + # check PersistentStoreDatabaseSetting.objects.filter method is called with 'app' + MockPsds.objects.filter.assert_called_with(tethys_app=app) + # check SpatialDatasetServiceSetting.objects.filter is called with 'app' + MockSdss.objects.filter.assert_called_with(tethys_app=app) + + # get the called arguments from the mock print + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertIn('Unlinked Settings:', po_call_args[0][0][0]) + self.assertIn('ID', po_call_args[1][0][0]) + self.assertIn('n001', po_call_args[2][0][0]) + self.assertIn('n002', po_call_args[3][0][0]) + self.assertIn('n003', po_call_args[4][0][0]) + self.assertIn('n003', po_call_args[4][0][0]) + self.assertIn('Linked Settings:', po_call_args[5][0][0]) + self.assertIn('None', po_call_args[6][0][0]) + + @mock.patch('tethys_apps.models.TethysApp') + @mock.patch('tethys_apps.cli.app_settings_commands.pretty_output') + def test_app_settings_list_command_object_does_not_exist(self, mock_pretty_output, MockTethysApp): + # mock the args + mock_arg = mock.MagicMock(app='foo') + MockTethysApp.objects.get.side_effect = ObjectDoesNotExist + + # raise ObjectDoesNotExist error + cli_app_settings_command.app_settings_list_command(mock_arg) + + # get the called arguments from the mock print + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertIn('The app you specified ("foo") does not exist. Command aborted.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.models.TethysApp') + @mock.patch('tethys_apps.cli.app_settings_commands.pretty_output') + def test_app_settings_list_command_object_exception(self, mock_pretty_output, MockTethysApp): + # mock the args + mock_arg = mock.MagicMock(app='foo') + + MockTethysApp.objects.get.side_effect = Exception + + # raise ObjectDoesNotExist error + cli_app_settings_command.app_settings_list_command(mock_arg) + + # get the called arguments from the mock print + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertIn('Something went wrong. Please try again', po_call_args[1][0][0]) + + # @mock.patch('tethys_apps.cli.app_settings_commands.create_ps_database_setting') + @mock.patch('tethys_apps.cli.app_settings_commands.exit') + @mock.patch('tethys_apps.utilities.create_ps_database_setting') + def test_app_settings_create_ps_database_command(self, mock_database_settings, mock_exit): + # mock the args + mock_arg = mock.MagicMock(app='foo') + mock_arg.name = 'arg_name' + mock_arg.description = 'mock_description' + mock_arg.required = True + mock_arg.initializer = '' + mock_arg.initialized = 'initialized' + mock_arg.spatial = 'spatial' + mock_arg.dynamic = 'dynamic' + + # mock the system exit + mock_exit.side_effect = SystemExit + + # raise the system exit call when database is created + self.assertRaises(SystemExit, cli_app_settings_command.app_settings_create_ps_database_command, mock_arg) + + # check the call arguments from mock_database + mock_database_settings.assert_called_with('foo', 'arg_name', 'mock_description', True, '', + 'initialized', 'spatial', 'dynamic') + # check the mock exit value + mock_exit.assert_called_with(0) + + @mock.patch('tethys_apps.cli.app_settings_commands.exit') + @mock.patch('tethys_apps.utilities.create_ps_database_setting') + def test_app_settings_create_ps_database_command_with_no_success(self, mock_database_settings, mock_exit): + + # mock the args + mock_arg = mock.MagicMock(app='foo') + mock_arg.name = None + mock_arg.description = 'mock_description' + mock_arg.required = True + mock_arg.initializer = '' + mock_arg.initialized = 'initialized' + mock_arg.spatial = 'spatial' + mock_arg.dynamic = 'dynamic' + + # mock the system exit + mock_exit.side_effect = SystemExit + + mock_database_settings.return_value = False + + # raise the system exit call when database is created + self.assertRaises(SystemExit, cli_app_settings_command.app_settings_create_ps_database_command, mock_arg) + + # check the mock exit value + mock_exit.assert_called_with(1) + + @mock.patch('tethys_apps.cli.app_settings_commands.exit') + @mock.patch('tethys_apps.utilities.remove_ps_database_setting') + def test_app_settings_remove_command(self, mock_database_settings, mock_exit): + # mock the args + mock_arg = mock.MagicMock(app='foo') + mock_arg.name = 'arg_name' + mock_arg.force = 'force' + + # mock the system exit + mock_exit.side_effect = SystemExit + + # raise the system exit call when database is created + self.assertRaises(SystemExit, cli_app_settings_command.app_settings_remove_command, mock_arg) + + # check the call arguments from mock_database + mock_database_settings.assert_called_with('foo', 'arg_name', 'force') + + # check the mock exit value + mock_exit.assert_called_with(0) + + @mock.patch('tethys_apps.cli.app_settings_commands.exit') + @mock.patch('tethys_apps.utilities.remove_ps_database_setting') + def test_app_settings_remove_command_with_no_success(self, mock_database_settings, mock_exit): + # mock the args + mock_arg = mock.MagicMock(app='foo') + mock_arg.name = 'arg_name' + mock_arg.force = 'force' + + # mock the system exit + mock_exit.side_effect = SystemExit + + mock_database_settings.return_value = False + + # raise the system exit call when database is created + self.assertRaises(SystemExit, cli_app_settings_command.app_settings_remove_command, mock_arg) + + # check the mock exit value + mock_exit.assert_called_with(1) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_cli_colors.py b/tests/unit_tests/test_tethys_apps/test_cli/test_cli_colors.py new file mode 100644 index 000000000..60c11f859 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_cli_colors.py @@ -0,0 +1,67 @@ +import unittest +import mock +from tethys_apps.cli.cli_colors import pretty_output, FG_RED, FG_BLUE, BOLD, FG_GREEN, BG_GREEN, END + + +class TestCliColors(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.cli.cli_colors.print') + def test_pretty_output_fg_red(self, mock_print): + act_msg = 'This is a test in RED' + expected_string = '\x1b[31mThis is a test in RED\x1b[0m' + with pretty_output(FG_RED) as p: + p.write(act_msg) + + mock_print.assert_called_with(expected_string) + + @mock.patch('tethys_apps.cli.cli_colors.print') + def test_pretty_output_fg_blue(self, mock_print): + act_msg = 'This is a test in BLUE' + expected_string = '\x1b[34mThis is a test in BLUE\x1b[0m' + with pretty_output(FG_BLUE) as p: + p.write(act_msg) + + mock_print.assert_called_with(expected_string) + + @mock.patch('tethys_apps.cli.cli_colors.print') + def test_pretty_output_bold_fg_green(self, mock_print): + act_msg = 'This is a bold text in green' + expected_string = '\x1b[1m\x1b[32mThis is a bold text in green\x1b[0m' + with pretty_output(BOLD, FG_GREEN) as p: + p.write(act_msg) + + mock_print.assert_called_with(expected_string) + + @mock.patch('tethys_apps.cli.cli_colors.print') + def test_pretty_output_bold_bg_green(self, mock_print): + act_msg = 'This is a text with green background' + expected_string = '\x1b[1m\x1b[42mThis is a text with green background\x1b[0m' + with pretty_output(BOLD, BG_GREEN) as p: + p.write(act_msg) + + mock_print.assert_called_with(expected_string) + + @mock.patch('tethys_apps.cli.cli_colors.print') + def test_pretty_output_fg_green(self, mock_print): + act_msg = 'This is a green text with ' + BOLD + 'bold' + END + ' text included' + expected_string = '\x1b[32mThis is a green text with \x1b[1mbold\x1b[0m\x1b[32m text included\x1b[0m' + with pretty_output(FG_GREEN) as p: + p.write(act_msg) + + mock_print.assert_called_with(expected_string) + + @mock.patch('tethys_apps.cli.cli_colors.print') + def test_pretty_output_empty_msg(self, mock_print): + in_msg = BOLD + 'Use this' + END + ' even with ' + BOLD + FG_RED + 'no parameters' + \ + END + ' in the with statement' + expected_string = '\x1b[1mUse this\x1b[0m even with \x1b[1m\x1b[31mno parameters\x1b' \ + '[0m in the with statement\x1b[0m' + with pretty_output() as p: + p.write(in_msg) + + mock_print.assert_called_with(expected_string) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_cli_helper.py b/tests/unit_tests/test_tethys_apps/test_cli/test_cli_helper.py new file mode 100644 index 000000000..d09ac462d --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_cli_helper.py @@ -0,0 +1,15 @@ +import unittest +import tethys_apps.cli.cli_helpers as cli_helper + + +class TestCliHelper(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_add_geoserver_rest_to_endpoint(self): + endpoint = "http://localhost:8181/geoserver/rest/" + ret = cli_helper.add_geoserver_rest_to_endpoint(endpoint) + self.assertEqual(endpoint, ret) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_docker_commands.py b/tests/unit_tests/test_tethys_apps/test_cli/test_docker_commands.py new file mode 100644 index 000000000..b081bd703 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_docker_commands.py @@ -0,0 +1,1342 @@ +import unittest +import mock +import tethys_apps.cli.docker_commands as cli_docker_commands + + +class TestDockerCommands(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_add_max_to_prompt(self): + self.assertEquals(' (max Foo)', cli_docker_commands.add_max_to_prompt('', 'Foo')) + + def test_add_default_to_prompt(self): + self.assertEquals(' [Foo]', cli_docker_commands.add_default_to_prompt('', 'Foo')) + + def test_add_default_to_prompt_with_choice(self): + self.assertEquals(' [Foo/bar]', cli_docker_commands.add_default_to_prompt('', 'Foo', choices=['Foo', 'Bar'])) + + def test_close_prompt(self): + self.assertEquals('Bar: ', cli_docker_commands.close_prompt('Bar')) + + def test_validate_numeric_cli_input_with_no_value(self): + self.assertEquals('12', cli_docker_commands.validate_numeric_cli_input('', default=12, max=100)) + + def test_validate_numeric_cli_input_with_value(self): + self.assertEquals(50, cli_docker_commands.validate_numeric_cli_input(50, default=12, max=100)) + + @mock.patch('tethys_apps.cli.docker_commands.input') + def test_validate_numeric_cli_input_with_value_gt_max(self, mock_input): + mock_input.side_effect = [55] + self.assertEquals(55, cli_docker_commands.validate_numeric_cli_input(200, default=12, max=100)) + + @mock.patch('tethys_apps.cli.docker_commands.input') + def test_validate_numeric_cli_input_with_value_gt_max_no_default(self, mock_input): + mock_input.side_effect = [66] + self.assertEquals(66, cli_docker_commands.validate_numeric_cli_input(200, max=100)) + + @mock.patch('tethys_apps.cli.docker_commands.input') + def test_validate_numeric_cli_input_value_error(self, mock_input): + mock_input.side_effect = [23] + self.assertEquals(23, cli_docker_commands.validate_numeric_cli_input(value='123ABC', default=12, max=100)) + + def test_validate_choice_cli_input_no_value(self): + self.assertEquals('Bar', cli_docker_commands.validate_choice_cli_input('', '', default='Bar')) + + @mock.patch('tethys_apps.cli.docker_commands.input') + def test_validate_choice_cli_input(self, mock_input): + mock_input.side_effect = ['Foo'] + self.assertEquals('Foo', cli_docker_commands.validate_choice_cli_input('Bell', choices=['foo', 'bar'], + default='bar')) + + def test_validate_directory_cli_input_no_value(self): + self.assertEquals('/tmp', cli_docker_commands.validate_directory_cli_input('', default='/tmp')) + + @mock.patch('tethys_apps.cli.docker_commands.os.path.isdir') + def test_validate_directory_cli_input_is_dir(self, mock_os_path_isdir): + mock_os_path_isdir.return_value = True + self.assertEquals('/c://temp//foo//bar', cli_docker_commands.validate_directory_cli_input('c://temp//foo//bar', + default='c://temp//')) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.input') + @mock.patch('tethys_apps.cli.docker_commands.os.makedirs') + @mock.patch('tethys_apps.cli.docker_commands.os.path.isdir') + def test_validate_directory_cli_input_oserror(self, mock_os_path_isdir, mock_os_makedirs, mock_input, + mock_pretty_output): + mock_os_path_isdir.side_effect = [False, True] + mock_os_makedirs.side_effect = OSError + mock_input.side_effect = ['/foo/tmp'] + self.assertEquals('/foo/tmp', cli_docker_commands.validate_directory_cli_input('c://temp//foo//bar', + default='c://temp//')) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('OSError(): /c://temp//foo//bar', po_call_args[0][0][0]) + + def test_get_api_version(self): + versions = ('1.2', '1.3') + self.assertEquals(versions, cli_docker_commands.get_api_version(versions)) + + @mock.patch('tethys_apps.cli.docker_commands.get_api_version') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_get_docker_client_linux(self, mock_DockerClient, mock_get_api_version): + ret = cli_docker_commands.get_docker_client() + mock_get_api_version.assert_called() + call_args = mock_DockerClient.call_args_list + + self.assertEqual(2, len(call_args)) + # Validate first call + first_call = call_args[0] + self.assertEqual('unix://var/run/docker.sock', first_call[1]['base_url']) + self.assertEqual('1.12', first_call[1]['version']) + + # Validate second call + second_call = call_args[1] + self.assertEqual('unix://var/run/docker.sock', second_call[1]['base_url']) + self.assertEqual(mock_get_api_version(), second_call[1]['version']) + + # Validate result + self.assertEqual(mock_DockerClient(), ret) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_api_version') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + @mock.patch('tethys_apps.cli.docker_commands.kwargs_from_env') + @mock.patch('tethys_apps.cli.docker_commands.os.environ') + @mock.patch('tethys_apps.cli.docker_commands.json.loads') + @mock.patch('tethys_apps.cli.docker_commands.subprocess') + def test_get_docker_client_mac_no_os_environ(self, mock_subprocess, mock_json_loads, mock_os_environ, + mock_kwargs, mock_docker_client, mock_get_api_version, + mock_pretty_output): + mock_p = mock.MagicMock() + mock_p.communicate.return_value = ['DOCKER_HOST=bar_host:9 DOCKER_CERT_PATH=baz_path DOCKER_TLS_VERIFY=qux_tls'] + mock_subprocess.Popen.return_value = mock_p + mock_json_loads.return_value = {"State": "foo", "DOCKER_HOST": "bar", "DOCKER_CERT_PATH": "baz", + "DOCKER_TLS_VERIFY": "qux"} + mock_os_environ.return_value = {} + mock_kwargs.return_value = {} + mock_version_client = mock.MagicMock() + mock_version_client.version.return_value = {'ApiVersion': 'quux'} + mock_docker_client.return_value = mock_version_client + mock_get_api_version.return_value = 'corge' + + ret = cli_docker_commands.get_docker_client() + + self.assertEquals('9', ret.host) + self.assertEquals(mock_docker_client(), ret) + mock_subprocess.Popen.assert_any_call(['boot2docker', 'info'], stdout=-1) + mock_subprocess.call.assert_called_once_with(['boot2docker', 'start']) + mock_subprocess.Popen.assert_called_with(['boot2docker', 'shellinit'], stdout=-1) + mock_json_loads.asssert_called_once() + mock_os_environ.__setitem__.assert_any_call('DOCKER_TLS_VERIFY', 'qux_tls') + mock_os_environ.__setitem__.assert_any_call('DOCKER_HOST', 'bar_host:9') + mock_os_environ.__setitem__.assert_any_call('DOCKER_CERT_PATH', 'baz_path') + mock_kwargs.assert_called_once_with(assert_hostname=False) + mock_docker_client.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('Starting Boot2Docker VM:', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_api_version') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + @mock.patch('tethys_apps.cli.docker_commands.kwargs_from_env') + @mock.patch('tethys_apps.cli.docker_commands.json.loads') + @mock.patch('tethys_apps.cli.docker_commands.subprocess') + def test_get_docker_client_mac_docker_host_env(self, mock_subprocess, mock_json_loads, mock_kwargs, + mock_docker_client, mock_get_api_version, + mock_pretty_output): + mock_p = mock.MagicMock() + mock_p.communicate.return_value = ['DOCKER_HOST=bar_host:5555 DOCKER_CERT_PATH=baz_path ' + 'DOCKER_TLS_VERIFY=qux_tls'] + mock_subprocess.Popen.return_value = mock_p + mock_json_loads.return_value = {"State": "foo", "DOCKER_HOST": "bar", "DOCKER_CERT_PATH": "baz", + "DOCKER_TLS_VERIFY": "qux"} + mock_kwargs.return_value = {} + mock_version_client = mock.MagicMock() + mock_version_client.version.return_value = {'ApiVersion': 'quux'} + mock_docker_client.return_value = mock_version_client + mock_get_api_version.return_value = 'corge' + + with mock.patch.dict('tethys_apps.cli.docker_commands.os.environ', {'DOCKER_HOST': 'foo=888:777', + 'DOCKER_CERT_PATH': 'bar', + 'DOCKER_TLS_VERIFY': 'baz'}, clear=True): + ret = cli_docker_commands.get_docker_client() + + self.assertEquals('777', ret.host) + self.assertEquals(mock_docker_client(), ret) + mock_subprocess.Popen.assert_called_once_with(['boot2docker', 'info'], stdout=-1) + mock_subprocess.call.assert_called_once_with(['boot2docker', 'start']) + mock_json_loads.asssert_called_once() + mock_kwargs.assert_called_once_with(assert_hostname=False) + mock_docker_client.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('Starting Boot2Docker VM:', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.subprocess.Popen') + def test_get_docker_client_other(self, mock_subprocess): + mock_subprocess.side_effect = Exception + self.assertRaises(Exception, cli_docker_commands.get_docker_client) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.subprocess') + def test_stop_boot2docker(self, mock_subprocess, mock_pretty_output): + cli_docker_commands.stop_boot2docker() + mock_subprocess.call.assert_called_with(['boot2docker', 'stop']) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('Boot2Docker VM Stopped', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.subprocess.call') + def test_stop_boot2docker_os_error(self, mock_subprocess): + mock_subprocess.side_effect = OSError + cli_docker_commands.stop_boot2docker() + mock_subprocess.assert_called_once_with(['boot2docker', 'stop']) + + @mock.patch('tethys_apps.cli.docker_commands.subprocess.call') + def test_stop_boot2docker_exception(self, mock_subprocess): + mock_subprocess.side_effect = Exception + self.assertRaises(Exception, cli_docker_commands.stop_boot2docker) + + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_get_images_to_install(self, mock_dc): + mock_dc.images.return_value = [{'RepoTags': 'ciwater/postgis:2.1.2'}, + {'RepoTags': 'ciwater/geoserver:2.8.2-clustered'}] + + # mock docker client return images + all_docker_input = ('postgis', 'geoserver', 'wps') + ret = cli_docker_commands.get_images_to_install(mock_dc, all_docker_input) + + self.assertEquals(1, len(ret)) + self.assertEquals('ciwater/n52wps:3.3.1', ret[0]) + + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_get_containers_to_create(self, mock_dc): + # mock_dc.containers.return_value = [{'Names': '/tethys_postgis'}, + # {'Names': '/tethys_geoserver'}] + mock_dc.containers.return_value = [{'Names': ['/tethys_postgis'], 'Image': '/tethys_postgis'}, + {'Names': ['/tethys_geoserver'], 'Image': '/tethys_geoserver'}] + all_container_input = ('postgis', 'geoserver', 'wps') + ret = cli_docker_commands.get_containers_to_create(mock_dc, all_container_input) + + self.assertEquals(1, len(ret)) + self.assertEquals('tethys_wps', ret[0]) + + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_get_docker_container_dicts(self, mock_dc): + mock_dc.containers.return_value = [{'Names': ['/tethys_postgis'], 'Image': '/tethys_postgis'}, + {'Names': ['/tethys_geoserver'], 'Image': '/tethys_geoserver'}, + {'Names': ['/tethys_wps'], 'Image': '/tethys_wps'}] + ret = cli_docker_commands.get_docker_container_dicts(mock_dc) + + self.assertEquals(3, len(ret)) + self.assertTrue('tethys_postgis' in ret) + self.assertTrue('tethys_geoserver' in ret) + self.assertTrue('tethys_wps' in ret) + + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_get_docker_container_image(self, mock_dc): + mock_dc.containers.return_value = [{'Names': ['/tethys_postgis'], 'Image': '/tethys_postgis'}, + {'Names': ['/tethys_geoserver'], 'Image': '/tethys_geoserver'}, + {'Names': ['/tethys_wps'], 'Image': '/tethys_wps'}] + ret = cli_docker_commands.get_docker_container_image(mock_dc) + + self.assertEquals(3, len(ret)) + self.assertTrue('tethys_postgis' in ret) + self.assertTrue('tethys_geoserver' in ret) + self.assertTrue('tethys_wps' in ret) + + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_get_docker_container_status(self, mock_dc): + mock_dc.containers.return_value = [{'Names': ['/tethys_postgis'], 'Image': '/tethys_postgis'}, + {'Names': ['/tethys_geoserver'], 'Image': '/tethys_geoserver'}, + {'Names': ['/tethys_wps'], 'Image': '/tethys_wps'}] + ret = cli_docker_commands.get_docker_container_status(mock_dc) + + self.assertEquals(3, len(ret)) + self.assertTrue('tethys_postgis' in ret) + self.assertTrue('tethys_geoserver' in ret) + self.assertTrue('tethys_wps' in ret) + self.assertTrue(ret['tethys_postgis']) + self.assertTrue(ret['tethys_geoserver']) + self.assertTrue(ret['tethys_wps']) + + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_get_docker_container_status_off(self, mock_dc): + mock_dc.containers.return_value = [{'Names': ['/tethys_postgis'], 'Image': '/tethys_postgis'}, + {'Names': ['/tethys_geoserver'], 'Image': '/tethys_geoserver'}] + ret = cli_docker_commands.get_docker_container_status(mock_dc) + + self.assertEquals(2, len(ret)) + self.assertTrue('tethys_postgis' in ret) + self.assertTrue('tethys_geoserver' in ret) + self.assertFalse('tethys_wps' in ret) + self.assertTrue(ret['tethys_postgis']) + self.assertTrue(ret['tethys_geoserver']) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_containers_to_create') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_remove_docker_containers(self, mock_dc, mock_create, mock_pretty_output): + mock_create.return_value = [{'Names': ['/tethys_postgis'], 'Image': '/tethys_postgis'}, + {'Names': ['/tethys_geoserver'], 'Image': '/tethys_geoserver'}, + {'Names': ['/tethys_wps'], 'Image': '/tethys_wps'}] + cli_docker_commands.remove_docker_containers(mock_dc) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('Removing PostGIS...', po_call_args[0][0][0]) + self.assertEquals('Removing GeoServer...', po_call_args[1][0][0]) + self.assertEquals('Removing 52 North WPS...', po_call_args[2][0][0]) + mock_dc.remove_container.assert_any_call(container='tethys_postgis') + mock_dc.remove_container.assert_any_call(container='tethys_geoserver', v=True) + mock_dc.remove_container.assert_called_with(container='tethys_wps') + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.install_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.log_pull_stream') + @mock.patch('tethys_apps.cli.docker_commands.get_images_to_install') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_init(self, mock_dc, mock_images, mock_stream, mock_containers, mock_pretty_output): + mock_dc.return_value = mock.MagicMock() + mock_images.return_value = ['foo_image', 'foo2'] + mock_stream.return_value = True + mock_containers.return_value = True + + cli_docker_commands.docker_init() + + mock_dc.assert_called_once() + mock_images.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps')) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('Pulling Docker images...', po_call_args[0][0][0]) + mock_stream.assert_called_with(mock_dc().pull()) + mock_containers.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps'), defaults=False) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.install_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.log_pull_stream') + @mock.patch('tethys_apps.cli.docker_commands.get_images_to_install') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_init_no_images(self, mock_dc, mock_images, mock_stream, mock_containers, mock_pretty_output): + mock_dc.return_value = mock.MagicMock() + mock_images.return_value = [] + mock_stream.return_value = True + mock_containers.return_value = True + + cli_docker_commands.docker_init() + + mock_dc.assert_called_once() + mock_images.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps')) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('Docker images already pulled.', po_call_args[0][0][0]) + mock_stream.assert_not_called() + mock_containers.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps'), defaults=False) + + @mock.patch('tethys_apps.cli.docker_commands.start_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_start(self, mock_dc, mock_start): + cli_docker_commands.docker_start(containers=('postgis', 'geoserver', 'wps')) + + mock_dc.assert_called_once() + mock_start.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps')) + + @mock.patch('tethys_apps.cli.docker_commands.stop_boot2docker') + @mock.patch('tethys_apps.cli.docker_commands.stop_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_stop(self, mock_dc, mock_stop, mock_boot): + cli_docker_commands.docker_stop(containers=('postgis', 'geoserver', 'wps')) + + mock_dc.assert_called_once() + mock_stop.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps')) + mock_boot.assert_not_called() + + @mock.patch('tethys_apps.cli.docker_commands.stop_boot2docker') + @mock.patch('tethys_apps.cli.docker_commands.stop_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_stop_boot2docker(self, mock_dc, mock_stop, mock_boot): + cli_docker_commands.docker_stop(containers='', boot2docker=True) + + mock_dc.assert_called_once() + mock_stop.assert_called_once_with(mock_dc(), containers='') + mock_boot.assert_called_once() + + @mock.patch('tethys_apps.cli.docker_commands.start_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.stop_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_restart(self, mock_dc, mock_stop, mock_start): + cli_docker_commands.docker_restart() + + mock_dc.assert_called_once() + mock_stop.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps')) + mock_start.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps')) + + @mock.patch('tethys_apps.cli.docker_commands.start_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.stop_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_restart_containers(self, mock_dc, mock_stop, mock_start): + cli_docker_commands.docker_restart(containers=('postgis', 'geoserver')) + + mock_dc.assert_called_once() + mock_stop.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver')) + mock_start.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver')) + + @mock.patch('tethys_apps.cli.docker_commands.remove_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.stop_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_remove(self, mock_dc, mock_stop, mock_remove): + cli_docker_commands.docker_remove() + + mock_dc.assert_called_once() + mock_stop.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps')) + mock_remove.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps')) + + @mock.patch('tethys_apps.cli.docker_commands.remove_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.stop_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_remove_containers(self, mock_dc, mock_stop, mock_remove): + cli_docker_commands.docker_remove(containers=('postgis', 'geoserver')) + + mock_dc.assert_called_once() + mock_stop.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver')) + mock_remove.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver')) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_status_container_running(self, mock_dc, mock_dc_status, mock_pretty_output): + mock_dc_status.return_value = {'tethys_postgis': True, 'tethys_geoserver': True, 'tethys_wps': True} + cli_docker_commands.docker_status() + mock_dc.assert_called_once() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('PostGIS/Database: Running', po_call_args[0][0][0]) + self.assertEquals('GeoServer: Running', po_call_args[1][0][0]) + self.assertEquals('52 North WPS: Running', po_call_args[2][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_status_container_stopped(self, mock_dc, mock_dc_status, mock_pretty_output): + mock_dc_status.return_value = {'tethys_postgis': False, 'tethys_geoserver': False, 'tethys_wps': False} + cli_docker_commands.docker_status() + mock_dc.assert_called_once() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('PostGIS/Database: Stopped', po_call_args[0][0][0]) + self.assertEquals('GeoServer: Stopped', po_call_args[1][0][0]) + self.assertEquals('52 North WPS: Stopped', po_call_args[2][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_status_container_not_installed(self, mock_dc, mock_dc_status, mock_pretty_output): + mock_dc_status.return_value = {} + cli_docker_commands.docker_status() + mock_dc.assert_called_once() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('PostGIS/Database: Not Installed', po_call_args[0][0][0]) + self.assertEquals('GeoServer: Not Installed', po_call_args[1][0][0]) + self.assertEquals('52 North WPS: Not Installed', po_call_args[2][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.install_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.log_pull_stream') + @mock.patch('tethys_apps.cli.docker_commands.remove_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.stop_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_update_with_no_container(self, mock_dc, mock_stop, mock_remove, mock_lps, mock_install): + cli_docker_commands.docker_update(containers=None, defaults=False) + + mock_dc.assert_called_once() + + mock_stop.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps')) + + mock_remove.assert_called_once_with(mock_dc(), containers=('postgis', 'geoserver', 'wps')) + + mock_lps.assert_any_call(mock_dc().pull()) + + mock_install.assert_called_once_with(mock_dc(), force=True, containers=('postgis', 'geoserver', 'wps'), + defaults=False) + + @mock.patch('tethys_apps.cli.docker_commands.install_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.log_pull_stream') + @mock.patch('tethys_apps.cli.docker_commands.remove_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.stop_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_update_with_container(self, mock_dc, mock_stop, mock_remove, mock_lps, mock_install): + + cli_docker_commands.docker_update(containers=[''], defaults=False) + + mock_dc.assert_called_once() + + mock_stop.assert_called_once_with(mock_dc(), containers=['']) + + mock_remove.assert_called_once_with(mock_dc(), containers=['']) + + mock_lps.assert_any_call(mock_dc().pull()) + + mock_install.assert_called_once_with(mock_dc(), force=True, containers=[''], defaults=False) + + @mock.patch('tethys_apps.cli.docker_commands.install_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.log_pull_stream') + @mock.patch('tethys_apps.cli.docker_commands.remove_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.stop_docker_containers') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_update_with_bad_container(self, mock_dc, mock_stop, mock_remove, mock_lps, mock_install): + cli_docker_commands.docker_update(containers=['foo'], defaults=False) + + mock_dc.assert_called_once() + + mock_stop.assert_called_once_with(mock_dc(), containers=['foo']) + + mock_remove.assert_called_once_with(mock_dc(), containers=['foo']) + + mock_lps.assert_not_called() + + mock_install.assert_called_once_with(mock_dc(), force=True, containers=['foo'], defaults=False) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_dicts') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_ip(self, mock_dc, mock_containers, mock_status, mock_pretty_output): + mock_dc().host = 'host' + + mock_containers.return_value = {'tethys_postgis': {'Ports': [{'PublicPort': 123}]}, + 'tethys_geoserver': {'Ports': [{'PublicPort': 234}]}, + 'tethys_wps': {'Ports': [{'PublicPort': 456}]}} + + mock_status.return_value = {'tethys_postgis': True, 'tethys_geoserver': True, 'tethys_wps': True} + + cli_docker_commands.docker_ip() + + mock_dc.assert_called() + + mock_containers.assert_called_once_with(mock_dc()) + + mock_status.assert_called_once_with(mock_dc()) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(11, len(po_call_args)) + self.assertIn('PostGIS/Database:', po_call_args[0][0][0]) + self.assertEquals(' Host: host', po_call_args[1][0][0]) + self.assertEquals(' Port: 123', po_call_args[2][0][0]) + + self.assertIn('GeoServer:', po_call_args[3][0][0]) + self.assertEquals(' Host: host', po_call_args[4][0][0]) + self.assertEquals(' Port: 234', po_call_args[5][0][0]) + self.assertEquals(' Endpoint: http://host:234/geoserver/rest', po_call_args[6][0][0]) + + self.assertIn('52 North WPS:', po_call_args[7][0][0]) + self.assertEquals(' Host: host', po_call_args[8][0][0]) + self.assertEquals(' Port: 456', po_call_args[9][0][0]) + self.assertEquals(' Endpoint: http://host:456/wps/WebProcessingService\n', po_call_args[10][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_dicts') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_ip_not_running(self, mock_dc, mock_containers, mock_status, mock_pretty_output): + mock_dc().host = 'host' + + mock_containers.return_value = {'tethys_postgis': {'Ports': [{'PublicPort': 123}]}, + 'tethys_geoserver': {'Ports': [{'PublicPort': 234}]}, + 'tethys_wps': {'Ports': [{'PublicPort': 456}]}} + + mock_status.return_value = {'tethys_postgis': False, 'tethys_geoserver': False, 'tethys_wps': False} + + cli_docker_commands.docker_ip() + + mock_dc.assert_called() + + mock_containers.assert_called_once_with(mock_dc()) + + mock_status.assert_called_once_with(mock_dc()) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('\nPostGIS/Database: Not Running.', po_call_args[0][0][0]) + self.assertEquals('\nGeoServer: Not Running.', po_call_args[1][0][0]) + self.assertEquals('\n52 North WPS: Not Running.', po_call_args[2][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_dicts') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_ip_not_installed(self, mock_dc, mock_containers, mock_status, mock_pretty_output): + mock_dc().host = 'host' + + mock_containers.return_value = {'foo': {'Ports': [{'PublicPort': 123}]}, + 'bar': {'Ports': [{'PublicPort': 234}]}, + 'baz': {'Ports': [{'PublicPort': 456}]}} + + mock_status.return_value = {'foo': True, 'bar': True, 'baz': True} + + cli_docker_commands.docker_ip() + + mock_dc.assert_called() + + mock_containers.assert_called_once_with(mock_dc()) + + mock_status.assert_called_once_with(mock_dc()) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('\nPostGIS/Database: Not Installed.', po_call_args[0][0][0]) + self.assertEquals('\nGeoServer: Not Installed.', po_call_args[1][0][0]) + self.assertEquals('\n52 North WPS: Not Installed.', po_call_args[2][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output.write') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_dicts') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_ip_exception_post_gis(self, mock_dc, mock_containers, mock_status, mock_pretty_output): + mock_pretty_output.side_effect = Exception + + mock_dc().host = 'host' + + mock_containers.return_value = {'tethys_postgis': {'Ports': [{'PublicPort': 123}]}, + 'tethys_geoserver': {'Ports': [{'PublicPort': 234}]}, + 'tethys_wps': {'Ports': [{'PublicPort': 456}]}} + + mock_status.return_value = {'tethys_postgis': True, 'tethys_geoserver': False, 'tethys_wps': False} + + self.assertRaises(Exception, cli_docker_commands.docker_ip) + + mock_dc.assert_called() + + mock_containers.assert_called_once_with(mock_dc()) + + mock_status.assert_called_once_with(mock_dc()) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output.write') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_dicts') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_ip_exception_geo_server(self, mock_dc, mock_containers, mock_status, mock_pretty_output): + mock_pretty_output.side_effect = [True, Exception] + + mock_dc().host = 'host' + + mock_containers.return_value = {'tethys_postgis': {'Ports': [{'PublicPort': 123}]}, + 'tethys_geoserver': {'Ports': [{'PublicPort': 234}]}, + 'tethys_wps': {'Ports': [{'PublicPort': 456}]}} + + mock_status.return_value = {'tethys_postgis': False, 'tethys_geoserver': True, 'tethys_wps': False} + + self.assertRaises(Exception, cli_docker_commands.docker_ip) + + mock_dc.assert_called() + + mock_containers.assert_called_once_with(mock_dc()) + + mock_status.assert_called_once_with(mock_dc()) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output.write') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_dicts') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_client') + def test_docker_ip_exception_wps(self, mock_dc, mock_containers, mock_status, mock_pretty_output): + mock_pretty_output.side_effect = [True, True, Exception] + mock_dc().host = 'host' + mock_containers.return_value = {'tethys_postgis': {'Ports': [{'PublicPort': 123}]}, + 'tethys_geoserver': {'Ports': [{'PublicPort': 234}]}, + 'tethys_wps': {'Ports': [{'PublicPort': 456}]}} + mock_status.return_value = {'tethys_postgis': False, 'tethys_geoserver': False, 'tethys_wps': True} + + self.assertRaises(Exception, cli_docker_commands.docker_ip) + + mock_dc.assert_called() + mock_containers.assert_called_once_with(mock_dc()) + mock_status.assert_called_once_with(mock_dc()) + + @mock.patch('tethys_apps.cli.docker_commands.docker_init') + def test_docker_command_init(self, mock_function): + mock_args = mock.MagicMock() + mock_args.command = 'init' + mock_args.containers = 'containers' + mock_args.defaults = True + mock_args.boot2docker = True + + cli_docker_commands.docker_command(mock_args) + + mock_function.assert_called_once_with(containers='containers', defaults=True) + + @mock.patch('tethys_apps.cli.docker_commands.docker_start') + def test_docker_command_start(self, mock_function): + mock_args = mock.MagicMock() + mock_args.command = 'start' + mock_args.containers = 'containers' + mock_args.defaults = True + mock_args.boot2docker = True + + cli_docker_commands.docker_command(mock_args) + + mock_function.assert_called_once_with(containers='containers') + + @mock.patch('tethys_apps.cli.docker_commands.docker_stop') + def test_docker_command_stop(self, mock_function): + mock_args = mock.MagicMock() + mock_args.command = 'stop' + mock_args.containers = 'containers' + mock_args.defaults = True + mock_args.boot2docker = True + + cli_docker_commands.docker_command(mock_args) + + mock_function.assert_called_once_with(containers='containers', boot2docker=True) + + @mock.patch('tethys_apps.cli.docker_commands.docker_status') + def test_docker_command_status(self, mock_function): + mock_args = mock.MagicMock() + mock_args.command = 'status' + mock_args.containers = 'containers' + mock_args.defaults = True + mock_args.boot2docker = True + + cli_docker_commands.docker_command(mock_args) + + mock_function.assert_called_once_with() + + @mock.patch('tethys_apps.cli.docker_commands.docker_update') + def test_docker_command_update(self, mock_function): + mock_args = mock.MagicMock() + mock_args.command = 'update' + mock_args.containers = 'containers' + mock_args.defaults = True + mock_args.boot2docker = True + + cli_docker_commands.docker_command(mock_args) + + mock_function.assert_called_once_with(containers='containers', defaults=True) + + @mock.patch('tethys_apps.cli.docker_commands.docker_remove') + def test_docker_command_remove(self, mock_function): + mock_args = mock.MagicMock() + mock_args.command = 'remove' + mock_args.containers = 'containers' + mock_args.defaults = True + mock_args.boot2docker = True + + cli_docker_commands.docker_command(mock_args) + + mock_function.assert_called_once_with(containers='containers') + + @mock.patch('tethys_apps.cli.docker_commands.docker_ip') + def test_docker_command_ip(self, mock_function): + mock_args = mock.MagicMock() + mock_args.command = 'ip' + mock_args.containers = 'containers' + mock_args.defaults = True + mock_args.boot2docker = True + + cli_docker_commands.docker_command(mock_args) + + mock_function.assert_called_once_with() + + @mock.patch('tethys_apps.cli.docker_commands.docker_restart') + def test_docker_command_restart(self, mock_function): + mock_args = mock.MagicMock() + mock_args.command = 'restart' + mock_args.containers = 'containers' + mock_args.defaults = True + mock_args.boot2docker = True + + cli_docker_commands.docker_command(mock_args) + + mock_function.assert_called_once_with(containers='containers') + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.getpass.getpass') + @mock.patch('tethys_apps.cli.docker_commands.get_containers_to_create') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_install_docker_containers_postgis_password_given(self, mock_dc, mock_containers_to_create, + mock_getpass, mock_pretty_output): + mock_containers_to_create.return_value = ['tethys_postgis'] + mock_getpass.side_effect = ['pass', # tethys_default password + 'foo', # tethys_default password not matching + 'pass', # tethys_default password redo + 'pass', # tethys_default password redo match + 'passmgr', # tethys_db_manager password + 'foo', # tethys_db_manager password not matching + 'passmgr', # tethys_db_manager password redo + 'passmgr', # tethys_db_manager password redo matching + 'passsuper', # tethys_super password + 'foo', # tethys_super password not matching + 'passsuper', # tethys_super password redo + 'passsuper' # tethys_super password redo matching + ] + cli_docker_commands.install_docker_containers(mock_dc, force=False, defaults=False) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(6, len(po_call_args)) + self.assertEquals('\nInstalling the PostGIS Docker container...', po_call_args[0][0][0]) + self.assertIn('Provide passwords for the three Tethys database users or press enter', po_call_args[1][0][0]) + self.assertEquals('Passwords do not match, please try again: ', po_call_args[2][0][0]) + self.assertEquals('Passwords do not match, please try again: ', po_call_args[3][0][0]) + self.assertEquals('Passwords do not match, please try again: ', po_call_args[4][0][0]) + self.assertEquals('Finished installing Docker containers.', po_call_args[5][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.getpass.getpass') + @mock.patch('tethys_apps.cli.docker_commands.get_containers_to_create') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_install_docker_containers_postgis_password_default(self, mock_dc, mock_containers_to_create, + mock_getpass, mock_pretty_output): + mock_containers_to_create.return_value = ['tethys_postgis'] + mock_getpass.side_effect = ['', # tethys_default password + '', # tethys_db_manager password + '', # tethys_super password + ] + cli_docker_commands.install_docker_containers(mock_dc, force=False, defaults=False) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('\nInstalling the PostGIS Docker container...', po_call_args[0][0][0]) + self.assertIn('Provide passwords for the three Tethys database users or press enter', po_call_args[1][0][0]) + self.assertEquals('Finished installing Docker containers.', po_call_args[2][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.input') + @mock.patch('tethys_apps.cli.docker_commands.create_host_config') + @mock.patch('tethys_apps.cli.docker_commands.get_containers_to_create') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_install_docker_containers_geoserver_numprocessors_bind(self, mock_dc, mock_containers_to_create, + mock_create_host_config, mock_input, + mock_pretty_output): + mock_containers_to_create.return_value = ['tethys_geoserver'] + mock_input.side_effect = ['1', # Number of GeoServer Instances Enabled + '1', # Number of GeoServer Instances with REST API Enabled + 'c', # Would you like to specify number of Processors (c) OR set limits (e) + '2', # Number of Processors + '60', # Maximum request timeout in seconds + '1024', # Maximum memory to allocate to each GeoServer instance in MB + '0', # Minimum memory to allocate to each GeoServer instance in MB + 'y', # Bind the GeoServer data directory to the host? + '/tmp' # Specify location to bind data directory + ] + + cli_docker_commands.install_docker_containers(mock_dc, force=False, defaults=False) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(4, len(po_call_args)) + self.assertEquals('\nInstalling the GeoServer Docker container...', po_call_args[0][0][0]) + self.assertIn('The GeoServer docker can be configured to run in a clustered mode', po_call_args[1][0][0]) + self.assertIn('GeoServer can be configured with limits to certain types of requests', po_call_args[2][0][0]) + self.assertIn('Finished installing Docker containers', po_call_args[3][0][0]) + mock_create_host_config.assert_called() + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.input') + @mock.patch('tethys_apps.cli.docker_commands.create_host_config') + @mock.patch('tethys_apps.cli.docker_commands.get_containers_to_create') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_install_docker_containers_geoserver_limits_no_bind(self, mock_dc, mock_containers_to_create, + mock_create_host_config, mock_input, + mock_pretty_output): + mock_containers_to_create.return_value = ['tethys_geoserver'] + mock_input.side_effect = ['1', # Number of GeoServer Instances Enabled + '1', # Number of GeoServer Instances with REST API Enabled + 'e', # Would you like to specify number of Processors (c) OR set limits (e) + '100', # Maximum number of simultaneous OGC web service requests + '8', # Maximum number of simultaneous GetMap requests + '16', # Maximum number of simultaneous GeoWebCache tile renders + '60', # Maximum request timeout in seconds + '1024', # Maximum memory to allocate to each GeoServer instance in MB + '0', # Minimum memory to allocate to each GeoServer instance in MB + 'n', # Bind the GeoServer data directory to the host? + ] + + cli_docker_commands.install_docker_containers(mock_dc, force=False, defaults=False) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(4, len(po_call_args)) + self.assertEquals('\nInstalling the GeoServer Docker container...', po_call_args[0][0][0]) + self.assertIn('The GeoServer docker can be configured to run in a clustered mode', po_call_args[1][0][0]) + self.assertIn('GeoServer can be configured with limits to certain types of requests', po_call_args[2][0][0]) + self.assertIn('Finished installing Docker containers', po_call_args[3][0][0]) + mock_create_host_config.assert_not_called() + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.create_host_config') + @mock.patch('tethys_apps.cli.docker_commands.get_containers_to_create') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_install_docker_containers_geoserver_defaults(self, mock_dc, mock_containers_to_create, + mock_create_host_config, mock_pretty_output): + mock_containers_to_create.return_value = ['tethys_geoserver'] + + cli_docker_commands.install_docker_containers(mock_dc, force=False, defaults=True) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(2, len(po_call_args)) + self.assertEquals('\nInstalling the GeoServer Docker container...', po_call_args[0][0][0]) + self.assertIn('Finished installing Docker containers', po_call_args[1][0][0]) + mock_create_host_config.assert_called_once_with(binds=['/usr/lib/tethys/geoserver/data:/var/geoserver/data']) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.GEOSERVER_IMAGE') + @mock.patch('tethys_apps.cli.docker_commands.create_host_config') + @mock.patch('tethys_apps.cli.docker_commands.get_containers_to_create') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_install_docker_containers_geoserver_no_cluster(self, mock_dc, mock_containers_to_create, + mock_create_host_config, mock_image, mock_pretty_output): + mock_containers_to_create.return_value = ['tethys_geoserver'] + mock_image.return_value = '' + + cli_docker_commands.install_docker_containers(mock_dc, force=False, defaults=True) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(2, len(po_call_args)) + self.assertEquals('\nInstalling the GeoServer Docker container...', po_call_args[0][0][0]) + self.assertIn('Finished installing Docker containers', po_call_args[1][0][0]) + mock_create_host_config.assert_not_called() + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.getpass.getpass') + @mock.patch('tethys_apps.cli.docker_commands.input') + @mock.patch('tethys_apps.cli.docker_commands.get_containers_to_create') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_install_docker_containers_wps_no_defaults_password(self, mock_dc, mock_containers_to_create, mock_input, + mock_getpass, mock_pretty_output): + mock_containers_to_create.return_value = ['tethys_wps'] + mock_input.side_effect = ['', # Name + '', # Position + '', # Address + '', # City + '', # State + '', # Country + '', # Postal Code + '', # Email + '', # Phone + '', # Fax + ''] # Admin username + mock_getpass.side_effect = ['wps', # Admin Password + 'foo', # Admin Password no match + 'wps', # Admin Password redo + 'wps'] # Admin Password match + + cli_docker_commands.install_docker_containers(mock_dc, force=False, defaults=False) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(4, len(po_call_args)) + self.assertEquals('\nInstalling the 52 North WPS Docker container...', po_call_args[0][0][0]) + self.assertIn('Provide contact information for the 52 North Web Processing Service', po_call_args[1][0][0]) + self.assertEquals('Passwords do not match, please try again.', po_call_args[2][0][0]) + self.assertIn('Finished installing Docker containers', po_call_args[3][0][0]) + mock_dc.create_container.assert_called_once_with(name='tethys_wps', + image='ciwater/n52wps:3.3.1', + environment={'NAME': 'NONE', + 'POSITION': 'NONE', + 'ADDRESS': 'NONE', + 'CITY': 'NONE', + 'STATE': 'NONE', + 'COUNTRY': 'NONE', + 'POSTAL_CODE': 'NONE', + 'EMAIL': 'NONE', + 'PHONE': 'NONE', + 'FAX': 'NONE', + 'USERNAME': 'wps', + 'PASSWORD': 'wps'}) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.getpass.getpass') + @mock.patch('tethys_apps.cli.docker_commands.input') + @mock.patch('tethys_apps.cli.docker_commands.get_containers_to_create') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_install_docker_containers_wps_no_empty_defaults_blank_password(self, mock_dc, mock_containers_to_create, + mock_input, mock_getpass, + mock_pretty_output): + mock_containers_to_create.return_value = ['tethys_wps'] + mock_input.side_effect = ['Name', # Name + 'Pos', # Position + 'Addr', # Address + 'City', # City + 'State', # State + 'Cty', # Country + 'Code', # Postal Code + 'foo@foo.com', # Email + '123456789', # Phone + '123456788', # Fax + 'fooadmin'] # Admin username + mock_getpass.side_effect = [''] # Admin Password + + cli_docker_commands.install_docker_containers(mock_dc, force=False, defaults=False) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('\nInstalling the 52 North WPS Docker container...', po_call_args[0][0][0]) + self.assertIn('Provide contact information for the 52 North Web Processing Service', po_call_args[1][0][0]) + self.assertIn('Finished installing Docker containers', po_call_args[2][0][0]) + mock_dc.create_container.assert_called_once_with(name='tethys_wps', + image='ciwater/n52wps:3.3.1', + environment={'NAME': 'Name', + 'POSITION': 'Pos', + 'ADDRESS': 'Addr', + 'CITY': 'City', + 'STATE': 'State', + 'COUNTRY': 'Cty', + 'POSTAL_CODE': 'Code', + 'EMAIL': 'foo@foo.com', + 'PHONE': '123456789', + 'FAX': '123456788', + 'USERNAME': 'fooadmin', + 'PASSWORD': 'wps'}) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.getpass.getpass') + @mock.patch('tethys_apps.cli.docker_commands.input') + @mock.patch('tethys_apps.cli.docker_commands.get_containers_to_create') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_install_docker_containers_wps_defaults_password(self, mock_dc, mock_containers_to_create, mock_input, + mock_getpass, mock_pretty_output): + mock_containers_to_create.return_value = ['tethys_wps'] + + cli_docker_commands.install_docker_containers(mock_dc, force=False, defaults=True) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(2, len(po_call_args)) + self.assertEquals('\nInstalling the 52 North WPS Docker container...', po_call_args[0][0][0]) + self.assertIn('Finished installing Docker containers', po_call_args[1][0][0]) + mock_input.assert_not_called() + mock_getpass.assert_not_called() + mock_dc.create_container.assert_called_once_with(name='tethys_wps', + image='ciwater/n52wps:3.3.1', + environment={'NAME': 'NONE', + 'POSITION': 'NONE', + 'ADDRESS': 'NONE', + 'CITY': 'NONE', + 'STATE': 'NONE', + 'COUNTRY': 'NONE', + 'POSTAL_CODE': 'NONE', + 'EMAIL': 'NONE', + 'PHONE': 'NONE', + 'FAX': 'NONE', + 'USERNAME': 'wps', + 'PASSWORD': 'wps'}) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_image') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_start_docker_containers_already_running(self, mock_dc, mock_dc_image, mock_dc_status, mock_pretty_output): + mock_dc_image.return_value = [{'Names': ['/tethys_postgis'], 'Image': '/tethys_postgis'}, + {'Names': ['/tethys_geoserver'], 'Image': '/tethys_geoserver'}, + {'Names': ['/tethys_wps'], 'Image': '/tethys_wps'}] + mock_dc_status.return_value = {'tethys_postgis': True, 'tethys_geoserver': True, 'tethys_wps': True} + + cli_docker_commands.start_docker_containers(mock_dc) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('PostGIS container already running...', po_call_args[0][0][0]) + self.assertEquals('GeoServer container already running...', po_call_args[1][0][0]) + self.assertEquals('52 North WPS container already running...', po_call_args[2][0][0]) + mock_dc_image.assert_called_once_with(mock_dc) + mock_dc_status.assert_called_with(mock_dc) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_image') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_start_docker_containers_starting_cluster(self, mock_dc, mock_dc_image, mock_dc_status, mock_pretty_output): + mock_dc_image.return_value = {'tethys_geoserver': 'cluster'} + mock_dc_status.return_value = {'tethys_postgis': False, 'tethys_geoserver': False, 'tethys_wps': False} + + cli_docker_commands.start_docker_containers(mock_dc) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('Starting PostGIS container...', po_call_args[0][0][0]) + self.assertEquals('Starting GeoServer container...', po_call_args[1][0][0]) + self.assertEquals('Starting 52 North WPS container...', po_call_args[2][0][0]) + mock_dc_image.assert_called_once_with(mock_dc) + mock_dc_status.assert_called_with(mock_dc) + mock_dc.start.assert_any_call(container='tethys_postgis', port_bindings={5432: '5435'}) + mock_dc.start.assert_any_call(container='tethys_geoserver', port_bindings={8181: '8181', + 8081: ('0.0.0.0', 8081), + 8082: ('0.0.0.0', 8082), + 8083: ('0.0.0.0', 8083), + 8084: ('0.0.0.0', 8084)}) + mock_dc.start.assert_any_call(container='tethys_wps', port_bindings={8080: '8282'}) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_image') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_start_docker_containers_starting_no_cluster(self, mock_dc, mock_dc_image, mock_dc_status, + mock_pretty_output): + mock_dc_image.return_value = {'tethys_geoserver': 'foo'} + mock_dc_status.return_value = {'tethys_postgis': False, 'tethys_geoserver': False, 'tethys_wps': False} + + cli_docker_commands.start_docker_containers(mock_dc) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('Starting PostGIS container...', po_call_args[0][0][0]) + self.assertEquals('Starting GeoServer container...', po_call_args[1][0][0]) + self.assertEquals('Starting 52 North WPS container...', po_call_args[2][0][0]) + mock_dc_image.assert_called_once_with(mock_dc) + mock_dc_status.assert_called_with(mock_dc) + mock_dc.start.assert_any_call(container='tethys_postgis', port_bindings={5432: '5435'}) + mock_dc.start.assert_any_call(container='tethys_geoserver', port_bindings={8080: '8181'}) + mock_dc.start.assert_any_call(container='tethys_wps', port_bindings={8080: '8282'}) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_image') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_start_docker_containers_notinstalled(self, mock_dc, mock_dc_image, mock_dc_status, + mock_pretty_output): + mock_dc_image.return_value = {'tethys_geoserver': 'foo'} + mock_dc_status.return_value = {} + + cli_docker_commands.start_docker_containers(mock_dc) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('PostGIS container not installed...', po_call_args[0][0][0]) + self.assertEquals('GeoServer container not installed...', po_call_args[1][0][0]) + self.assertEquals('52 North WPS container not installed...', po_call_args[2][0][0]) + mock_dc_image.assert_called_once_with(mock_dc) + mock_dc_status.assert_called_with(mock_dc) + mock_dc.start.assert_not_called() + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_image') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_start_docker_containers_postgis_exception(self, mock_dc, mock_dc_image, mock_dc_status, + mock_pretty_output): + mock_dc_image.return_value = {'tethys_geoserver': 'foo'} + mock_dc_status.return_value = {'tethys_postgis': False, 'tethys_geoserver': False, 'tethys_wps': False} + mock_dc.start.side_effect = Exception + + self.assertRaises(Exception, cli_docker_commands.start_docker_containers, mock_dc, + containers=['postgis']) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('Starting PostGIS container...', po_call_args[0][0][0]) + mock_dc_image.assert_called_once_with(mock_dc) + mock_dc_status.assert_called_with(mock_dc) + mock_dc.start.assert_called_once() + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_image') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_start_docker_containers_geoserver_exception(self, mock_dc, mock_dc_image, mock_dc_status, + mock_pretty_output): + mock_dc_image.return_value = {'tethys_geoserver': 'foo'} + mock_dc_status.return_value = {'tethys_postgis': False, 'tethys_geoserver': False, 'tethys_wps': False} + mock_dc.start.side_effect = Exception + + self.assertRaises(Exception, cli_docker_commands.start_docker_containers, mock_dc, + containers=['geoserver']) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('Starting GeoServer container...', po_call_args[0][0][0]) + mock_dc_image.assert_called_once_with(mock_dc) + mock_dc_status.assert_called_with(mock_dc) + mock_dc.start.assert_called_once() + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_image') + @mock.patch('tethys_apps.cli.docker_commands.DockerClient') + def test_start_docker_containers_wps_exception(self, mock_dc, mock_dc_image, mock_dc_status, mock_pretty_output): + mock_dc_image.return_value = {'tethys_geoserver': 'foo'} + mock_dc_status.return_value = {'tethys_postgis': False, 'tethys_geoserver': False, 'tethys_wps': False} + mock_dc.start.side_effect = Exception + + self.assertRaises(Exception, cli_docker_commands.start_docker_containers, mock_dc, containers=['wps']) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('Starting 52 North WPS container...', po_call_args[0][0][0]) + mock_dc_image.assert_called_once_with(mock_dc) + mock_dc_status.assert_called_with(mock_dc) + mock_dc.start.assert_called_once() + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + def test_stop_docker_containers_already_stopped(self, mock_dc_status, mock_pretty_output): + mock_docker_client = mock.MagicMock() + mock_dc_status.return_value = {'tethys_postgis': False, 'tethys_geoserver': False, 'tethys_wps': False} + + cli_docker_commands.stop_docker_containers(mock_docker_client) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('PostGIS container already stopped.', po_call_args[0][0][0]) + self.assertEquals('GeoServer container already stopped.', po_call_args[1][0][0]) + self.assertEquals('52 North WPS container already stopped.', po_call_args[2][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + def test_stop_docker_containers_stopping(self, mock_dc_status, mock_pretty_output): + mock_docker_client = mock.MagicMock() + mock_docker_client.stop.return_value = True + mock_dc_status.return_value = {'tethys_postgis': True, 'tethys_geoserver': True, 'tethys_wps': True} + + cli_docker_commands.stop_docker_containers(mock_docker_client) + + mock_docker_client.stop.assert_any_call(container='tethys_postgis') + mock_docker_client.stop.assert_any_call(container='tethys_geoserver') + mock_docker_client.stop.assert_any_call(container='tethys_wps') + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertEquals('Stopping PostGIS container...', po_call_args[0][0][0]) + self.assertEquals('Stopping GeoServer container...', po_call_args[1][0][0]) + self.assertEquals('Stopping 52 North WPS container...', po_call_args[2][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') + def test_stop_docker_containers_not_installed(self, mock_dc_status, mock_pretty_output): + mock_docker_client = mock.MagicMock() + mock_docker_client.stop.side_effect = KeyError + mock_dc_status.return_value = {'tethys_postgis': True, 'tethys_geoserver': True, 'tethys_wps': True} + + cli_docker_commands.stop_docker_containers(mock_docker_client) + + mock_docker_client.stop.assert_any_call(container='tethys_postgis') + mock_docker_client.stop.assert_any_call(container='tethys_geoserver') + mock_docker_client.stop.assert_any_call(container='tethys_wps') + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(6, len(po_call_args)) + self.assertEquals('Stopping PostGIS container...', po_call_args[0][0][0]) + self.assertEquals('PostGIS container not installed...', po_call_args[1][0][0]) + self.assertEquals('Stopping GeoServer container...', po_call_args[2][0][0]) + self.assertEquals('GeoServer container not installed...', po_call_args[3][0][0]) + self.assertEquals('Stopping 52 North WPS container...', po_call_args[4][0][0]) + self.assertEquals('52 North WPS container not installed...', po_call_args[5][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.curses') + @mock.patch('tethys_apps.cli.docker_commands.platform.system') + def test_log_pull_stream_linux_with_id_bad_status(self, mock_platform_system, mock_curses, mock_pretty_output): + mock_stream = ['{ "id":"358464", "status":"foo", "progress":"bar" }'] + mock_platform_system.return_value = 'Linux' + mock_curses.initscr().getmaxyx.return_value = 1, 80 + + cli_docker_commands.log_pull_stream(mock_stream) + + mock_curses.initscr().addstr.assert_any_call(0, 0, u'foo ' + u' ') + mock_curses.initscr().addstr.assert_called_with(1, 0, '--- ' + ' ') + mock_curses.initscr().refresh.assert_called_once() + mock_curses.noecho.assert_called_once() + mock_curses.cbreak.assert_called_once() + mock_curses.echo.assert_called_once() + mock_curses.nocbreak.assert_called_once() + mock_curses.endwin.assert_called_once() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.curses') + @mock.patch('tethys_apps.cli.docker_commands.platform.system') + def test_log_pull_stream_linux_with_id_progress_status(self, mock_platform_system, mock_curses, mock_pretty_output): + mock_stream = ['{ "id":"358464", "status":"Downloading", "progress":"bar" }'] + mock_platform_system.return_value = 'Linux' + mock_curses.initscr().getmaxyx.return_value = 1, 80 + + cli_docker_commands.log_pull_stream(mock_stream) + + mock_curses.initscr().addstr.assert_called_with(0, 0, '358464: Downloading bar ' + ' ') + mock_curses.initscr().refresh.assert_called_once() + mock_curses.noecho.assert_called_once() + mock_curses.cbreak.assert_called_once() + mock_curses.echo.assert_called_once() + mock_curses.nocbreak.assert_called_once() + mock_curses.endwin.assert_called_once() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.curses') + @mock.patch('tethys_apps.cli.docker_commands.platform.system') + def test_log_pull_stream_linux_with_id_status(self, mock_platform_system, mock_curses, mock_pretty_output): + mock_stream = ['{ "id":"358464", "status":"Downloading", "progress":"bar" }\r\n' + '{ "id":"358464", "status":"Pulling fs layer", "progress":"baz" }'] + mock_platform_system.return_value = 'Linux' + mock_curses.initscr().getmaxyx.return_value = 1, 80 + + cli_docker_commands.log_pull_stream(mock_stream) + + mock_curses.initscr().addstr.assert_called_with(0, 0, '358464: Downloading bar ' + ' ') + mock_curses.initscr().refresh.assert_called() + mock_curses.noecho.assert_called_once() + mock_curses.cbreak.assert_called_once() + mock_curses.echo.assert_called_once() + mock_curses.nocbreak.assert_called_once() + mock_curses.endwin.assert_called_once() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.curses') + @mock.patch('tethys_apps.cli.docker_commands.platform.system') + def test_log_pull_stream_linux_with_no_id(self, mock_platform_system, mock_curses, mock_pretty_output): + mock_stream = ['{ "status":"foo", "progress":"bar" }'] + mock_platform_system.return_value = 'Linux' + mock_curses.initscr().getmaxyx.return_value = 1, 80 + + cli_docker_commands.log_pull_stream(mock_stream) + + mock_curses.initscr().addstr.assert_not_called() + mock_curses.initscr().refresh.assert_not_called() + mock_curses.noecho.assert_called_once() + mock_curses.cbreak.assert_called_once() + mock_curses.echo.assert_called_once() + mock_curses.nocbreak.assert_called_once() + mock_curses.endwin.assert_called_once() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals(u'foo', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.pretty_output') + @mock.patch('tethys_apps.cli.docker_commands.platform.system') + def test_log_pull_stream_windows(self, mock_platform_system, mock_pretty_output): + mock_stream = ['{ "id":"358464", "status":"Downloading", "progress":"bar" }'] + mock_platform_system.return_value = 'Windows' + + cli_docker_commands.log_pull_stream(mock_stream) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEquals('358464:Downloading bar', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.docker_commands.input') + def test_validate_numeric_cli_input_second_empty_value(self, mock_input): + mock_input.side_effect = [''] + ret = cli_docker_commands.validate_numeric_cli_input(value='555', default=1, max=1) + self.assertEqual('1', ret) + + @mock.patch('tethys_apps.cli.docker_commands.input') + def test_validate_choice_cli_input_second_empty_value(self, mock_input): + mock_input.side_effect = [''] + ret = cli_docker_commands.validate_choice_cli_input(value='555', choices=['foo', 'bar'], default='foo') + self.assertEqual('foo', ret) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_gen_commands.py b/tests/unit_tests/test_tethys_apps/test_cli/test_gen_commands.py new file mode 100644 index 000000000..7551db80b --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_gen_commands.py @@ -0,0 +1,322 @@ +import unittest +import mock +import tethys_apps.cli.gen_commands +from tethys_apps.cli.gen_commands import get_environment_value, get_settings_value, generate_command +from tethys_apps.cli.gen_commands import GEN_SETTINGS_OPTION, GEN_NGINX_OPTION, GEN_UWSGI_SERVICE_OPTION,\ + GEN_UWSGI_SETTINGS_OPTION + +try: + reload +except NameError: # Python 3 + from imp import reload + + +class CLIGenCommandsTest(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_get_environment_value(self): + result = get_environment_value(value_name='DJANGO_SETTINGS_MODULE') + + self.assertEqual('tethys_portal.settings', result) + + def test_get_environment_value_bad(self): + self.assertRaises(EnvironmentError, get_environment_value, + value_name='foo_bar_baz_bad_environment_value_foo_bar_baz') + + def test_get_settings_value(self): + result = get_settings_value(value_name='INSTALLED_APPS') + + self.assertIn('tethys_apps', result) + + def test_get_settings_value_bad(self): + self.assertRaises(ValueError, get_settings_value, value_name='foo_bar_baz_bad_setting_foo_bar_baz') + + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_settings_option(self, mock_os_path_isfile, mock_file): + mock_args = mock.MagicMock() + mock_args.type = GEN_SETTINGS_OPTION + mock_args.directory = None + mock_os_path_isfile.return_value = False + + generate_command(args=mock_args) + + mock_os_path_isfile.assert_called_once() + mock_file.assert_called() + + @mock.patch('tethys_apps.cli.gen_commands.get_settings_value') + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_nginx_option(self, mock_os_path_isfile, mock_file, mock_settings): + mock_args = mock.MagicMock() + mock_args.type = GEN_NGINX_OPTION + mock_args.directory = None + mock_os_path_isfile.return_value = False + mock_settings.side_effect = ['/foo/workspace', '/foo/static'] + + generate_command(args=mock_args) + + mock_os_path_isfile.assert_called_once() + mock_file.assert_called() + mock_settings.assert_any_call('TETHYS_WORKSPACES_ROOT') + mock_settings.assert_called_with('STATIC_ROOT') + + @mock.patch('tethys_apps.cli.gen_commands.Context') + @mock.patch('tethys_apps.cli.gen_commands.linux_distribution') + @mock.patch('tethys_apps.cli.gen_commands.os.path.exists') + @mock.patch('tethys_apps.cli.gen_commands.get_environment_value') + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_uwsgi_service_option_nginx_conf_redhat(self, mock_os_path_isfile, mock_file, mock_env, + mock_os_path_exists, mock_linux_distribution, + mock_context): + mock_args = mock.MagicMock() + mock_args.type = GEN_UWSGI_SERVICE_OPTION + mock_args.directory = None + mock_os_path_isfile.return_value = False + mock_env.side_effect = ['/foo/conda', 'conda_env'] + mock_os_path_exists.return_value = True + mock_linux_distribution.return_value = ['redhat'] + # First open is for the Template, next two are for /etc/nginx/nginx.conf and /etc/passwd, and the final + # open is to "write" out the resulting file. The middle two opens return information about a user, while + # the first and last use MagicMock. + handlers = (mock_file.return_value, + mock.mock_open(read_data='user foo_user').return_value, + mock.mock_open(read_data='foo_user:x:1000:1000:Foo User,,,:/foo/nginx:/bin/bash').return_value, + mock_file.return_value) + mock_file.side_effect = handlers + + generate_command(args=mock_args) + + mock_os_path_isfile.assert_called_once() + mock_file.assert_called() + mock_env.assert_any_call('CONDA_HOME') + mock_env.assert_called_with('CONDA_ENV_NAME') + mock_os_path_exists.assert_called_once_with('/etc/nginx/nginx.conf') + context = mock_context().update.call_args_list[0][0][0] + self.assertEqual('http-', context['user_option_prefix']) + + @mock.patch('tethys_apps.cli.gen_commands.Context') + @mock.patch('tethys_apps.cli.gen_commands.linux_distribution') + @mock.patch('tethys_apps.cli.gen_commands.os.path.exists') + @mock.patch('tethys_apps.cli.gen_commands.get_environment_value') + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_uwsgi_service_option_nginx_conf_ubuntu(self, mock_os_path_isfile, mock_file, mock_env, + mock_os_path_exists, mock_linux_distribution, + mock_context): + mock_args = mock.MagicMock() + mock_args.type = GEN_UWSGI_SERVICE_OPTION + mock_args.directory = None + mock_os_path_isfile.return_value = False + mock_env.side_effect = ['/foo/conda', 'conda_env'] + mock_os_path_exists.return_value = True + mock_linux_distribution.return_value = 'ubuntu' + # First open is for the Template, next two are for /etc/nginx/nginx.conf and /etc/passwd, and the final + # open is to "write" out the resulting file. The middle two opens return information about a user, while + # the first and last use MagicMock. + handlers = (mock_file.return_value, + mock.mock_open(read_data='user foo_user').return_value, + mock.mock_open(read_data='foo_user:x:1000:1000:Foo User,,,:/foo/nginx:/bin/bash').return_value, + mock_file.return_value) + mock_file.side_effect = handlers + + generate_command(args=mock_args) + + mock_os_path_isfile.assert_called_once() + mock_file.assert_called() + mock_env.assert_any_call('CONDA_HOME') + mock_env.assert_called_with('CONDA_ENV_NAME') + mock_os_path_exists.assert_called_once_with('/etc/nginx/nginx.conf') + context = mock_context().update.call_args_list[0][0][0] + self.assertEqual('', context['user_option_prefix']) + + @mock.patch('tethys_apps.cli.gen_commands.Context') + @mock.patch('tethys_apps.cli.gen_commands.linux_distribution') + @mock.patch('tethys_apps.cli.gen_commands.os.path.exists') + @mock.patch('tethys_apps.cli.gen_commands.get_environment_value') + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_uwsgi_service_option_nginx_conf_not_linux(self, mock_os_path_isfile, mock_file, mock_env, + mock_os_path_exists, mock_linux_distribution, + mock_context): + mock_args = mock.MagicMock() + mock_args.type = GEN_UWSGI_SERVICE_OPTION + mock_args.directory = None + mock_os_path_isfile.return_value = False + mock_env.side_effect = ['/foo/conda', 'conda_env'] + mock_os_path_exists.return_value = True + mock_linux_distribution.side_effect = Exception + # First open is for the Template, next two are for /etc/nginx/nginx.conf and /etc/passwd, and the final + # open is to "write" out the resulting file. The middle two opens return information about a user, while + # the first and last use MagicMock. + handlers = (mock_file.return_value, + mock.mock_open(read_data='user foo_user').return_value, + mock.mock_open(read_data='foo_user:x:1000:1000:Foo User,,,:/foo/nginx:/bin/bash').return_value, + mock_file.return_value) + mock_file.side_effect = handlers + + generate_command(args=mock_args) + + mock_os_path_isfile.assert_called_once() + mock_file.assert_called() + mock_env.assert_any_call('CONDA_HOME') + mock_env.assert_called_with('CONDA_ENV_NAME') + mock_os_path_exists.assert_called_once_with('/etc/nginx/nginx.conf') + context = mock_context().update.call_args_list[0][0][0] + self.assertEqual('', context['user_option_prefix']) + + @mock.patch('tethys_apps.cli.gen_commands.get_environment_value') + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_uwsgi_service_option(self, mock_os_path_isfile, mock_file, mock_env): + mock_args = mock.MagicMock() + mock_args.type = GEN_UWSGI_SERVICE_OPTION + mock_args.directory = None + mock_os_path_isfile.return_value = False + mock_env.side_effect = ['/foo/conda', 'conda_env'] + + generate_command(args=mock_args) + + mock_os_path_isfile.assert_called_once() + mock_file.assert_called() + mock_env.assert_any_call('CONDA_HOME') + mock_env.assert_called_with('CONDA_ENV_NAME') + + @mock.patch('tethys_apps.cli.gen_commands.linux_distribution') + @mock.patch('tethys_apps.cli.gen_commands.get_environment_value') + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_uwsgi_service_option_distro(self, mock_os_path_isfile, mock_file, mock_env, + mock_distribution): + mock_args = mock.MagicMock() + mock_args.type = GEN_UWSGI_SERVICE_OPTION + mock_args.directory = None + mock_os_path_isfile.return_value = False + mock_env.side_effect = ['/foo/conda', 'conda_env'] + mock_distribution.return_value = ('redhat', 'linux', '') + + generate_command(args=mock_args) + + mock_os_path_isfile.assert_called_once() + mock_file.assert_called() + mock_env.assert_any_call('CONDA_HOME') + mock_env.assert_called_with('CONDA_ENV_NAME') + + @mock.patch('tethys_apps.cli.gen_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.gen_commands.get_environment_value') + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_uwsgi_settings_option_directory(self, mock_os_path_isfile, mock_file, mock_env, + mock_os_path_isdir): + mock_args = mock.MagicMock() + mock_args.type = GEN_UWSGI_SETTINGS_OPTION + mock_args.directory = '/foo/temp' + mock_os_path_isfile.return_value = False + mock_env.side_effect = ['/foo/conda', 'conda_env'] + mock_os_path_isdir.return_value = True + + generate_command(args=mock_args) + + mock_os_path_isfile.assert_called_once() + mock_file.assert_called() + mock_os_path_isdir.assert_called_once_with(mock_args.directory) + mock_env.assert_any_call('CONDA_HOME') + mock_env.assert_called_with('CONDA_ENV_NAME') + + @mock.patch('tethys_apps.cli.gen_commands.print') + @mock.patch('tethys_apps.cli.gen_commands.exit') + @mock.patch('tethys_apps.cli.gen_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.gen_commands.get_environment_value') + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_uwsgi_settings_option_bad_directory(self, mock_os_path_isfile, mock_file, mock_env, + mock_os_path_isdir, mock_exit, mock_print): + mock_args = mock.MagicMock() + mock_args.type = GEN_UWSGI_SETTINGS_OPTION + mock_args.directory = '/foo/temp' + mock_os_path_isfile.return_value = False + mock_env.side_effect = ['/foo/conda', 'conda_env'] + mock_os_path_isdir.return_value = False + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, generate_command, args=mock_args) + + mock_os_path_isfile.assert_not_called() + mock_file.assert_called_once() + mock_os_path_isdir.assert_called_once_with(mock_args.directory) + + # Check if print is called correctly + rts_call_args = mock_print.call_args_list + self.assertIn('ERROR: ', rts_call_args[0][0][0]) + self.assertIn('is not a valid directory', rts_call_args[0][0][0]) + + mock_env.assert_any_call('CONDA_HOME') + mock_env.assert_called_with('CONDA_ENV_NAME') + + @mock.patch('tethys_apps.cli.gen_commands.print') + @mock.patch('tethys_apps.cli.gen_commands.exit') + @mock.patch('tethys_apps.cli.gen_commands.input') + @mock.patch('tethys_apps.cli.gen_commands.get_environment_value') + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_uwsgi_settings_pre_existing_input_exit(self, mock_os_path_isfile, mock_file, mock_env, + mock_input, mock_exit, mock_print): + mock_args = mock.MagicMock() + mock_args.type = GEN_UWSGI_SETTINGS_OPTION + mock_args.directory = None + mock_args.overwrite = False + mock_os_path_isfile.return_value = True + mock_env.side_effect = ['/foo/conda', 'conda_env'] + mock_input.side_effect = ['foo', 'no'] + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, generate_command, args=mock_args) + + mock_os_path_isfile.assert_called_once() + mock_file.assert_called() + + # Check if print is called correctly + rts_call_args = mock_print.call_args_list + self.assertIn('Generation of', rts_call_args[0][0][0]) + self.assertIn('cancelled', rts_call_args[0][0][0]) + + mock_env.assert_any_call('CONDA_HOME') + mock_env.assert_called_with('CONDA_ENV_NAME') + + @mock.patch('tethys_apps.cli.gen_commands.get_environment_value') + @mock.patch('tethys_apps.cli.gen_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.gen_commands.os.path.isfile') + def test_generate_command_uwsgi_settings_pre_existing_overwrite(self, mock_os_path_isfile, mock_file, mock_env): + mock_args = mock.MagicMock() + mock_args.type = GEN_UWSGI_SETTINGS_OPTION + mock_args.directory = None + mock_args.overwrite = True + mock_os_path_isfile.return_value = True + mock_env.side_effect = ['/foo/conda', 'conda_env'] + + generate_command(args=mock_args) + + mock_os_path_isfile.assert_called_once() + mock_file.assert_called() + mock_env.assert_any_call('CONDA_HOME') + mock_env.assert_called_with('CONDA_ENV_NAME') + + @mock.patch('tethys_apps.cli.gen_commands.os.environ') + def test_django_settings_module_error(self, mock_environ): + mock_environ.side_effect = Exception + try: + reload(tethys_apps.cli.gen_commands) + except Exception: + pass + + self.assertTrue(tethys_apps.cli.gen_commands.settings.configured) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_link_commands.py b/tests/unit_tests/test_tethys_apps/test_cli/test_link_commands.py new file mode 100644 index 000000000..54ab68a9c --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_link_commands.py @@ -0,0 +1,74 @@ +import unittest +import mock +import tethys_apps.cli.link_commands as link_commands + + +class TestLinkCommands(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.cli.link_commands.exit') + @mock.patch('tethys_apps.cli.link_commands.link_service_to_app_setting') + @mock.patch('tethys_apps.cli.link_commands.pretty_output') + def test_link_commands(self, _, mock_link_app_setting, mock_exit): + args = mock.MagicMock(service='persistent_connection:super_conn', setting='epanet:database:epanet_2') + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, link_commands.link_command, args) + + mock_link_app_setting.assert_called_with('persistent_connection', 'super_conn', 'epanet', 'database', + 'epanet_2') + mock_exit.assert_called_with(0) + + @mock.patch('tethys_apps.cli.link_commands.exit') + @mock.patch('tethys_apps.cli.link_commands.link_service_to_app_setting') + @mock.patch('tethys_apps.cli.link_commands.pretty_output') + def test_link_commands_index_error(self, mock_pretty_output, mock_link_app_setting, mock_exit): + args = mock.MagicMock(service='con1', setting='db:database') + # NOTE: We have the mocked exit function raise a SystemExit exception to break the code + # execution like the original exit function would have done. + mock_exit.side_effect = SystemExit + + try: + self.assertRaises(IndexError, link_commands.link_command, args) + except SystemExit: + pass + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertIn("Incorrect argument format", po_call_args[0][0][0]) + mock_link_app_setting.assert_not_called() + mock_exit.assert_called_with(1) + + @mock.patch('tethys_apps.cli.link_commands.exit') + @mock.patch('tethys_apps.cli.link_commands.link_service_to_app_setting') + @mock.patch('tethys_apps.cli.link_commands.pretty_output') + def test_link_commands_with_exception(self, mock_pretty_output, mock_link_app_setting, mock_exit): + # NOTE: We have the mocked exit function raise a SystemExit exception to break the code + # execution like the original exit function would have done. + mock_link_app_setting = mock.MagicMock() + mock_link_app_setting.return_value = None + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, link_commands.link_command, None) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertIn("An unexpected error occurred. Please try again.", po_call_args[1][0][0]) + mock_exit.assert_called_with(1) + + @mock.patch('tethys_apps.cli.link_commands.exit') + @mock.patch('tethys_apps.cli.link_commands.link_service_to_app_setting', return_value=False) + @mock.patch('tethys_apps.cli.link_commands.pretty_output') + def test_link_commands_with_no_success(self, _, mock_link_app_setting, mock_exit): + # NOTE: We have the mocked exit function raise a SystemExit exception to break the code + # execution like the original exit function would have done. + args = mock.MagicMock(service='persistent_connection:super_conn', setting='epanet:database:epanet_2') + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, link_commands.link_command, args) + + mock_link_app_setting.assert_called_with('persistent_connection', 'super_conn', 'epanet', 'database', + 'epanet_2') + mock_exit.assert_called_with(1) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_list_commands.py b/tests/unit_tests/test_tethys_apps/test_cli/test_list_commands.py new file mode 100644 index 000000000..2252a2644 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_list_commands.py @@ -0,0 +1,82 @@ +import unittest +import mock + +from tethys_apps.cli.list_command import list_command +try: + from StringIO import StringIO +except ImportError: + from io import StringIO # noqa: F401 + + +class ListCommandTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.cli.list_command.print') + @mock.patch('tethys_apps.cli.list_command.get_installed_tethys_extensions') + @mock.patch('tethys_apps.cli.list_command.get_installed_tethys_apps') + def test_list_command_installed_apps(self, mock_installed_apps, mock_installed_extensions, mock_print): + mock_args = mock.MagicMock() + mock_installed_apps.return_value = {'foo': '/foo', 'bar': "/bar"} + mock_installed_extensions.return_value = {} + + list_command(mock_args) + + mock_installed_apps.assert_called_once() + + # Check if print is called correctly + rts_call_args = mock_print.call_args_list + + check_list = [] + for i in range(len(rts_call_args)): + check_list.append(rts_call_args[i][0][0]) + + self.assertIn('Apps:', check_list) + self.assertIn(' foo', check_list) + self.assertIn(' bar', check_list) + + @mock.patch('tethys_apps.cli.list_command.print') + @mock.patch('tethys_apps.cli.list_command.get_installed_tethys_extensions') + @mock.patch('tethys_apps.cli.list_command.get_installed_tethys_apps') + def test_list_command_installed_extensions(self, mock_installed_apps, mock_installed_extensions, mock_print): + mock_args = mock.MagicMock() + mock_installed_apps.return_value = {} + mock_installed_extensions.return_value = {'baz': '/baz'} + + list_command(mock_args) + + # Check if print is called correctly + rts_call_args = mock_print.call_args_list + check_list = [] + for i in range(len(rts_call_args)): + check_list.append(rts_call_args[i][0][0]) + + self.assertIn('Extensions:', check_list) + self.assertIn(' baz', check_list) + + @mock.patch('tethys_apps.cli.list_command.print') + @mock.patch('tethys_apps.cli.list_command.get_installed_tethys_extensions') + @mock.patch('tethys_apps.cli.list_command.get_installed_tethys_apps') + def test_list_command_installed_both(self, mock_installed_apps, mock_installed_extensions, mock_print): + mock_args = mock.MagicMock() + mock_installed_apps.return_value = {'foo': '/foo', 'bar': "/bar"} + mock_installed_extensions.return_value = {'baz': '/baz'} + + list_command(mock_args) + + # Check if print is called correctly + rts_call_args = mock_print.call_args_list + + check_list = [] + for i in range(len(rts_call_args)): + check_list.append(rts_call_args[i][0][0]) + + self.assertIn('Apps:', check_list) + self.assertIn(' foo', check_list) + self.assertIn(' bar', check_list) + self.assertIn('Extensions:', check_list) + self.assertIn(' baz', check_list) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_manage_commands.py b/tests/unit_tests/test_tethys_apps/test_cli/test_manage_commands.py new file mode 100644 index 000000000..c547697f4 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_manage_commands.py @@ -0,0 +1,279 @@ +import unittest +import mock +import tethys_apps.cli.manage_commands as manage_commands +from tethys_apps.cli.manage_commands import MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, \ + MANAGE_COLLECTWORKSPACES, MANAGE_COLLECT, MANAGE_CREATESUPERUSER, MANAGE_SYNC + + +class TestManageCommands(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_get_manage_path(self): + # mock the input args with manage attribute + args = mock.MagicMock(manage='') + + # call the method + ret = manage_commands.get_manage_path(args=args) + + # check whether the response has manage + self.assertIn('manage.py', ret) + + @mock.patch('tethys_apps.cli.manage_commands.pretty_output') + @mock.patch('tethys_apps.cli.manage_commands.exit') + def test_get_manage_path_error(self, mock_exit, mock_pretty_output): + # mock the system exit + mock_exit.side_effect = SystemExit + + # mock the input args with manage attribute + args = mock.MagicMock(manage='foo') + + self.assertRaises(SystemExit, manage_commands.get_manage_path, args=args) + + # check the mock exit value + mock_exit.assert_called_with(1) + mock_pretty_output.assert_called() + + @mock.patch('tethys_apps.cli.manage_commands.run_process') + def test_manage_command_manage_start(self, mock_run_process): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_START, port='8080') + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # get the call arguments for the run process mock method + process_call_args = mock_run_process.call_args_list + + # check the values from the argument list + self.assertEquals('python', process_call_args[0][0][0][0]) + self.assertIn('manage.py', process_call_args[0][0][0][1]) + self.assertEquals('runserver', process_call_args[0][0][0][2]) + self.assertEquals('8080', process_call_args[0][0][0][3]) + + @mock.patch('tethys_apps.cli.manage_commands.run_process') + def test_manage_command_manage_start_with_no_port(self, mock_run_process): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_START, port='') + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # get the call arguments for the run process mock method + process_call_args = mock_run_process.call_args_list + + # check the values from the argument list + self.assertEquals('python', process_call_args[0][0][0][0]) + self.assertIn('manage.py', process_call_args[0][0][0][1]) + self.assertEquals('runserver', process_call_args[0][0][0][2]) + + @mock.patch('tethys_apps.cli.manage_commands.run_process') + def test_manage_command_manage_syncdb(self, mock_run_process): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_SYNCDB, port='8080') + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # get the call arguments for the run process mock method + process_call_args = mock_run_process.call_args_list + + # intermediate process + self.assertEquals('python', process_call_args[0][0][0][0]) + self.assertIn('manage.py', process_call_args[0][0][0][1]) + self.assertEquals('makemigrations', process_call_args[0][0][0][2]) + + # primary process + self.assertEquals('python', process_call_args[1][0][0][0]) + self.assertIn('manage.py', process_call_args[1][0][0][1]) + self.assertEquals('migrate', process_call_args[1][0][0][2]) + + @mock.patch('tethys_apps.cli.manage_commands.run_process') + def test_manage_command_manage_manage_collectstatic(self, mock_run_process): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_COLLECTSTATIC, port='8080', noinput=False) + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # get the call arguments for the run process mock method + process_call_args = mock_run_process.call_args_list + + # intermediate process + self.assertEquals('python', process_call_args[0][0][0][0]) + self.assertIn('manage.py', process_call_args[0][0][0][1]) + self.assertEquals('pre_collectstatic', process_call_args[0][0][0][2]) + + # primary process + self.assertEquals('python', process_call_args[1][0][0][0]) + self.assertIn('manage.py', process_call_args[1][0][0][1]) + self.assertEquals('collectstatic', process_call_args[1][0][0][2]) + self.assertNotIn('--noinput', process_call_args[1][0][0]) + + @mock.patch('tethys_apps.cli.manage_commands.run_process') + def test_manage_command_manage_manage_collectstatic_with_no_input(self, mock_run_process): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_COLLECTSTATIC, port='8080', noinput=True) + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # get the call arguments for the run process mock method + process_call_args = mock_run_process.call_args_list + + # intermediate process + self.assertEquals('python', process_call_args[0][0][0][0]) + self.assertIn('manage.py', process_call_args[0][0][0][1]) + self.assertEquals('pre_collectstatic', process_call_args[0][0][0][2]) + + # primary process + self.assertEquals('python', process_call_args[1][0][0][0]) + self.assertIn('manage.py', process_call_args[1][0][0][1]) + self.assertEquals('collectstatic', process_call_args[1][0][0][2]) + self.assertEquals('--noinput', process_call_args[1][0][0][3]) + + @mock.patch('tethys_apps.cli.manage_commands.run_process') + def test_manage_command_manage_manage_collect_workspace(self, mock_run_process): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_COLLECTWORKSPACES, port='8080', force=True) + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # get the call arguments for the run process mock method + process_call_args = mock_run_process.call_args_list + + # check the values from the argument list + self.assertEquals('python', process_call_args[0][0][0][0]) + self.assertIn('manage.py', process_call_args[0][0][0][1]) + self.assertEquals('collectworkspaces', process_call_args[0][0][0][2]) + self.assertEquals('--force', process_call_args[0][0][0][3]) + + @mock.patch('tethys_apps.cli.manage_commands.run_process') + def test_manage_command_manage_manage_collect_workspace_with_no_force(self, mock_run_process): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_COLLECTWORKSPACES, force=False) + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # get the call arguments for the run process mock method + process_call_args = mock_run_process.call_args_list + + # check the values from the argument list + self.assertEquals('python', process_call_args[0][0][0][0]) + self.assertIn('manage.py', process_call_args[0][0][0][1]) + self.assertEquals('collectworkspaces', process_call_args[0][0][0][2]) + self.assertNotIn('--force', process_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.manage_commands.run_process') + def test_manage_command_manage_manage_collect(self, mock_run_process): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_COLLECT, port='8080', noinput=False) + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # get the call arguments for the run process mock method + process_call_args = mock_run_process.call_args_list + + # pre_collectstatic + self.assertEquals('python', process_call_args[0][0][0][0]) + self.assertIn('manage.py', process_call_args[0][0][0][1]) + self.assertEquals('pre_collectstatic', process_call_args[0][0][0][2]) + + # collectstatic + self.assertEquals('python', process_call_args[1][0][0][0]) + self.assertIn('manage.py', process_call_args[1][0][0][1]) + self.assertEquals('collectstatic', process_call_args[1][0][0][2]) + self.assertNotIn('--noinput', process_call_args[1][0][0]) + + # collectworkspaces + self.assertEquals('python', process_call_args[2][0][0][0]) + self.assertIn('manage.py', process_call_args[2][0][0][1]) + self.assertEquals('collectworkspaces', process_call_args[2][0][0][2]) + + @mock.patch('tethys_apps.cli.manage_commands.run_process') + def test_manage_command_manage_manage_collect_no_input(self, mock_run_process): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_COLLECT, port='8080', noinput=True) + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # get the call arguments for the run process mock method + process_call_args = mock_run_process.call_args_list + + # pre_collectstatic + self.assertEquals('python', process_call_args[0][0][0][0]) + self.assertIn('manage.py', process_call_args[0][0][0][1]) + self.assertEquals('pre_collectstatic', process_call_args[0][0][0][2]) + + # collectstatic + self.assertEquals('python', process_call_args[1][0][0][0]) + self.assertIn('manage.py', process_call_args[1][0][0][1]) + self.assertEquals('collectstatic', process_call_args[1][0][0][2]) + self.assertEquals('--noinput', process_call_args[1][0][0][3]) + + # collectworkspaces + self.assertEquals('python', process_call_args[2][0][0][0]) + self.assertIn('manage.py', process_call_args[2][0][0][1]) + self.assertEquals('collectworkspaces', process_call_args[2][0][0][2]) + + @mock.patch('tethys_apps.cli.manage_commands.run_process') + def test_manage_command_manage_manage_create_super_user(self, mock_run_process): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_CREATESUPERUSER, port='8080') + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # get the call arguments for the run process mock method + process_call_args = mock_run_process.call_args_list + + # check the values from the argument list + self.assertEquals('python', process_call_args[0][0][0][0]) + self.assertIn('manage.py', process_call_args[0][0][0][1]) + self.assertEquals('createsuperuser', process_call_args[0][0][0][2]) + + @mock.patch('tethys_apps.harvester.SingletonHarvester') + def test_manage_command_manage_manage_sync(self, MockSingletonHarvester): + # mock the input args + args = mock.MagicMock(manage='', command=MANAGE_SYNC, port='8080') + + # call the testing method with the mock args + manage_commands.manage_command(args) + + # mock the singleton harvester + MockSingletonHarvester.assert_called() + MockSingletonHarvester().harvest.assert_called() + + @mock.patch('tethys_apps.cli.manage_commands.subprocess.call') + @mock.patch('tethys_apps.cli.manage_commands.set_testing_environment') + def test_run_process(self, mock_te_call, mock_subprocess_call): + + # mock the process + mock_process = ['test'] + + manage_commands.run_process(mock_process) + + self.assertEqual(2, len(mock_te_call.call_args_list)) + + mock_subprocess_call.assert_called_with(mock_process) + + @mock.patch('tethys_apps.cli.manage_commands.subprocess.call') + @mock.patch('tethys_apps.cli.manage_commands.set_testing_environment') + def test_run_process_keyboardinterrupt(self, mock_te_call, mock_subprocess_call): + + # mock the process + mock_process = ['foo'] + + mock_subprocess_call.side_effect = KeyboardInterrupt + + manage_commands.run_process(mock_process) + mock_subprocess_call.assert_called_with(mock_process) + mock_te_call.assert_called_once() diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_scaffold_commands.py b/tests/unit_tests/test_tethys_apps/test_cli/test_scaffold_commands.py new file mode 100644 index 000000000..3ea125acd --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_scaffold_commands.py @@ -0,0 +1,1255 @@ +import unittest +import mock +import os +import sys + +from tethys_apps.cli.scaffold_commands import proper_name_validator, get_random_color, theme_color_validator, \ + render_path, scaffold_command + +if sys.version_info[0] < 3: + callable_mock_path = '__builtin__.callable' +else: + callable_mock_path = 'builtins.callable' + + +class TestScaffoldCommands(unittest.TestCase): + + def setUp(self): + self.app_prefix = 'tethysapp' + self.extensions_prefix = 'tethysext' + self.scaffold_templates_dir = 'scaffold_templates' + self.extension_template_dir = 'extension_templates' + self.app_template_dir = 'app_templates' + self.template_suffix = '_tmpl' + self.app_path = os.path.join(os.path.dirname(__file__), self.scaffold_templates_dir, self.app_template_dir) + self.extension_path = os.path.join(os.path.dirname(__file__), self.scaffold_templates_dir, + self.extension_template_dir) + + def tearDown(self): + pass + + def test_proper_name_validator(self): + expected_value = 'foo' + expected_default = 'bar' + ret = proper_name_validator(expected_value, expected_default) + self.assertTrue(ret[0]) + self.assertEquals('foo', ret[1]) + + def test_proper_name_validator_value_as_default(self): + expected_value = 'bar' + expected_default = 'bar' + ret = proper_name_validator(expected_value, expected_default) + self.assertTrue(ret[0]) + self.assertEquals('bar', ret[1]) + + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + def test_proper_name_validator_warning(self, mock_pretty_output): + expected_value = 'foo_' + expected_default = 'bar' + ret = proper_name_validator(expected_value, expected_default) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals('Warning: Illegal characters were detected in proper name "foo_". ' + 'They have been replaced or removed with valid characters: "foo "', po_call_args[0][0][0]) + self.assertTrue(ret[0]) + self.assertEquals('foo ', ret[1]) + + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + def test_proper_name_validator_error(self, mock_pretty_output): + expected_value = '@@' + expected_default = 'bar' + ret = proper_name_validator(expected_value, expected_default) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals('Error: Proper name can only contain letters and numbers and spaces.', po_call_args[0][0][0]) + self.assertFalse(ret[0]) + self.assertEquals('@@', ret[1]) + + @mock.patch('tethys_apps.cli.scaffold_commands.random.choice') + def test_get_random_color(self, mock_choice): + mock_choice.return_value = '#16a085' + ret = get_random_color() + self.assertEquals(mock_choice.return_value, ret) + + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + def test_theme_color_validator_same_default_value(self, mock_random_color): + expected_value = 'foo' + expected_default = 'foo' + mock_random_color.return_value = '#16a085' + ret = theme_color_validator(expected_value, expected_default) + self.assertTrue(ret[0]) + self.assertEquals('#16a085', ret[1]) + + def test_theme_color_validator(self): + expected_value = '#8e44ad' + expected_default = '#16a085' + ret = theme_color_validator(expected_value, expected_default) + self.assertTrue(ret[0]) + self.assertEquals('#8e44ad', ret[1]) + + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + def test_theme_color_validator_exception(self, mock_pretty_output): + expected_value = 'foo' + expected_default = 'bar' + ret = theme_color_validator(expected_value, expected_default) + mock_pretty_output.assert_called() + self.assertFalse(ret[0]) + self.assertEquals('foo', ret[1]) + + def test_render_path_with_plus(self): + expected_path = '+ite1+' + expected_context = {'ite1': 'foo'} + ret = render_path(expected_path, expected_context) + self.assertEquals('foo', ret) + + def test_render_path(self): + expected_path = 'variable' + mock_context = mock.MagicMock() + ret = render_path(expected_path, mock_context) + self.assertEquals('variable', ret) + + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch(callable_mock_path) + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command(self, _, __, mock_makedirs, mock_os_walk, mock_render_path, mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, mock_random_color, mock_callable, mock_logger): + + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'project_name' + mock_args.use_defaults = True + mock_args.overwrite = True + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + # mocking the validate template call return value + mock_os_path_isdir.return_value = [True, True] + + mock_callable.return_value = True + + mock_template_context = mock.MagicMock() + + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + scaffold_command(args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_called() + + mock_render_path.assert_called() + + mock_makedirs.assert_called_with(mock_cuurent_project_root) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[0][0][0]) + self.assertIn('Created:', po_call_args[1][0][0]) + self.assertIn('Created:', po_call_args[2][0][0]) + self.assertIn('Created:', po_call_args[3][0][0]) + self.assertIn('Created:', po_call_args[4][0][0]) + self.assertIn('Created:', po_call_args[5][0][0]) + self.assertEquals('Successfully scaffolded new project "project_name"', po_call_args[6][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) + self.assertEquals('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) + self.assertEquals('Rendering template: "/foo/baz"', mock_log_call_args[7][0][0]) + self.assertEquals('Loading template: "/foo/bar/spam"', mock_log_call_args[8][0][0]) + self.assertEquals('Rendering template: "/foo/bar/spam"', mock_log_call_args[9][0][0]) + self.assertEquals('Loading template: "/foo/bar/eggs"', mock_log_call_args[10][0][0]) + self.assertEquals('Rendering template: "/foo/bar/eggs"', mock_log_call_args[11][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.exit') + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + def test_scaffold_command_with_not_valid_template(self, mock_os_path_isdir, mock_pretty_output, mock_logger, + mock_exit): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = '@@' + mock_args.use_defaults = True + mock_args.overwrite = True + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + mock_os_path_isdir.return_value = False + + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, scaffold_command, args=mock_args) + + mock_pretty_output.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Error: "template_name" is not a valid template.', + po_call_args[0][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch(callable_mock_path) + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command_with_no_extension(self, _, __, mock_makedirs, mock_os_walk, mock_render_path, + mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, mock_random_color, + mock_callable, mock_logger): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = None + mock_args.template = 'template_name' + mock_args.name = 'project_name' + mock_args.use_defaults = True + mock_args.overwrite = True + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + mock_os_path_isdir.return_value = [True, True] + + mock_callable.return_value = True + + mock_template_context = mock.MagicMock() + + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + scaffold_command(args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_called() + + mock_render_path.assert_called() + + mock_makedirs.assert_called_with(mock_cuurent_project_root) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Creating new Tethys project named "tethysapp-project_name".', po_call_args[0][0][0]) + self.assertIn('Created:', po_call_args[1][0][0]) + self.assertIn('Created:', po_call_args[2][0][0]) + self.assertIn('Created:', po_call_args[3][0][0]) + self.assertIn('Created:', po_call_args[4][0][0]) + self.assertIn('Created:', po_call_args[5][0][0]) + self.assertEquals('Successfully scaffolded new project "project_name"', po_call_args[6][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) + self.assertEquals('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) + self.assertEquals('Rendering template: "/foo/baz"', mock_log_call_args[7][0][0]) + self.assertEquals('Loading template: "/foo/bar/spam"', mock_log_call_args[8][0][0]) + self.assertEquals('Rendering template: "/foo/bar/spam"', mock_log_call_args[9][0][0]) + self.assertEquals('Loading template: "/foo/bar/eggs"', mock_log_call_args[10][0][0]) + self.assertEquals('Rendering template: "/foo/bar/eggs"', mock_log_call_args[11][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch(callable_mock_path) + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command_with_uppercase_project_name(self, _, __, mock_makedirs, mock_os_walk, mock_render_path, + mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, mock_random_color, + mock_callable, mock_logger): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'PROJECT_NAME' + mock_args.use_defaults = True + mock_args.overwrite = True + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + # mocking the validate template call return value + mock_os_path_isdir.return_value = [True, True] + + mock_callable.return_value = True + + mock_template_context = mock.MagicMock() + + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + scaffold_command(args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_called() + + mock_render_path.assert_called() + + mock_makedirs.assert_called_with(mock_cuurent_project_root) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Warning: Uppercase characters in project name "PROJECT_NAME" changed to ' + 'lowercase: "project_name".', po_call_args[0][0][0]) + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[1][0][0]) + self.assertIn('Created:', po_call_args[2][0][0]) + self.assertIn('Created:', po_call_args[3][0][0]) + self.assertIn('Created:', po_call_args[4][0][0]) + self.assertIn('Created:', po_call_args[5][0][0]) + self.assertIn('Created:', po_call_args[6][0][0]) + self.assertEquals('Successfully scaffolded new project "project_name"', po_call_args[7][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) + self.assertEquals('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) + self.assertEquals('Rendering template: "/foo/baz"', mock_log_call_args[7][0][0]) + self.assertEquals('Loading template: "/foo/bar/spam"', mock_log_call_args[8][0][0]) + self.assertEquals('Rendering template: "/foo/bar/spam"', mock_log_call_args[9][0][0]) + self.assertEquals('Loading template: "/foo/bar/eggs"', mock_log_call_args[10][0][0]) + self.assertEquals('Rendering template: "/foo/bar/eggs"', mock_log_call_args[11][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.exit') + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + def test_scaffold_command_with_wrong_project_name(self, mock_os_path_isdir, mock_pretty_output, mock_logger, + mock_exit): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = '@@' + mock_args.use_defaults = True + mock_args.overwrite = True + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + mock_os_path_isdir.return_value = True + + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, scaffold_command, args=mock_args) + + mock_pretty_output.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Error: Invalid characters in project name "@@". Only letters, numbers, and underscores.', + po_call_args[0][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch(callable_mock_path) + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command_with_project_warning(self, _, __, mock_makedirs, mock_os_walk, mock_render_path, + mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, mock_random_color, + mock_callable, mock_logger): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'project-name' + mock_args.use_defaults = True + mock_args.overwrite = True + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + mock_os_path_isdir.return_value = [True, True] + + mock_callable.return_value = True + + mock_template_context = mock.MagicMock() + + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + scaffold_command(args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_called() + + mock_render_path.assert_called() + + mock_makedirs.assert_called_with(mock_cuurent_project_root) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Warning: Dashes in project name "project-name" have been replaced with underscores ' + '"project_name"', po_call_args[0][0][0]) + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[1][0][0]) + self.assertIn('Created:', po_call_args[2][0][0]) + self.assertIn('Created:', po_call_args[3][0][0]) + self.assertIn('Created:', po_call_args[4][0][0]) + self.assertIn('Created:', po_call_args[5][0][0]) + self.assertIn('Created:', po_call_args[6][0][0]) + self.assertEquals('Successfully scaffolded new project "project_name"', po_call_args[7][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) + self.assertEquals('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) + self.assertEquals('Rendering template: "/foo/baz"', mock_log_call_args[7][0][0]) + self.assertEquals('Loading template: "/foo/bar/spam"', mock_log_call_args[8][0][0]) + self.assertEquals('Rendering template: "/foo/bar/spam"', mock_log_call_args[9][0][0]) + self.assertEquals('Loading template: "/foo/bar/eggs"', mock_log_call_args[10][0][0]) + self.assertEquals('Rendering template: "/foo/bar/eggs"', mock_log_call_args[11][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.proper_name_validator') + @mock.patch('tethys_apps.cli.scaffold_commands.input') + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch(callable_mock_path) + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command_with_no_defaults(self, _, __, mock_makedirs, mock_os_walk, mock_render_path, + mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, mock_random_color, + mock_callable, mock_logger, mock_input, mock_proper_name_validator): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'project-name' + mock_args.use_defaults = False + mock_args.overwrite = True + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + mock_os_path_isdir.return_value = [True, True] + + mock_callable.side_effect = [True, False, False, False, False] + + mock_template_context = mock.MagicMock() + + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + mock_input.side_effect = ['test1', 'test2', 'test3', 'test4', 'test5'] + mock_proper_name_validator.return_value = True, 'foo' + + scaffold_command(args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_called() + + mock_render_path.assert_called() + + # mock the create root directory + mock_makedirs.assert_called_with(mock_cuurent_project_root) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Warning: Dashes in project name "project-name" have been replaced with underscores ' + '"project_name"', po_call_args[0][0][0]) + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[1][0][0]) + self.assertIn('Created:', po_call_args[2][0][0]) + self.assertIn('Created:', po_call_args[3][0][0]) + self.assertIn('Created:', po_call_args[4][0][0]) + self.assertIn('Created:', po_call_args[5][0][0]) + self.assertIn('Created:', po_call_args[6][0][0]) + self.assertEquals('Successfully scaffolded new project "project_name"', po_call_args[7][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('foo', mock_log_call_args[4][0][0]) + self.assertIn('test2', mock_log_call_args[4][0][0]) + self.assertIn('test3', mock_log_call_args[4][0][0]) + self.assertIn('test4', mock_log_call_args[4][0][0]) + self.assertIn('test5', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) + self.assertEquals('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) + self.assertEquals('Rendering template: "/foo/baz"', mock_log_call_args[7][0][0]) + self.assertEquals('Loading template: "/foo/bar/spam"', mock_log_call_args[8][0][0]) + self.assertEquals('Rendering template: "/foo/bar/spam"', mock_log_call_args[9][0][0]) + self.assertEquals('Loading template: "/foo/bar/eggs"', mock_log_call_args[10][0][0]) + self.assertEquals('Rendering template: "/foo/bar/eggs"', mock_log_call_args[11][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.proper_name_validator') + @mock.patch('tethys_apps.cli.scaffold_commands.input') + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch(callable_mock_path) + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + @mock.patch('tethys_apps.cli.scaffold_commands.exit') + def test_scaffold_command_with_no_defaults_input_exception(self, mock_exit, _, __, mock_makedirs, mock_os_walk, + mock_render_path, mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, + mock_random_color, mock_callable, + mock_logger, mock_input, mock_proper_name_validator): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'project-name' + mock_args.use_defaults = False + mock_args.overwrite = True + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + # mocking the validate template call return value + mock_os_path_isdir.return_value = [True, True] + + mock_callable.side_effect = [True, False, False, False, False] + + mock_template_context = mock.MagicMock() + + # testing: walk the template directory, creating the templates and directories in the new project as we go + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + mock_exit.side_effect = SystemExit + + mock_input.side_effect = KeyboardInterrupt + + mock_proper_name_validator.return_value = True, 'foo' + + self.assertRaises(SystemExit, scaffold_command, args=mock_args) + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_not_called() + + mock_render_path.assert_not_called() + + # mock the create root directory + mock_makedirs.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Warning: Dashes in project name "project-name" have been replaced with underscores ' + '"project_name"', po_call_args[0][0][0]) + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[1][0][0]) + self.assertIn('Scaffolding cancelled.', po_call_args[2][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.proper_name_validator') + @mock.patch('tethys_apps.cli.scaffold_commands.input') + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch('tethys_apps.cli.scaffold_commands.callable') + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command_with_no_defaults_invalid_response(self, _, __, mock_makedirs, mock_os_walk, + mock_render_path, + mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, + mock_random_color, + mock_callable, mock_logger, mock_input, + mock_proper_name_validator): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'project-name' + mock_args.use_defaults = False + mock_args.overwrite = True + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + # mocking the validate template call return value + mock_os_path_isdir.return_value = [True, True] + + mock_callable.side_effect = [True, False, False, False, False, False] + + mock_template_context = mock.MagicMock() + + # testing: walk the template directory, creating the templates and directories in the new project as we go + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + mock_input.side_effect = ['test1', 'test1_a', 'test2', 'test3', 'test4', 'test5'] + mock_proper_name_validator.return_value = False, 'foo' + + scaffold_command(args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_called() + + mock_render_path.assert_called() + + # mock the create root directory + mock_makedirs.assert_called_with(mock_cuurent_project_root) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Warning: Dashes in project name "project-name" have been replaced with underscores ' + '"project_name"', po_call_args[0][0][0]) + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[1][0][0]) + self.assertIn('Invalid response: foo', po_call_args[2][0][0]) + self.assertIn('Created:', po_call_args[3][0][0]) + self.assertIn('Created:', po_call_args[4][0][0]) + self.assertIn('Created:', po_call_args[5][0][0]) + self.assertIn('Created:', po_call_args[6][0][0]) + self.assertIn('Created:', po_call_args[7][0][0]) + self.assertEquals('Successfully scaffolded new project "project_name"', po_call_args[8][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('test1_a', mock_log_call_args[4][0][0]) + self.assertIn('test2', mock_log_call_args[4][0][0]) + self.assertIn('test3', mock_log_call_args[4][0][0]) + self.assertIn('test4', mock_log_call_args[4][0][0]) + self.assertIn('test5', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) + self.assertEquals('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) + self.assertEquals('Rendering template: "/foo/baz"', mock_log_call_args[7][0][0]) + self.assertEquals('Loading template: "/foo/bar/spam"', mock_log_call_args[8][0][0]) + self.assertEquals('Rendering template: "/foo/bar/spam"', mock_log_call_args[9][0][0]) + self.assertEquals('Loading template: "/foo/bar/eggs"', mock_log_call_args[10][0][0]) + self.assertEquals('Rendering template: "/foo/bar/eggs"', mock_log_call_args[11][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.input') + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command_with_no_overwrite(self, _, __, mock_makedirs, mock_os_walk, mock_render_path, + mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, mock_random_color, + mock_logger, mock_input): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'project-name' + mock_args.use_defaults = True + mock_args.overwrite = False + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + # mocking the validate template call return value + mock_os_path_isdir.return_value = [True, True] + + mock_template_context = mock.MagicMock() + + # testing: walk the template directory, creating the templates and directories in the new project as we go + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + mock_input.side_effect = ['y'] + + scaffold_command(args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_called() + + mock_render_path.assert_called() + + # mock the create root directory + mock_makedirs.assert_called_with(mock_cuurent_project_root) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Warning: Dashes in project name "project-name" have been replaced with underscores ' + '"project_name"', po_call_args[0][0][0]) + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[1][0][0]) + self.assertIn('Created:', po_call_args[2][0][0]) + self.assertIn('Created:', po_call_args[3][0][0]) + self.assertIn('Created:', po_call_args[4][0][0]) + self.assertIn('Created:', po_call_args[5][0][0]) + self.assertIn('Created:', po_call_args[6][0][0]) + self.assertEquals('Successfully scaffolded new project "project_name"', po_call_args[7][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) + self.assertEquals('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) + self.assertEquals('Rendering template: "/foo/baz"', mock_log_call_args[7][0][0]) + self.assertEquals('Loading template: "/foo/bar/spam"', mock_log_call_args[8][0][0]) + self.assertEquals('Rendering template: "/foo/bar/spam"', mock_log_call_args[9][0][0]) + self.assertEquals('Loading template: "/foo/bar/eggs"', mock_log_call_args[10][0][0]) + self.assertEquals('Rendering template: "/foo/bar/eggs"', mock_log_call_args[11][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.exit') + @mock.patch('tethys_apps.cli.scaffold_commands.input') + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command_with_no_overwrite_keyboard_interrupt(self, _, __, mock_makedirs, mock_os_walk, + mock_render_path, + mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, + mock_random_color, + mock_logger, mock_input, mock_exit): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'project-name' + mock_args.use_defaults = True + mock_args.overwrite = False + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + # mocking the validate template call return value + mock_os_path_isdir.return_value = [True, True] + + mock_template_context = mock.MagicMock() + + # testing: walk the template directory, creating the templates and directories in the new project as we go + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + mock_exit.side_effect = SystemExit + + mock_input.side_effect = KeyboardInterrupt + + self.assertRaises(SystemExit, scaffold_command, args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_not_called() + + mock_render_path.assert_not_called() + + # mock the create root directory + mock_makedirs.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Warning: Dashes in project name "project-name" have been replaced with underscores ' + '"project_name"', po_call_args[0][0][0]) + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[1][0][0]) + self.assertIn('Scaffolding cancelled.', po_call_args[2][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.exit') + @mock.patch('tethys_apps.cli.scaffold_commands.input') + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command_with_no_overwrite_cancel(self, _, __, mock_makedirs, mock_os_walk, + mock_render_path, + mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, + mock_random_color, + mock_logger, mock_input, mock_exit): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'project-name' + mock_args.use_defaults = True + mock_args.overwrite = False + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + # mocking the validate template call return value + mock_os_path_isdir.return_value = [True, True] + + mock_template_context = mock.MagicMock() + + # testing: walk the template directory, creating the templates and directories in the new project as we go + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + mock_exit.side_effect = SystemExit + + mock_input.side_effect = ['n'] + + self.assertRaises(SystemExit, scaffold_command, args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_not_called() + + mock_render_path.assert_not_called() + + # mock the create root directory + mock_makedirs.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Warning: Dashes in project name "project-name" have been replaced with underscores ' + '"project_name"', po_call_args[0][0][0]) + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[1][0][0]) + self.assertIn('Scaffolding cancelled.', po_call_args[2][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.exit') + @mock.patch('tethys_apps.cli.scaffold_commands.input') + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command_with_no_overwrite_os_error(self, _, __, mock_makedirs, mock_os_walk, + mock_render_path, + mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, + mock_random_color, + mock_logger, mock_input, mock_exit): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'project-name' + mock_args.use_defaults = True + mock_args.overwrite = False + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + # mocking the validate template call return value + mock_os_path_isdir.return_value = [True, True] + + mock_template_context = mock.MagicMock() + + # testing: walk the template directory, creating the templates and directories in the new project as we go + mock_context.return_value = mock_template_context + + mock_cuurent_project_root = mock.MagicMock() + mock_render_path.return_value = mock_cuurent_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + mock_exit.side_effect = SystemExit + + mock_input.side_effect = ['y'] + + mock_rmt.side_effect = OSError + + self.assertRaises(SystemExit, scaffold_command, args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_called_once() + + mock_render_path.assert_not_called() + + # mock the create root directory + mock_makedirs.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Warning: Dashes in project name "project-name" have been replaced with underscores ' + '"project_name"', po_call_args[0][0][0]) + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[1][0][0]) + self.assertIn('Error: Unable to overwrite', po_call_args[2][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) + + @mock.patch('tethys_apps.cli.scaffold_commands.logging.getLogger') + @mock.patch('tethys_apps.cli.scaffold_commands.get_random_color') + @mock.patch('tethys_apps.cli.scaffold_commands.pretty_output') + @mock.patch('tethys_apps.cli.scaffold_commands.os.path.isdir') + @mock.patch('tethys_apps.cli.scaffold_commands.shutil.rmtree') + @mock.patch('tethys_apps.cli.scaffold_commands.Context') + @mock.patch('tethys_apps.cli.scaffold_commands.render_path') + @mock.patch('tethys_apps.cli.scaffold_commands.os.walk') + @mock.patch('tethys_apps.cli.scaffold_commands.os.makedirs') + @mock.patch('tethys_apps.cli.scaffold_commands.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.cli.scaffold_commands.Template') + def test_scaffold_command_with_unicode_decode_error(self, mock_template, _, mock_makedirs, mock_os_walk, + mock_render_path, + mock_context, mock_rmt, + mock_os_path_isdir, mock_pretty_output, + mock_random_color, + mock_logger): + # mock the input args + mock_args = mock.MagicMock() + + mock_args.extension = '.ext' + mock_args.template = 'template_name' + mock_args.name = 'project-name' + mock_args.use_defaults = True + mock_args.overwrite = True + + # mock the log + mock_log = mock.MagicMock() + + # mock the getlogger from logging + mock_logger.return_value = mock_log + + # mocking the validate template call return value + mock_os_path_isdir.return_value = [True, True] + mock_template.side_effect = UnicodeDecodeError('foo', 'bar'.encode(), 1, 2, 'baz') + + mock_template_context = mock.MagicMock() + + # testing: walk the template directory, creating the templates and directories in the new project as we go + mock_context.return_value = mock_template_context + + mock_current_project_root = mock.MagicMock() + mock_render_path.return_value = mock_current_project_root + + mock_os_walk.return_value = [ + ('/foo', ('bar',), ('baz',)), + ('/foo/bar', (), ('spam', 'eggs')), + ] + + mock_makedirs.return_value = True + + scaffold_command(args=mock_args) + + mock_pretty_output.assert_called() + + mock_random_color.assert_called() + + # check shutil.rmtree call + mock_rmt.assert_called_once() + + mock_render_path.assert_called() + + # mock the create root directory + mock_makedirs.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + + self.assertEquals('Warning: Dashes in project name "project-name" have been replaced with underscores ' + '"project_name"', po_call_args[0][0][0]) + self.assertEquals('Creating new Tethys project named "tethysext-project_name".', po_call_args[1][0][0]) + self.assertIn('Created', po_call_args[2][0][0]) + self.assertIn('Created', po_call_args[3][0][0]) + self.assertIn('Successfully scaffolded new project "project_name"', po_call_args[4][0][0]) + + mock_log_call_args = mock_log.debug.call_args_list + self.assertIn('Command args', mock_log_call_args[0][0][0]) + self.assertIn('APP_PATH', mock_log_call_args[1][0][0]) + self.assertIn('EXTENSION_PATH', mock_log_call_args[2][0][0]) + self.assertIn('Template root directory', mock_log_call_args[3][0][0]) + self.assertIn('Template context', mock_log_call_args[4][0][0]) + self.assertIn('Project root path', mock_log_call_args[5][0][0]) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_scheduler_commands.py b/tests/unit_tests/test_tethys_apps/test_cli/test_scheduler_commands.py new file mode 100644 index 000000000..0fcf9e719 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_scheduler_commands.py @@ -0,0 +1,286 @@ +import unittest +import mock + +from tethys_apps.cli.scheduler_commands import scheduler_create_command, schedulers_list_command, \ + schedulers_remove_command +from django.core.exceptions import ObjectDoesNotExist + + +class SchedulerCommandsTest(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.cli.scheduler_commands.pretty_output') + @mock.patch('tethys_apps.cli.scheduler_commands.exit') + @mock.patch('tethys_compute.models.Scheduler') + def test_scheduler_create_command(self, mock_scheduler, mock_exit, mock_pretty_output): + """ + Test for scheduler_create_command. + Runs through and saves. + :param mock_scheduler: mock for Scheduler + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_scheduler.objects.filter().first.return_value = False + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, scheduler_create_command, mock_args) + + mock_scheduler.assert_called_with( + name=mock_args.name, + host=mock_args.endpoint, + username=mock_args.username, + password=mock_args.password, + private_key_path=mock_args.private_key_path, + private_key_pass=mock_args.private_key_pass + ) + mock_scheduler().save.assert_called() + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual('Scheduler created successfully!', po_call_args[0][0][0]) + mock_exit.assert_called_with(0) + + @mock.patch('tethys_apps.cli.scheduler_commands.pretty_output') + @mock.patch('tethys_apps.cli.scheduler_commands.exit') + @mock.patch('tethys_compute.models.Scheduler') + def test_scheduler_create_command_existing_scheduler(self, mock_scheduler, mock_exit, mock_pretty_output): + """ + Test for scheduler_create_command. + For when a scheduler already exists. + :param mock_scheduler: mock for Scheduler + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_scheduler.objects.filter().first.return_value = True + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, scheduler_create_command, mock_args) + + mock_scheduler.objects.filter.assert_called() + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertIn('already exists', po_call_args[0][0][0]) + mock_exit.assert_called_with(0) + + @mock.patch('tethys_apps.cli.scheduler_commands.pretty_output') + @mock.patch('tethys_compute.models.Scheduler') + def test_schedulers_list_command(self, mock_scheduler, mock_pretty_output): + """ + Test for schedulers_list_command. + For use with multiple schedulers. + :param mock_scheduler: mock for Scheduler + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_scheduler1 = mock.MagicMock(name='test1') + mock_scheduler1.name = 'test_name1' + mock_scheduler1.host = 'test_host1' + mock_scheduler1.username = 'test_user1' + mock_scheduler1.private_key_path = 'test_path1' + mock_scheduler1.private_key_pass = 'test_private_key_path1' + mock_scheduler2 = mock.MagicMock() + mock_scheduler2.name = 'test_name2' + mock_scheduler2.host = 'test_host2' + mock_scheduler2.username = 'test_user2' + mock_scheduler2.private_key_path = 'test_path2' + mock_scheduler2.private_key_pass = 'test_private_key_path2' + mock_scheduler.objects.all.return_value = [mock_scheduler1, mock_scheduler2] + mock_args = mock.MagicMock() + schedulers_list_command(mock_args) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(3, len(po_call_args)) + self.assertIn('Name', po_call_args[0][0][0]) + self.assertIn('Host', po_call_args[0][0][0]) + self.assertIn('Username', po_call_args[0][0][0]) + self.assertIn('Password', po_call_args[0][0][0]) + self.assertIn('Private Key Path', po_call_args[0][0][0]) + self.assertIn('Private Key Pass', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.scheduler_commands.pretty_output') + @mock.patch('tethys_compute.models.Scheduler') + def test_schedulers_list_command_no_schedulers(self, mock_scheduler, mock_pretty_output): + """ + Test for schedulers_list_command. + For use with no schedulers. + :param mock_scheduler: mock for Scheduler + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_scheduler.objects.all.return_value = [] + mock_args = mock.MagicMock() + schedulers_list_command(mock_args) + + mock_scheduler.objects.all.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('There are no Schedulers registered in Tethys.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.scheduler_commands.pretty_output') + @mock.patch('tethys_apps.cli.scheduler_commands.exit') + @mock.patch('tethys_compute.models.Scheduler') + def test_schedulers_remove_command_force(self, mock_scheduler, mock_exit, mock_pretty_output): + """ + Test for schedulers_remove_command. + Runs through, forcing a delete + :param mock_scheduler: mock for Scheduler + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = True + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, schedulers_remove_command, mock_args) + + mock_scheduler.objects.get.assert_called() + mock_scheduler.objects.get().delete.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Successfully removed Scheduler', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.scheduler_commands.input') + @mock.patch('tethys_apps.cli.scheduler_commands.pretty_output') + @mock.patch('tethys_apps.cli.scheduler_commands.exit') + @mock.patch('tethys_compute.models.Scheduler') + def test_schedulers_remove_command_force_invalid_proceed_char(self, mock_scheduler, mock_exit, mock_pretty_output, + mock_input): + """ + Test for schedulers_remove_command. + Runs through, not forcing a delete, and when prompted to delete, gives an invalid answer + :param mock_scheduler: mock for Scheduler + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :param mock_input: mock for handling raw_input requests + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = False + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + mock_input.side_effect = ['foo', 'N'] + + self.assertRaises(SystemExit, schedulers_remove_command, mock_args) + + mock_scheduler.objects.get.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Aborted. Scheduler not removed.', po_call_args[0][0][0]) + + po_call_args = mock_input.call_args_list + self.assertEqual(2, len(po_call_args)) + self.assertEqual('Are you sure you want to delete this Scheduler? [y/n]: ', po_call_args[0][0][0]) + self.assertEqual('Please enter either "y" or "n": ', po_call_args[1][0][0]) + + @mock.patch('tethys_apps.cli.scheduler_commands.input') + @mock.patch('tethys_apps.cli.scheduler_commands.pretty_output') + @mock.patch('tethys_apps.cli.scheduler_commands.exit') + @mock.patch('tethys_compute.models.Scheduler') + def test_schedulers_remove_command_no_force_proceed(self, mock_scheduler, mock_exit, mock_pretty_output, + mock_input): + """ + Test for schedulers_remove_command. + Runs through, not forcing a delete, and when prompted to delete, gives a valid answer to delete + :param mock_scheduler: mock for Scheduler + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :param mock_input: mock for handling raw_input requests + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = False + mock_input.side_effect = ['Y'] + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, schedulers_remove_command, mock_args) + + mock_scheduler.objects.get.assert_called() + mock_scheduler.objects.get().delete.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Successfully removed Scheduler', po_call_args[0][0][0]) + + po_call_args = mock_input.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Are you sure you want to delete this Scheduler? [y/n]: ', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.scheduler_commands.input') + @mock.patch('tethys_apps.cli.scheduler_commands.pretty_output') + @mock.patch('tethys_apps.cli.scheduler_commands.exit') + @mock.patch('tethys_compute.models.Scheduler') + def test_schedulers_remove_command_no_force_no_proceed(self, mock_scheduler, mock_exit, mock_pretty_output, + mock_input): + """ + Test for schedulers_remove_command. + Runs through, not forcing a delete, and when prompted to delete, gives a valid answer to not delete + :param mock_scheduler: mock for Scheduler + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :param mock_input: mock for handling raw_input requests + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = False + mock_input.side_effect = ['N'] + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, schedulers_remove_command, mock_args) + + mock_scheduler.objects.get.assert_called() + mock_scheduler.objects.get().delete.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Aborted. Scheduler not removed.', po_call_args[0][0][0]) + + po_call_args = mock_input.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Are you sure you want to delete this Scheduler? [y/n]: ', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.scheduler_commands.pretty_output') + @mock.patch('tethys_apps.cli.scheduler_commands.exit') + @mock.patch('tethys_compute.models.Scheduler') + def test_schedulers_remove_command_does_not_exist(self, mock_scheduler, mock_exit, mock_pretty_output): + """ + Test for schedulers_remove_command. + For handling the Scheduler throwing ObjectDoesNotExist + :param mock_scheduler: mock for Scheduler + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_scheduler.objects.get.side_effect = ObjectDoesNotExist + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, schedulers_remove_command, mock_args) + + mock_scheduler.objects.get.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Command aborted.', po_call_args[0][0][0]) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_services_commands.py b/tests/unit_tests/test_tethys_apps/test_cli/test_services_commands.py new file mode 100644 index 000000000..3ee8c06f6 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_services_commands.py @@ -0,0 +1,574 @@ +try: + from StringIO import StringIO +except ImportError: + from io import StringIO # noqa: F401 +import unittest +import mock + +from tethys_apps.cli.services_commands import services_create_persistent_command, services_remove_persistent_command,\ + services_create_spatial_command, services_remove_spatial_command, services_list_command +from django.core.exceptions import ObjectDoesNotExist +from django.db.utils import IntegrityError + + +class ServicesCommandsTest(unittest.TestCase): + """ + Tests for tethys_apps.cli.services_commands + """ + + # Dictionary used in some of the tests + my_dict = {'id': 'Id_foo', 'name': 'Name_foo', 'host': 'Host_foo', 'port': 'Port_foo', 'endpoint': 'EndPoint_foo', + 'public_endpoint': 'PublicEndPoint_bar', 'apikey': 'APIKey_foo'} + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_services.models.PersistentStoreService') + def test_services_create_persistent_command(self, mock_service, mock_pretty_output): + """ + Test for services_create_persistent_command. + For running the test without any errors or problems. + :param mock_service: mock for PersistentStoreService + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + services_create_persistent_command(mock_args) + + mock_service.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Successfully created new Persistent Store Service!', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_services.models.PersistentStoreService') + def test_services_create_persistent_command_exception_indexerror(self, mock_service, mock_pretty_output): + """ + Test for services_create_persistent_command. + For running the test with an IndexError exception thrown. + :param mock_service: mock for PersistentStoreService + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_service.side_effect = IndexError + services_create_persistent_command(mock_args) + + mock_service.assert_called() + mock_service.objects.get().save.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('The connection argument (-c) must be of the form', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_services.models.PersistentStoreService') + def test_services_create_persistent_command_exception_integrityerror(self, mock_service, mock_pretty_output): + """ + Test for services_create_persistent_command. + For running the test with an IntegrityError exception thrown. + :param mock_service: mock for PersistentStoreService + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_service.side_effect = IntegrityError + services_create_persistent_command(mock_args) + + mock_service.assert_called() + mock_service.objects.get().save.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Persistent Store Service with name', po_call_args[0][0][0]) + self.assertIn('already exists. Command aborted.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_apps.cli.services_commands.exit') + @mock.patch('tethys_services.models.PersistentStoreService') + def test_services_remove_persistent_command_Exceptions(self, mock_service, mock_exit, mock_pretty_output): + """ + Test for services_remove_persistent_command + Test for handling all exceptions thrown by the function. + :param mock_service: mock for PersistentStoreService + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = True + mock_service.objects.get.side_effect = [ValueError, ObjectDoesNotExist] + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, services_remove_persistent_command, mock_args) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('A Persistent Store Service with ID/Name', po_call_args[0][0][0]) + self.assertIn('does not exist', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_apps.cli.services_commands.exit') + @mock.patch('tethys_services.models.PersistentStoreService') + def test_services_remove_persistent_command_force(self, mock_service, mock_exit, mock_pretty_output): + """ + Test for services_remove_persistent_command + Test for forcing a delete of the service + :param mock_service: mock for PersistentStoreService + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = True + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, services_remove_persistent_command, mock_args) + + mock_service.objects.get().delete.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Successfully removed Persistent Store Service', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.input') + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_apps.cli.services_commands.exit') + @mock.patch('tethys_services.models.PersistentStoreService') + def test_services_remove_persistent_command_no_proceed_invalid_char(self, mock_service, mock_exit, + mock_pretty_output, mock_input): + """ + Test for services_remove_persistent_command + Handles answering the prompt to delete with invalid characters, and answering no. + :param mock_service: mock for PersistentStoreService + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :param mock_input: mock for handling raw_input requests + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = False + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + mock_input.side_effect = ['foo', 'N'] + + self.assertRaises(SystemExit, services_remove_persistent_command, mock_args) + + mock_service.objects.get().delete.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Aborted. Persistent Store Service not removed.', po_call_args[0][0][0]) + + po_call_args = mock_input.call_args_list + self.assertEqual(2, len(po_call_args)) + self.assertEqual('Are you sure you want to delete this Persistent Store Service? [y/n]: ', + po_call_args[0][0][0]) + self.assertEqual('Please enter either "y" or "n": ', po_call_args[1][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.input') + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_apps.cli.services_commands.exit') + @mock.patch('tethys_services.models.PersistentStoreService') + def test_services_remove_persistent_command_proceed(self, mock_service, mock_exit, mock_pretty_output, mock_input): + """ + Test for services_remove_persistent_command + Handles answering the prompt to delete with invalid characters by answering yes + :param mock_service: mock for PersistentStoreService + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :param mock_input: mock for handling raw_input requests + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = False + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + mock_input.side_effect = ['y'] + + self.assertRaises(SystemExit, services_remove_persistent_command, mock_args) + + mock_service.objects.get().delete.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Successfully removed Persistent Store Service', po_call_args[0][0][0]) + + po_call_args = mock_input.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Are you sure you want to delete this Persistent Store Service? [y/n]: ', + po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_services.models.SpatialDatasetService') + def test_services_create_spatial_command_IndexError(self, mock_service, mock_pretty_output): + """ + Test for services_create_spatial_command + Handles an IndexError exception + :param mock_service: mock for SpatialDatasetService + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_args.connection = 'IndexError:9876@IndexError' # No 'http' or '://' + + services_create_spatial_command(mock_args) + + mock_service.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('The connection argument (-c) must be of the form', po_call_args[0][0][0]) + self.assertIn('":@//:".', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_services.models.SpatialDatasetService') + def test_services_create_spatial_command_FormatError(self, mock_service, mock_pretty_output): + """ + Test for services_create_spatial_command + Handles an FormatError exception + :param mock_service: mock for SpatialDatasetService + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_args.connection = 'foo:pass@http:://foo:1234' + mock_args.public_endpoint = 'foo@foo:foo' # No 'http' or '://' + + services_create_spatial_command(mock_args) + + mock_service.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('The public_endpoint argument (-p) must be of the form ', po_call_args[0][0][0]) + self.assertIn('"//:".', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_services.models.SpatialDatasetService') + def test_services_create_spatial_command_IntegrityError(self, mock_service, mock_pretty_output): + """ + Test for services_create_spatial_command + Handles an IntegrityError exception + :param mock_service: mock for SpatialDatasetService + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_args.connection = 'foo:pass@http:://foo:1234' + mock_args.public_endpoint = 'http://foo:1234' + mock_service.side_effect = IntegrityError + + services_create_spatial_command(mock_args) + + mock_service.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Spatial Dataset Service with name ', po_call_args[0][0][0]) + self.assertIn('already exists. Command aborted.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_services.models.SpatialDatasetService') + def test_services_create_spatial_command(self, mock_service, mock_pretty_output): + """ + Test for services_create_spatial_command + For going through the function and saving + :param mock_service: mock for SpatialDatasetService + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_args.connection = 'foo:pass@http:://foo:1234' + mock_args.public_endpoint = 'http://foo:1234' + mock_service.return_value = mock.MagicMock() + + services_create_spatial_command(mock_args) + + mock_service.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Successfully created new Spatial Dataset Service!', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_apps.cli.services_commands.exit') + @mock.patch('tethys_services.models.SpatialDatasetService') + def test_services_remove_spatial_command_Exceptions(self, mock_service, mock_exit, mock_pretty_output): + """ + Test for services_remove_spatial_command + Handles testing all of the exceptions thrown + :param mock_service: mock for SpatialDatasetService + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_service.objects.get.side_effect = [ValueError, ObjectDoesNotExist] + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, services_remove_spatial_command, mock_args) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('A Spatial Dataset Service with ID/Name', po_call_args[0][0][0]) + self.assertIn('does not exist.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_apps.cli.services_commands.exit') + @mock.patch('tethys_services.models.SpatialDatasetService') + def test_services_remove_spatial_command_force(self, mock_service, mock_exit, mock_pretty_output): + """ + Test for services_remove_spatial_command + For when a delete is forced + :param mock_service: mock for SpatialDatasetService + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = True + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, services_remove_spatial_command, mock_args) + + mock_service.objects.get().delete.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Successfully removed Spatial Dataset Service', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.input') + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_apps.cli.services_commands.exit') + @mock.patch('tethys_services.models.SpatialDatasetService') + def test_services_remove_spatial_command_no_proceed_invalid_char(self, mock_service, mock_exit, + mock_pretty_output, mock_input): + """ + Test for services_remove_spatial_command + For when deleting is not forced, and when prompted, giving an invalid answer, then no delete + :param mock_service: mock for SpatialDatasetService + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :param mock_input: mock for handling raw_input requests + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = False + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + mock_input.side_effect = ['foo', 'N'] + + self.assertRaises(SystemExit, services_remove_spatial_command, mock_args) + + mock_service.objects.get().delete.assert_not_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Aborted. Spatial Dataset Service not removed.', po_call_args[0][0][0]) + + po_call_args = mock_input.call_args_list + self.assertEqual(2, len(po_call_args)) + self.assertEqual('Are you sure you want to delete this Persistent Store Service? [y/n]: ', + po_call_args[0][0][0]) + self.assertEqual('Please enter either "y" or "n": ', po_call_args[1][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.input') + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_apps.cli.services_commands.exit') + @mock.patch('tethys_services.models.SpatialDatasetService') + def test_services_remove_spatial_command_proceed(self, mock_service, mock_exit, mock_pretty_output, mock_input): + """ + Test for services_remove_spatial_command + For when deleting is not forced, and when prompted, giving a valid answer to delete + :param mock_service: mock for SpatialDatasetService + :param mock_exit: mock for handling exit() code in function + :param mock_pretty_output: mock for pretty_output text + :param mock_input: mock for handling raw_input requests + :return: + """ + mock_args = mock.MagicMock() + mock_args.force = False + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + mock_input.side_effect = ['y'] + + self.assertRaises(SystemExit, services_remove_spatial_command, mock_args) + + mock_service.objects.get().delete.assert_called() + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Successfully removed Spatial Dataset Service', po_call_args[0][0][0]) + + po_call_args = mock_input.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Are you sure you want to delete this Persistent Store Service? [y/n]: ', + po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.print') + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_services.models.PersistentStoreService') + @mock.patch('tethys_services.models.SpatialDatasetService') + @mock.patch('tethys_apps.cli.services_commands.model_to_dict') + def test_services_list_command_not_spatial_not_persistent(self, mock_mtd, mock_spatial, mock_persistent, + mock_pretty_output, mock_print): + """ + Test for services_list_command + Both spatial and persistent are not set, so both are processed + :param mock_mtd: mock for model_to_dict to return a dictionary + :param mock_spatial: mock for SpatialDatasetService + :param mock_persistent: mock for PersistentStoreService + :param mock_pretty_output: mock for pretty_output text + :param mock_stdout: mock for text written with print statements + :return: + """ + mock_mtd.return_value = self.my_dict + mock_args = mock.MagicMock() + mock_args.spatial = False + mock_args.persistent = False + mock_spatial.objects.order_by('id').all.return_value = [mock.MagicMock(), mock.MagicMock(), mock.MagicMock()] + mock_persistent.objects.order_by('id').all.return_value = [mock.MagicMock(), mock.MagicMock()] + + services_list_command(mock_args) + + # Check expected pretty_output + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(4, len(po_call_args)) + self.assertIn('Persistent Store Services:', po_call_args[0][0][0]) + self.assertIn('ID', po_call_args[1][0][0]) + self.assertIn('Name', po_call_args[1][0][0]) + self.assertIn('Host', po_call_args[1][0][0]) + self.assertIn('Port', po_call_args[1][0][0]) + self.assertNotIn('Endpoint', po_call_args[1][0][0]) + self.assertNotIn('Public Endpoint', po_call_args[1][0][0]) + self.assertNotIn('API Key', po_call_args[1][0][0]) + self.assertIn('Spatial Dataset Services:', po_call_args[2][0][0]) + self.assertIn('ID', po_call_args[3][0][0]) + self.assertIn('Name', po_call_args[3][0][0]) + self.assertNotIn('Host', po_call_args[3][0][0]) + self.assertNotIn('Port', po_call_args[3][0][0]) + self.assertIn('Endpoint', po_call_args[3][0][0]) + self.assertIn('Public Endpoint', po_call_args[3][0][0]) + self.assertIn('API Key', po_call_args[3][0][0]) + + # Check text written with Python's print + rts_call_args = mock_print.call_args_list + self.assertIn(self.my_dict['id'], rts_call_args[0][0][0]) + self.assertIn(self.my_dict['name'], rts_call_args[0][0][0]) + self.assertIn(self.my_dict['host'], rts_call_args[0][0][0]) + self.assertIn(self.my_dict['port'], rts_call_args[0][0][0]) + self.assertIn(self.my_dict['id'], rts_call_args[4][0][0]) + self.assertIn(self.my_dict['name'], rts_call_args[4][0][0]) + self.assertNotIn(self.my_dict['host'], rts_call_args[4][0][0]) + self.assertNotIn(self.my_dict['port'], rts_call_args[4][0][0]) + self.assertIn(self.my_dict['endpoint'], rts_call_args[4][0][0]) + self.assertIn(self.my_dict['public_endpoint'], rts_call_args[4][0][0]) + self.assertIn(self.my_dict['apikey'], rts_call_args[4][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.print') + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_services.models.SpatialDatasetService') + @mock.patch('tethys_apps.cli.services_commands.model_to_dict') + def test_services_list_command_spatial(self, mock_mtd, mock_spatial, mock_pretty_output, mock_print): + """ + Test for services_list_command + Only spatial is set + :param mock_mtd: mock for model_to_dict to return a dictionary + :param mock_spatial: mock for SpatialDatasetService + :param mock_pretty_output: mock for pretty_output text + :param mock_stdout: mock for text written with print statements + :return: + """ + mock_mtd.return_value = self.my_dict + mock_args = mock.MagicMock() + mock_args.spatial = True + mock_args.persistent = False + mock_spatial.objects.order_by('id').all.return_value = [mock.MagicMock(), mock.MagicMock(), mock.MagicMock()] + + services_list_command(mock_args) + + # Check expected pretty_output + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(2, len(po_call_args)) + self.assertIn('Spatial Dataset Services:', po_call_args[0][0][0]) + self.assertIn('ID', po_call_args[1][0][0]) + self.assertIn('Name', po_call_args[1][0][0]) + self.assertNotIn('Host', po_call_args[1][0][0]) + self.assertNotIn('Port', po_call_args[1][0][0]) + self.assertIn('Endpoint', po_call_args[1][0][0]) + self.assertIn('Public Endpoint', po_call_args[1][0][0]) + self.assertIn('API Key', po_call_args[1][0][0]) + + # Check text written with Python's print + rts_call_args = mock_print.call_args_list + + self.assertIn(self.my_dict['id'], rts_call_args[2][0][0]) + self.assertIn(self.my_dict['name'], rts_call_args[2][0][0]) + self.assertNotIn(self.my_dict['host'], rts_call_args[2][0][0]) + self.assertNotIn(self.my_dict['port'], rts_call_args[2][0][0]) + self.assertIn(self.my_dict['endpoint'], rts_call_args[2][0][0]) + self.assertIn(self.my_dict['public_endpoint'], rts_call_args[2][0][0]) + self.assertIn(self.my_dict['apikey'], rts_call_args[2][0][0]) + + @mock.patch('tethys_apps.cli.services_commands.print') + @mock.patch('tethys_apps.cli.services_commands.pretty_output') + @mock.patch('tethys_services.models.PersistentStoreService') + @mock.patch('tethys_apps.cli.services_commands.model_to_dict') + def test_services_list_command_persistent(self, mock_mtd, mock_persistent, mock_pretty_output, mock_print): + """ + Test for services_list_command + Only persistent is set + :param mock_mtd: mock for model_to_dict to return a dictionary + :param mock_persistent: mock for PersistentStoreService + :param mock_pretty_output: mock for pretty_output text + :param mock_stdout: mock for text written with print statements + :return: + """ + mock_mtd.return_value = self.my_dict + mock_args = mock.MagicMock() + mock_args.spatial = False + mock_args.persistent = True + mock_persistent.objects.order_by('id').all.return_value = [mock.MagicMock(), mock.MagicMock()] + + services_list_command(mock_args) + + # Check expected pretty_output + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(2, len(po_call_args)) + self.assertIn('Persistent Store Services:', po_call_args[0][0][0]) + self.assertIn('ID', po_call_args[1][0][0]) + self.assertIn('Name', po_call_args[1][0][0]) + self.assertIn('Host', po_call_args[1][0][0]) + self.assertIn('Port', po_call_args[1][0][0]) + self.assertNotIn('Endpoint', po_call_args[1][0][0]) + self.assertNotIn('Public Endpoint', po_call_args[1][0][0]) + self.assertNotIn('API Key', po_call_args[1][0][0]) + + # Check text written with Python's print + rts_call_args = mock_print.call_args_list + + self.assertIn(self.my_dict['id'], rts_call_args[1][0][0]) + self.assertIn(self.my_dict['name'], rts_call_args[1][0][0]) + self.assertIn(self.my_dict['host'], rts_call_args[1][0][0]) + self.assertIn(self.my_dict['port'], rts_call_args[1][0][0]) + self.assertNotIn(self.my_dict['endpoint'], rts_call_args[1][0][0]) + self.assertNotIn(self.my_dict['public_endpoint'], rts_call_args[1][0][0]) + self.assertNotIn(self.my_dict['apikey'], rts_call_args[1][0][0]) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_syncstores_command.py b/tests/unit_tests/test_tethys_apps/test_cli/test_syncstores_command.py new file mode 100644 index 000000000..03dfbd680 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_syncstores_command.py @@ -0,0 +1,119 @@ +import unittest +import mock + +from tethys_apps.cli.syncstores_command import syncstores_command + + +class SyncstoresCommandTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.cli.syncstores_command.subprocess.call') + @mock.patch('tethys_apps.cli.syncstores_command.get_manage_path') + def test_syncstores_command_no_args(self, mock_get_manage_path, mock_subprocess_call): + mock_args = mock.MagicMock() + mock_args.refresh = False + mock_args.firsttime = False + mock_args.database = False + mock_args.app = False + mock_get_manage_path.return_value = '/foo/manage.py' + mock_subprocess_call.return_value = True + + syncstores_command(mock_args) + + mock_get_manage_path.assert_called_once() + mock_subprocess_call.assert_called_once() + mock_subprocess_call.assert_called_with(['python', '/foo/manage.py', 'syncstores']) + + @mock.patch('tethys_apps.cli.syncstores_command.subprocess.call') + @mock.patch('tethys_apps.cli.syncstores_command.get_manage_path') + def test_syncstores_command_args_no_refresh(self, mock_get_manage_path, mock_subprocess_call): + mock_args = mock.MagicMock() + mock_args.refresh = False + mock_args.firsttime = True + mock_args.database = 'foo_db' + mock_args.app = ['foo_app'] + mock_get_manage_path.return_value = '/foo/manage.py' + mock_subprocess_call.return_value = True + + syncstores_command(mock_args) + + mock_get_manage_path.assert_called_once() + mock_subprocess_call.assert_called_once() + mock_subprocess_call.assert_called_with(['python', '/foo/manage.py', 'syncstores', '-f', '-d', 'foo_db', + 'foo_app']) + + @mock.patch('tethys_apps.cli.syncstores_command.subprocess.call') + @mock.patch('tethys_apps.cli.syncstores_command.get_manage_path') + def test_syncstores_command_no_args_assert(self, mock_get_manage_path, mock_subprocess_call): + mock_args = mock.MagicMock() + mock_args.refresh = False + mock_args.firsttime = False + mock_args.database = False + mock_args.app = False + mock_get_manage_path.return_value = '/foo/manage.py' + mock_subprocess_call.side_effect = KeyboardInterrupt + + syncstores_command(mock_args) + + mock_get_manage_path.assert_called_once() + mock_subprocess_call.assert_called_once() + mock_subprocess_call.assert_called_with(['python', '/foo/manage.py', 'syncstores']) + + @mock.patch('tethys_apps.cli.syncstores_command.input') + @mock.patch('tethys_apps.cli.syncstores_command.subprocess.call') + @mock.patch('tethys_apps.cli.syncstores_command.get_manage_path') + def test_syncstores_command_refresh_continue(self, mock_get_manage_path, mock_subprocess_call, mock_input): + mock_args = mock.MagicMock() + mock_args.refresh = True + mock_args.firsttime = False + mock_args.database = False + mock_args.app = ['foo_app'] + mock_get_manage_path.return_value = '/foo/manage.py' + mock_subprocess_call.return_value = True + mock_input.side_effect = ['foo', 'y'] + + syncstores_command(mock_args) + + mock_get_manage_path.assert_called_once() + mock_subprocess_call.assert_called_once() + mock_subprocess_call.assert_called_with(['python', '/foo/manage.py', 'syncstores', '-r', 'foo_app']) + po_call_args = mock_input.call_args_list + self.assertEqual(2, len(po_call_args)) + self.assertIn('WARNING', po_call_args[0][0][0]) + self.assertIn('Invalid option. Do you wish to continue?', po_call_args[1][0][0]) + + @mock.patch('tethys_apps.cli.syncstores_command.print') + @mock.patch('tethys_apps.cli.syncstores_command.exit') + @mock.patch('tethys_apps.cli.syncstores_command.input') + @mock.patch('tethys_apps.cli.syncstores_command.subprocess.call') + @mock.patch('tethys_apps.cli.syncstores_command.get_manage_path') + def test_syncstores_command_refresh_exit(self, mock_get_manage_path, mock_subprocess_call, mock_input, mock_exit, + mock_print): + mock_args = mock.MagicMock() + mock_args.refresh = True + mock_args.firsttime = False + mock_args.database = False + mock_args.app = ['foo_app'] + mock_get_manage_path.return_value = '/foo/manage.py' + mock_subprocess_call.return_value = True + mock_input.side_effect = ['n'] + mock_exit.side_effect = SystemExit + + self.assertRaises(SystemExit, syncstores_command, mock_args) + mock_exit.assert_called_with(0) + + mock_get_manage_path.assert_called_once() + mock_subprocess_call.assert_not_called() + + po_call_args = mock_input.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('WARNING', po_call_args[0][0][0]) + + # Check print statement + rts_call_args = mock_print.call_args_list + self.assertIn('Operation cancelled by user.', rts_call_args[0][0][0]) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_test_command.py b/tests/unit_tests/test_tethys_apps/test_cli/test_test_command.py new file mode 100644 index 000000000..32f1b2633 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_test_command.py @@ -0,0 +1,212 @@ +import unittest +import mock + +from tethys_apps.cli.test_command import test_command + + +class TestCommandTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.cli.test_command.run_process') + @mock.patch('tethys_apps.cli.test_command.os.path.join') + @mock.patch('tethys_apps.cli.test_command.get_manage_path') + def test_test_command_no_coverage_file(self, mock_get_manage_path, mock_join, mock_run_process): + mock_args = mock.MagicMock() + mock_args.coverage = False + mock_args.coverage_html = False + mock_args.file = 'foo_file' + mock_args.unit = False + mock_args.gui = False + mock_get_manage_path.return_value = '/foo/manage.py' + mock_join.return_value = '/foo' + mock_run_process.return_value = 0 + + self.assertRaises(SystemExit, test_command, mock_args) + mock_get_manage_path.assert_called() + mock_join.assert_called() + mock_run_process.assert_called_once() + mock_run_process.assert_called_with(['python', '/foo/manage.py', 'test', 'foo_file']) + + @mock.patch('tethys_apps.cli.test_command.run_process') + @mock.patch('tethys_apps.cli.test_command.os.path.join') + @mock.patch('tethys_apps.cli.test_command.get_manage_path') + def test_test_command_coverage_unit(self, mock_get_manage_path, mock_join, mock_run_process): + mock_args = mock.MagicMock() + mock_args.coverage = True + mock_args.coverage_html = False + mock_args.file = None + mock_args.unit = True + mock_args.gui = False + mock_get_manage_path.return_value = '/foo/manage.py' + mock_join.return_value = '/foo' + mock_run_process.return_value = 0 + + self.assertRaises(SystemExit, test_command, mock_args) + mock_get_manage_path.assert_called() + mock_join.assert_called() + mock_run_process.assert_called() + mock_run_process.assert_any_call(['coverage', 'run', '--rcfile=/foo', '/foo/manage.py', 'test', '/foo']) + mock_run_process.assert_called_with(['coverage', 'report', '--rcfile=/foo']) + + @mock.patch('tethys_apps.cli.test_command.run_process') + @mock.patch('tethys_apps.cli.test_command.os.path.join') + @mock.patch('tethys_apps.cli.test_command.get_manage_path') + def test_test_command_coverage_unit_file_app_package(self, mock_get_manage_path, mock_join, mock_run_process): + mock_args = mock.MagicMock() + mock_args.coverage = True + mock_args.coverage_html = False + mock_args.file = '/foo/tethys_apps.tethysapp.foo' + mock_args.unit = True + mock_args.gui = False + mock_get_manage_path.return_value = '/foo/manage.py' + mock_join.return_value = '/foo' + mock_run_process.return_value = 0 + + self.assertRaises(SystemExit, test_command, mock_args) + mock_get_manage_path.assert_called() + mock_join.assert_called() + mock_run_process.assert_called() + mock_run_process.assert_any_call(['coverage', 'run', '--source=tethys_apps.tethysapp.foo,tethysapp.foo', + '/foo/manage.py', 'test', '/foo/tethys_apps.tethysapp.foo']) + mock_run_process.assert_called_with(['coverage', 'report']) + + @mock.patch('tethys_apps.cli.test_command.run_process') + @mock.patch('tethys_apps.cli.test_command.os.path.join') + @mock.patch('tethys_apps.cli.test_command.get_manage_path') + def test_test_command_coverage_html_unit_file_app_package(self, mock_get_manage_path, mock_join, mock_run_process): + mock_args = mock.MagicMock() + mock_args.coverage = False + mock_args.coverage_html = True + mock_args.file = '/foo/tethys_apps.tethysapp.foo' + mock_args.unit = True + mock_args.gui = False + mock_get_manage_path.return_value = '/foo/manage.py' + mock_join.return_value = '/foo' + mock_run_process.return_value = 0 + + self.assertRaises(SystemExit, test_command, mock_args) + mock_get_manage_path.assert_called() + mock_join.assert_called() + mock_run_process.assert_called() + mock_run_process.assert_any_call(['coverage', 'run', '--source=tethys_apps.tethysapp.foo,tethysapp.foo', + '/foo/manage.py', 'test', '/foo/tethys_apps.tethysapp.foo']) + mock_run_process.assert_any_call(['coverage', 'html', '--directory=/foo']) + mock_run_process.assert_called_with(['open', '/foo']) + + @mock.patch('tethys_apps.cli.test_command.run_process') + @mock.patch('tethys_apps.cli.test_command.os.path.join') + @mock.patch('tethys_apps.cli.test_command.get_manage_path') + def test_test_command_coverage_unit_file_extension_package(self, mock_get_manage_path, mock_join, mock_run_process): + mock_args = mock.MagicMock() + mock_args.coverage = True + mock_args.coverage_html = False + mock_args.file = '/foo/tethysext.foo' + mock_args.unit = True + mock_args.gui = False + mock_get_manage_path.return_value = '/foo/manage.py' + mock_join.return_value = '/foo' + mock_run_process.return_value = 0 + + self.assertRaises(SystemExit, test_command, mock_args) + mock_get_manage_path.assert_called() + mock_join.assert_called() + mock_run_process.assert_called() + mock_run_process.assert_any_call(['coverage', 'run', '--source=tethysext.foo,tethysext.foo', '/foo/manage.py', + 'test', '/foo/tethysext.foo']) + mock_run_process.assert_called_with(['coverage', 'report']) + + @mock.patch('tethys_apps.cli.test_command.run_process') + @mock.patch('tethys_apps.cli.test_command.os.path.join') + @mock.patch('tethys_apps.cli.test_command.get_manage_path') + def test_test_command_coverage_html_gui_file(self, mock_get_manage_path, mock_join, mock_run_process): + mock_args = mock.MagicMock() + mock_args.coverage = False + mock_args.coverage_html = True + mock_args.file = 'foo_file' + mock_args.unit = False + mock_args.gui = True + mock_get_manage_path.return_value = '/foo/manage.py' + mock_join.return_value = '/foo/bar' + mock_run_process.return_value = 0 + + self.assertRaises(SystemExit, test_command, mock_args) + mock_get_manage_path.assert_called() + mock_join.assert_called() + mock_run_process.assert_called() + mock_run_process.assert_any_call(['coverage', 'run', '--rcfile=/foo/bar', '/foo/manage.py', 'test', 'foo_file']) + mock_run_process.assert_any_call(['coverage', 'html', '--rcfile=/foo/bar']) + mock_run_process.assert_called_with(['open', '/foo/bar']) + + @mock.patch('tethys_apps.cli.test_command.webbrowser.open_new_tab') + @mock.patch('tethys_apps.cli.test_command.run_process') + @mock.patch('tethys_apps.cli.test_command.os.path.join') + @mock.patch('tethys_apps.cli.test_command.get_manage_path') + def test_test_command_coverage_html_gui_file_exception(self, mock_get_manage_path, mock_join, mock_run_process, + mock_open_new_tab): + mock_args = mock.MagicMock() + mock_args.coverage = False + mock_args.coverage_html = True + mock_args.file = 'foo_file' + mock_args.unit = False + mock_args.gui = True + mock_get_manage_path.return_value = '/foo/manage.py' + mock_join.side_effect = ['/foo/bar', '/foo/bar2', '/foo/bar3', '/foo/bar4', '/foo/bar5', '/foo/bar6'] + mock_run_process.side_effect = [0, 0, 1] + mock_open_new_tab.return_value = 1 + + self.assertRaises(SystemExit, test_command, mock_args) + mock_get_manage_path.assert_called() + mock_join.assert_called() + mock_run_process.assert_called() + mock_run_process.assert_any_call(['coverage', 'run', '--rcfile=/foo/bar2', '/foo/manage.py', + 'test', 'foo_file']) + mock_run_process.assert_any_call(['coverage', 'html', '--rcfile=/foo/bar2']) + mock_run_process.assert_called_with(['open', '/foo/bar3']) + mock_open_new_tab.assert_called_once() + mock_open_new_tab.assert_called_with('/foo/bar4') + + @mock.patch('tethys_apps.cli.test_command.run_process') + @mock.patch('tethys_apps.cli.test_command.os.path.join') + @mock.patch('tethys_apps.cli.test_command.get_manage_path') + def test_test_command_unit_no_file(self, mock_get_manage_path, mock_join, mock_run_process): + mock_args = mock.MagicMock() + mock_args.coverage = False + mock_args.coverage_html = False + mock_args.file = None + mock_args.unit = True + mock_args.gui = False + mock_get_manage_path.return_value = '/foo/manage.py' + mock_join.return_value = '/foo' + mock_run_process.return_value = 0 + + self.assertRaises(SystemExit, test_command, mock_args) + + mock_get_manage_path.assert_called() + mock_join.assert_called() + mock_run_process.assert_called_once() + mock_run_process.assert_called_with(['python', '/foo/manage.py', 'test', '/foo']) + + @mock.patch('tethys_apps.cli.test_command.run_process') + @mock.patch('tethys_apps.cli.test_command.os.path.join') + @mock.patch('tethys_apps.cli.test_command.get_manage_path') + def test_test_command_gui_no_file(self, mock_get_manage_path, mock_join, mock_run_process): + mock_args = mock.MagicMock() + mock_args.coverage = False + mock_args.coverage_html = False + mock_args.file = None + mock_args.unit = False + mock_args.gui = True + mock_get_manage_path.return_value = '/foo/manage.py' + mock_join.return_value = '/foo' + mock_run_process.return_value = 0 + + self.assertRaises(SystemExit, test_command, mock_args) + mock_get_manage_path.assert_called() + mock_join.assert_called() + mock_run_process.assert_called_once() + mock_run_process.assert_called_with(['python', '/foo/manage.py', 'test', '/foo']) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_uninstall_command.py b/tests/unit_tests/test_tethys_apps/test_cli/test_uninstall_command.py new file mode 100644 index 000000000..b2312b2bc --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_uninstall_command.py @@ -0,0 +1,58 @@ +import unittest +import mock + +from tethys_apps.cli.uninstall_command import uninstall_command + + +class UninstallCommandTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.cli.uninstall_command.subprocess.call') + @mock.patch('tethys_apps.cli.uninstall_command.get_manage_path') + def test_uninstall_command(self, mock_get_manage_path, mock_subprocess_call): + mock_args = mock.MagicMock() + mock_args.is_extension = True + mock_args.app_or_extension = 'foo_ext' + mock_get_manage_path.return_value = '/foo/manage.py' + mock_subprocess_call.return_value = True + + uninstall_command(mock_args) + + mock_get_manage_path.assert_called_once() + mock_subprocess_call.assert_called_once() + mock_subprocess_call.assert_called_with(['python', '/foo/manage.py', 'tethys_app_uninstall', 'foo_ext', '-e']) + + @mock.patch('tethys_apps.cli.uninstall_command.subprocess.call') + @mock.patch('tethys_apps.cli.uninstall_command.get_manage_path') + def test_uninstall_command_no_extension(self, mock_get_manage_path, mock_subprocess_call): + mock_args = mock.MagicMock() + mock_args.is_extension = False + mock_args.app_or_extension = 'foo_app' + mock_get_manage_path.return_value = '/foo/manage.py' + mock_subprocess_call.return_value = True + + uninstall_command(mock_args) + + mock_get_manage_path.assert_called_once() + mock_subprocess_call.assert_called_once() + mock_subprocess_call.assert_called_with(['python', '/foo/manage.py', 'tethys_app_uninstall', 'foo_app']) + + @mock.patch('tethys_apps.cli.uninstall_command.subprocess.call') + @mock.patch('tethys_apps.cli.uninstall_command.get_manage_path') + def test_uninstall_command_assert(self, mock_get_manage_path, mock_subprocess_call): + mock_args = mock.MagicMock() + mock_args.is_extension = True + mock_args.app_or_extension = 'foo_ext' + mock_get_manage_path.return_value = '/foo/manage.py' + mock_subprocess_call.side_effect = KeyboardInterrupt + + uninstall_command(mock_args) + + mock_get_manage_path.assert_called_once() + mock_subprocess_call.assert_called_once() + mock_subprocess_call.assert_called_with(['python', '/foo/manage.py', 'tethys_app_uninstall', 'foo_ext', '-e']) diff --git a/tests/unit_tests/test_tethys_apps/test_context_processors.py b/tests/unit_tests/test_tethys_apps/test_context_processors.py new file mode 100644 index 000000000..3e77a0c11 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_context_processors.py @@ -0,0 +1,83 @@ +import unittest +import mock + +from django.db import models +from tethys_apps.context_processors import tethys_apps_context +from tethys_apps.models import TethysApp +from tethys_compute.utilities import ListField + + +class ContextProcessorsTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.context_processors.get_active_app') + def test_tethys_apps_context(self, mock_get_active_app): + mock_args = mock.MagicMock() + app = TethysApp() + app.id = 'foo.id' + app.name = models.CharField(max_length=200, default='') + app.name.value = 'foo.name' + app.icon = models.CharField(max_length=200, default='') + app.icon.value = 'foo.icon' + app.color = models.CharField(max_length=10, default='') + app.color.value = '#foobar' + app.tags = models.CharField(max_length=200, blank=True, default='') + app.tags.value = 'tags' + app.description = models.TextField(max_length=1000, blank=True, default='') + app.description.value = 'foo.description' + app.enable_feedback = models.BooleanField(default=True) + app.enable_feedback.value = False + app.feedback_emails = ListField(default='', blank=True) + mock_get_active_app.return_value = app + + context = tethys_apps_context(mock_args) + + mock_get_active_app.assert_called_once() + self.assertEqual('foo.id', context['tethys_app']['id']) + self.assertEqual('foo.name', context['tethys_app']['name'].value) + self.assertEqual('foo.icon', context['tethys_app']['icon'].value) + self.assertEqual('#foobar', context['tethys_app']['color'].value) + self.assertEqual('tags', context['tethys_app']['tags'].value) + self.assertEqual('foo.description', context['tethys_app']['description'].value) + self.assertFalse('enable_feedback' in context['tethys_app']) + self.assertFalse('feedback_emails' in context['tethys_app']) + + @mock.patch('tethys_apps.context_processors.get_active_app') + def test_tethys_apps_context_feedback(self, mock_get_active_app): + mock_args = mock.MagicMock() + app = TethysApp() + app.id = 'foo.id' + app.name = models.CharField(max_length=200, default='') + app.name.value = 'foo.name' + app.icon = models.CharField(max_length=200, default='') + app.icon.value = 'foo.icon' + app.color = models.CharField(max_length=10, default='') + app.color.value = '#foobar' + app.tags = models.CharField(max_length=200, blank=True, default='') + app.tags.value = 'tags' + app.description = models.TextField(max_length=1000, blank=True, default='') + app.description.value = 'foo.description' + app.enable_feedback = models.BooleanField(default=True) + app.enable_feedback.value = True + app.feedback_emails = ListField(default='', blank=True) + app.feedback_emails.append('foo.feedback') + mock_get_active_app.return_value = app + + context = tethys_apps_context(mock_args) + + mock_get_active_app.assert_called_once() + self.assertEqual('foo.id', context['tethys_app']['id']) + self.assertEqual('foo.name', context['tethys_app']['name'].value) + self.assertEqual('foo.icon', context['tethys_app']['icon'].value) + self.assertEqual('#foobar', context['tethys_app']['color'].value) + self.assertEqual('tags', context['tethys_app']['tags'].value) + self.assertEqual('foo.description', context['tethys_app']['description'].value) + self.assertTrue('enable_feedback' in context['tethys_app']) + self.assertTrue('feedback_emails' in context['tethys_app']) + self.assertEqual(True, context['tethys_app']['enable_feedback'].value) + self.assertEqual(['foo.feedback'], context['tethys_app']['feedback_emails']) diff --git a/tests/unit_tests/test_tethys_apps/test_decorators.py b/tests/unit_tests/test_tethys_apps/test_decorators.py new file mode 100644 index 000000000..bd727add4 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_decorators.py @@ -0,0 +1,149 @@ +import unittest +import mock + +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory +from django.http import HttpResponseRedirect + +from tethys_sdk.permissions import permission_required +from tests.factories.django_user import UserFactory + + +class DecoratorsTest(unittest.TestCase): + + def setUp(self): + self.request_factory = RequestFactory() + self.user = UserFactory() + + def tearDown(self): + pass + + @mock.patch('tethys_apps.decorators.messages') + @mock.patch('tethys_apps.decorators.has_permission', return_value=False) + def test_permission_required_no_pass_authenticated(self, _, mock_messages): + request = self.request_factory.get('/apps/test-app') + request.user = self.user + + @permission_required('create_projects') + def create_projects(request, *args, **kwargs): + return "expected_result" + + ret = create_projects(request) + + mock_messages.add_message.assert_called() + self.assertIsInstance(ret, HttpResponseRedirect) + self.assertEqual('/apps/', ret.url) + + @mock.patch('tethys_apps.decorators.messages') + @mock.patch('tethys_apps.decorators.has_permission', return_value=False) + def test_permission_required_no_pass_authenticated_with_referrer(self, _, mock_messages): + request = self.request_factory.get('/apps/test-app') + request.user = self.user + request.META['HTTP_REFERER'] = 'http://testserver/foo/bar' + + @permission_required('create_projects') + def create_projects(request, *args, **kwargs): + return "expected_result" + + ret = create_projects(request) + + mock_messages.add_message.assert_called() + self.assertIsInstance(ret, HttpResponseRedirect) + self.assertEqual('/foo/bar', ret.url) + + @mock.patch('tethys_apps.decorators.messages') + @mock.patch('tethys_apps.decorators.has_permission', return_value=False) + def test_permission_required_no_pass_not_authenticated(self, _, mock_messages): + request = self.request_factory.get('/apps/test-app') + request.user = AnonymousUser() + + @permission_required('create_projects') + def create_projects(request, *args, **kwargs): + return "expected_result" + + ret = create_projects(request) + + mock_messages.add_message.assert_called() + self.assertIsInstance(ret, HttpResponseRedirect) + self.assertIn('/accounts/login/', ret.url) + + @mock.patch('tethys_apps.decorators.messages') + @mock.patch('tethys_apps.decorators.has_permission', return_value=False) + def test_permission_required_message(self, _, mock_messages): + request = self.request_factory.get('/apps/test-app') + request.user = self.user + msg = 'A different message.' + + @permission_required('create_projects', message=msg) + def create_projects(request, *args, **kwargs): + return "expected_result" + + ret = create_projects(request) + + mock_messages.add_message.assert_called_with(request, mock_messages.WARNING, msg) + self.assertIsInstance(ret, HttpResponseRedirect) + self.assertEqual('/apps/', ret.url) + + def test_blank_permissions(self): + self.assertRaises(ValueError, permission_required) + + @mock.patch('tethys_apps.decorators.has_permission', return_value=True) + def test_multiple_permissions(self, mock_has_permission): + request = self.request_factory.get('/apps/test-app') + request.user = self.user + + @permission_required('create_projects', 'delete_projects') + def multiple_permissions(request, *args, **kwargs): + return "expected_result" + + ret = multiple_permissions(request) + self.assertEqual(ret, "expected_result") + hp_call_args = mock_has_permission.call_args_list + self.assertEqual(2, len(hp_call_args)) + self.assertEqual('create_projects', hp_call_args[0][0][1]) + self.assertEqual('delete_projects', hp_call_args[1][0][1]) + + @mock.patch('tethys_apps.decorators.has_permission', return_value=True) + def test_multiple_permissions_OR(self, _): + request = self.request_factory.get('/apps/test-app') + request.user = self.user + + @permission_required('create_projects', 'delete_projects', use_or=True) + def multiple_permissions_or(request, *args, **kwargs): + return "expected_result" + + self.assertEqual(multiple_permissions_or(request), "expected_result") + + @mock.patch('tethys_apps.decorators.tethys_portal_error', return_value=False) + @mock.patch('tethys_apps.decorators.has_permission', return_value=False) + def test_permission_required_exception_403(self, _, mock_tp_error): + request = self.request_factory.get('/apps/test-app') + request.user = self.user + + @permission_required('create_projects', raise_exception=True) + def exception_403(request, *args, **kwargs): + return "expected_result" + + exception_403(request) + mock_tp_error.handler_403.assert_called_with(request) + + def test_permission_required_no_request(self): + @permission_required('create_projects') + def no_request(request, *args, **kwargs): + return "expected_result" + + self.assertRaises(ValueError, no_request) + + @mock.patch('tethys_apps.decorators.has_permission', return_value=True) + def test_multiple_permissions_class_method(self, _): + request = self.request_factory.get('/apps/test-app') + request.user = self.user + + class Foo(object): + @permission_required('create_projects') + def method(self, request, *args, **kwargs): + return "expected_result" + + f = Foo() + + self.assertEqual(f.method(request), "expected_result") diff --git a/tests/unit_tests/test_tethys_apps/test_exceptions.py b/tests/unit_tests/test_tethys_apps/test_exceptions.py new file mode 100644 index 000000000..45386882c --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_exceptions.py @@ -0,0 +1,39 @@ +import unittest +from tethys_apps.exceptions import TethysAppSettingDoesNotExist, TethysAppSettingNotAssigned, \ + PersistentStoreDoesNotExist, PersistentStoreExists, PersistentStoreInitializerError, PersistentStorePermissionError + + +def raise_exception(exc, *args, **kwargs): + raise exc(*args, **kwargs) + + +class TestExceptions(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_tethys_app_setting_does_not_exist(self): + self.assertRaises(TethysAppSettingDoesNotExist, raise_exception, TethysAppSettingDoesNotExist, 'setting-type', + 'setting-name', 'app-name') + exc = TethysAppSettingDoesNotExist('setting-type', 'setting-name', 'app-name') + self.assertIn('setting-type', str(exc)) + self.assertIn('setting-name', str(exc)) + self.assertIn('app-name', str(exc)) + self.assertIn('does not exist', str(exc)) + + def test_tethys_app_settign_not_assigned(self): + self.assertRaises(TethysAppSettingNotAssigned, raise_exception, TethysAppSettingNotAssigned) + + def test_persistent_store_does_not_exist(self): + self.assertRaises(PersistentStoreDoesNotExist, raise_exception, PersistentStoreDoesNotExist) + + def test_persistent_store_exists(self): + self.assertRaises(PersistentStoreExists, raise_exception, PersistentStoreExists) + + def test_persistent_store_permission_error(self): + self.assertRaises(PersistentStorePermissionError, raise_exception, PersistentStorePermissionError) + + def test_persistent_store_initializer_error(self): + self.assertRaises(PersistentStoreInitializerError, raise_exception, PersistentStoreInitializerError) diff --git a/tests/unit_tests/test_tethys_apps/test_harvester.py b/tests/unit_tests/test_tethys_apps/test_harvester.py new file mode 100644 index 000000000..7c36eb966 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_harvester.py @@ -0,0 +1,242 @@ +import io +import unittest +import mock + +from django.db.utils import ProgrammingError +from tethys_apps.harvester import SingletonHarvester +from tethys_apps.base.testing.environment import set_testing_environment + + +class HarvesterTest(unittest.TestCase): + + def setUp(self): + set_testing_environment(False) + + def tearDown(self): + set_testing_environment(True) + + @mock.patch('tethys_apps.harvester.tethys_log') + @mock.patch('sys.stdout', new_callable=io.StringIO) + def test_harvest_extensions_apps(self, mock_stdout, _): + """ + Test for SingletonHarvester.harvest. + Checks for expected text output + :param mock_stdout: mock for text output + :return: + """ + shv = SingletonHarvester() + shv.harvest() + + self.assertIn('Loading Tethys Extensions...', mock_stdout.getvalue()) + self.assertIn('Tethys Extensions Loaded:', mock_stdout.getvalue()) + self.assertIn('test_extension', mock_stdout.getvalue()) + + @mock.patch('sys.stdout', new_callable=io.StringIO) + @mock.patch('pkgutil.iter_modules') + def test_harvest_extensions_exception(self, mock_pkgutil, mock_stdout): + """ + Test for SingletonHarvester.harvest. + With an exception thrown, when harvesting the extensions + :param mock_pkgutil: mock for the exception + :param mock_stdout: mock for the text output + :return: + """ + mock_pkgutil.side_effect = Exception + + shv = SingletonHarvester() + shv.harvest_extensions() + + self.assertIn('Loading Tethys Extensions...', mock_stdout.getvalue()) + self.assertNotIn('Tethys Extensions Loaded:', mock_stdout.getvalue()) + self.assertNotIn('test_extension', mock_stdout.getvalue()) + + def test_harvest_get_url_patterns(self): + """ + Test for SingletonHarvester.get_url_patterns + :return: + """ + shv = SingletonHarvester() + app_url_patterns, extension_url_patterns = shv.get_url_patterns() + self.assertGreaterEqual(len(app_url_patterns), 1) + self.assertIn('test_app', app_url_patterns) + self.assertGreaterEqual(len(extension_url_patterns), 1) + self.assertIn('test_extension', extension_url_patterns) + + def test_harvest_validate_extension(self): + """ + Test for SingletonHarvester._validate_extension + :return: + """ + mock_args = mock.MagicMock() + + shv = SingletonHarvester() + extension = shv._validate_extension(mock_args) + self.assertEqual(mock_args, extension) + + def test_harvest_validate_app(self): + """ + Test for SingletonHarvester._validate_app + Gives invalid icon and color information which is altered by the function + :return: + """ + mock_args = mock.MagicMock() + mock_args.icon = '/foo_icon' # prepended slash + mock_args.color = 'foo_color' # missing prepended #, not 6 or 3 digit hex color + + shv = SingletonHarvester() + validate = shv._validate_app(mock_args) + + self.assertEqual('foo_icon', validate.icon) + self.assertEqual('', validate.color) + + @mock.patch('sys.stdout', new_callable=io.StringIO) + @mock.patch('tethys_apps.harvester.tethys_log.exception') + def test_harvest_extension_instances_ImportError(self, mock_logexception, mock_stdout): + """ + Test for SingletonHarvester._harvest_extension_instances + With an ImportError exception thrown due to invalid argument information passed + :param mock_logexception: mock for the tethys_log exception + :param mock_stdout: mock for the text output + :return: + """ + mock_args = mock.MagicMock() + dict_ext = {'foo': 'foo_ext'} + mock_args = dict_ext + + shv = SingletonHarvester() + shv._harvest_extension_instances(mock_args) + + valid_ext_instances = [] + valid_extension_modules = {} + + self.assertEqual(valid_ext_instances, shv.extensions) + self.assertEqual(valid_extension_modules, shv.extension_modules) + mock_logexception.assert_called_once() + self.assertIn('Tethys Extensions Loaded:', mock_stdout.getvalue()) + + @mock.patch('sys.stdout', new_callable=io.StringIO) + @mock.patch('tethys_apps.harvester.tethys_log.exception') + @mock.patch('tethys_apps.harvester.issubclass') + def test_harvest_extension_instances_TypeError(self, mock_subclass, mock_logexception, mock_stdout): + """ + Test for SingletonHarvester._harvest_extension_instances + With a TypeError exception mocked up + :param mock_subclass: mock for the TypeError exception + :param mock_logexception: mock for the tethys_log exception + :param mock_stdout: mock for the text output + :return: + """ + mock_args = mock.MagicMock() + dict_ext = {'test_extension': 'tethysext.test_extension'} + mock_args = dict_ext + mock_subclass.side_effect = TypeError + + shv = SingletonHarvester() + shv._harvest_extension_instances(mock_args) + + valid_ext_instances = [] + valid_extension_modules = {} + + self.assertEqual(valid_ext_instances, shv.extensions) + self.assertEqual(valid_extension_modules, shv.extension_modules) + mock_logexception.assert_not_called() + mock_subclass.assert_called() + self.assertIn('Tethys Extensions Loaded:', mock_stdout.getvalue()) + + @mock.patch('sys.stdout', new_callable=io.StringIO) + @mock.patch('tethys_apps.harvester.tethys_log.exception') + def test_harvest_app_instances_ImportError(self, mock_logexception, mock_stdout): + """ + Test for SingletonHarvester._harvest_app_instances + With an ImportError exception thrown due to invalid argument information passed + :param mock_logexception: mock for the tethys_log exception + :param mock_stdout: mock for the text output + :return: + """ + mock_args = mock.MagicMock() + list_apps = ['foo'] + mock_args = list_apps + + shv = SingletonHarvester() + shv._harvest_app_instances(mock_args) + + valid_app_instance_list = [] + + self.assertEqual(valid_app_instance_list, shv.apps) + mock_logexception.assert_called_once() + self.assertIn('Tethys Apps Loaded:', mock_stdout.getvalue()) + + @mock.patch('sys.stdout', new_callable=io.StringIO) + @mock.patch('tethys_apps.harvester.tethys_log.exception') + @mock.patch('tethys_apps.harvester.issubclass') + def test_harvest_app_instances_TypeError(self, mock_subclass, mock_logexception, mock_stdout): + """ + Test for SingletonHarvester._harvest_app_instances + :param mock_subclass: mock for the TypeError exception + :param mock_logexception: mock for the tethys_log exception + :param mock_stdout: mock for the text output + :return: + """ + mock_args = mock.MagicMock() + list_apps = [u'.gitignore', u'test_app', u'__init__.py', u'__init__.pyc'] + mock_args = list_apps + mock_subclass.side_effect = TypeError + + shv = SingletonHarvester() + shv._harvest_app_instances(mock_args) + + valid_app_instance_list = [] + + self.assertEqual(valid_app_instance_list, shv.apps) + mock_logexception.assert_not_called() + mock_subclass.assert_called() + self.assertIn('Tethys Apps Loaded:', mock_stdout.getvalue()) + + @mock.patch('sys.stdout', new_callable=io.StringIO) + @mock.patch('tethys_apps.harvester.tethys_log.exception') + @mock.patch('tethys_apps.tethysapp.test_app.app.TestApp.url_maps') + def test_harvest_app_instances_Exceptions1(self, mock_url_maps, mock_logexception, mock_stdout): + """ + Test for SingletonHarvester._harvest_app_instances + For the exception on lines 230-234 + With an exception mocked up for the url_patterns + :param mock_url_maps: mock for url_patterns to thrown an Exception + :param mock_logexception: mock for the tethys_log exception + :param mock_stdout: mock for the text output + :return: + """ + mock_args = mock.MagicMock() + list_apps = [u'.gitignore', u'test_app', u'__init__.py', u'__init__.pyc'] + mock_args = list_apps + mock_url_maps.side_effect = ImportError + + shv = SingletonHarvester() + shv._harvest_app_instances(mock_args) + + mock_logexception.assert_called() + mock_url_maps.assert_called() + self.assertIn('Tethys Apps Loaded:', mock_stdout.getvalue()) + + @mock.patch('sys.stdout', new_callable=io.StringIO) + @mock.patch('tethys_apps.harvester.tethys_log.warning') + @mock.patch('tethys_apps.tethysapp.test_app.app.TestApp.register_app_permissions') + def test_harvest_app_instances_Exceptions2(self, mock_permissions, mock_logwarning, mock_stdout): + """ + Test for SingletonHarvester._harvest_app_instances + For the exception on lines 239-240 + With an exception mocked up for register_app_permissions + :param mock_permissions: mock for throwing a ProgrammingError exception + :param mock_logerror: mock for the tethys_log error + :param mock_stdout: mock for the text output + :return: + """ + list_apps = [u'.gitignore', u'test_app', u'__init__.py', u'__init__.pyc'] + mock_permissions.side_effect = ProgrammingError + + shv = SingletonHarvester() + shv._harvest_app_instances(list_apps) + + mock_logwarning.assert_called() + mock_permissions.assert_called() + self.assertIn('Tethys Apps Loaded:', mock_stdout.getvalue()) + self.assertIn('test_app', mock_stdout.getvalue()) diff --git a/tests/unit_tests/test_tethys_apps/test_helpers.py b/tests/unit_tests/test_tethys_apps/test_helpers.py new file mode 100644 index 000000000..8f64e71dd --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_helpers.py @@ -0,0 +1,48 @@ +import unittest +import mock + +from tethys_apps import helpers + + +class TethysAppsHelpersTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_get_tethysapp_dir(self): + # Get the absolute path to the tethysapp directory + result = helpers.get_tethysapp_dir() + self.assertIn('/tethys_apps/tethysapp', result) + + def test_get_installed_tethys_apps(self): + # Get list of apps installed in the tethysapp directory + result = helpers.get_installed_tethys_apps() + self.assertTrue('test_app' in result) + + @mock.patch('tethys_apps.helpers.os.path.isdir') + @mock.patch('tethys_apps.helpers.os.listdir') + @mock.patch('tethys_apps.helpers.get_tethysapp_dir') + def test_get_installed_tethys_apps_mock(self, mock_dir, mock_listdir, mock_isdir): + # Get list of apps installed in the mock directory + mock_dir.return_value = '/foo/bar' + mock_listdir.return_value = ['.gitignore', 'foo_app', '__init__.py', '__init__.pyc'] + mock_isdir.side_effect = [False, True, False, False] + result = helpers.get_installed_tethys_apps() + self.assertTrue('foo_app' in result) + + def test_get_installed_tethys_extensions(self): + # Get a list of installed extensions + result = helpers.get_installed_tethys_extensions() + self.assertTrue('test_extension' in result) + + @mock.patch('tethys_apps.helpers.SingletonHarvester') + def test_get_installed_tethys_extensions_error(self, mock_harvester): + # Mock the extension_modules variable with bad data + mock_harvester().extension_modules = {'foo_invalid_foo': 'tethysext.foo_invalid_foo'} + + # Get a list of installed extensions + result = helpers.get_installed_tethys_extensions() + self.assertEqual({}, result) diff --git a/tests/unit_tests/test_tethys_apps/test_management/__init__.py b/tests/unit_tests/test_tethys_apps/test_management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_apps/test_management/test_commands/__init__.py b/tests/unit_tests/test_tethys_apps/test_management/test_commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py new file mode 100644 index 000000000..bc4198081 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py @@ -0,0 +1,228 @@ +import unittest +import mock + +from tethys_apps.management.commands import collectworkspaces + + +class ManagementCommandsCollectWorkspacesTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_collectworkspaces_add_arguments(self): + from argparse import ArgumentParser + parser = ArgumentParser() + cmd = collectworkspaces.Command() + cmd.add_arguments(parser) + + self.assertIn('[-f]', parser.format_usage()) + self.assertIn('--force', parser.format_help()) + self.assertIn('Force the overwrite the app directory', parser.format_help()) + + @mock.patch('tethys_apps.management.commands.collectworkspaces.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.exit') + @mock.patch('tethys_apps.management.commands.collectworkspaces.settings') + def test_collectworkspaces_handle_no_atts(self, mock_settings, mock_exit, mock_print): + mock_settings.TETHYS_WORKSPACES_ROOT = None + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + cmd = collectworkspaces.Command() + self.assertRaises(SystemExit, cmd.handle) + + check_msg = 'WARNING: Cannot find the TETHYS_WORKSPACES_ROOT setting in the settings.py file. ' \ + 'Please provide the path to the static directory using the TETHYS_WORKSPACES_ROOT ' \ + 'setting and try again.' + + mock_print.assert_called_with(check_msg) + + @mock.patch('tethys_apps.management.commands.collectworkspaces.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.isdir') + @mock.patch('tethys_apps.management.commands.collectworkspaces.get_installed_tethys_apps') + @mock.patch('tethys_apps.management.commands.collectworkspaces.settings') + def test_collectworkspaces_handle_no_force_not_dir(self, mock_settings, mock_get_apps, mock_os_path_isdir, + mock_print): + mock_settings.TETHYS_WORKSPACES_ROOT = '/foo/workspace' + mock_get_apps.return_value = {'foo_app': '/foo/testing/tests/foo_app'} + mock_os_path_isdir.return_value = False + + cmd = collectworkspaces.Command() + cmd.handle(force=False) + + mock_get_apps.assert_called_once() + mock_os_path_isdir.assert_called_once_with('/foo/testing/tests/foo_app/workspaces') + + msg_info = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' + + msg_warning = 'WARNING: The workspace_path for app "foo_app" is not a directory. Skipping...' + + print_call_args = mock_print.call_args_list + + self.assertEqual(msg_info, print_call_args[0][0][0]) + + self.assertEqual(msg_warning, print_call_args[1][0][0]) + + @mock.patch('tethys_apps.management.commands.collectworkspaces.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.islink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.isdir') + @mock.patch('tethys_apps.management.commands.collectworkspaces.get_installed_tethys_apps') + @mock.patch('tethys_apps.management.commands.collectworkspaces.settings') + def test_collectworkspaces_handle_no_force_is_link(self, mock_settings, mock_get_apps, mock_os_path_isdir, + mock_os_path_islink, mock_print): + mock_settings.TETHYS_WORKSPACES_ROOT = '/foo/workspace' + mock_get_apps.return_value = {'foo_app': '/foo/testing/tests/foo_app'} + mock_os_path_isdir.return_value = True + mock_os_path_islink.return_value = True + + cmd = collectworkspaces.Command() + cmd.handle(force=False) + + mock_get_apps.assert_called_once() + mock_os_path_isdir.assert_called_once_with('/foo/testing/tests/foo_app/workspaces') + mock_os_path_islink.assert_called_once_with('/foo/testing/tests/foo_app/workspaces') + msg_in = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' + mock_print.assert_called_with(msg_in) + + @mock.patch('tethys_apps.management.commands.collectworkspaces.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.symlink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.shutil.move') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.exists') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.islink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.isdir') + @mock.patch('tethys_apps.management.commands.collectworkspaces.get_installed_tethys_apps') + @mock.patch('tethys_apps.management.commands.collectworkspaces.settings') + def test_collectworkspaces_handle_not_exists(self, mock_settings, mock_get_apps, mock_os_path_isdir, + mock_os_path_islink, mock_os_path_exists, mock_shutil_move, + mock_os_symlink, mock_print): + mock_settings.TETHYS_WORKSPACES_ROOT = '/foo/workspace' + mock_get_apps.return_value = {'foo_app': '/foo/testing/tests/foo_app'} + mock_os_path_isdir.side_effect = [True, True] + mock_os_path_islink.return_value = False + mock_os_path_exists.return_value = False + mock_shutil_move.return_value = True + mock_os_symlink.return_value = True + + cmd = collectworkspaces.Command() + cmd.handle(force=True) + + mock_get_apps.assert_called_once() + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_app/workspaces') + mock_os_path_isdir.assert_called_with('/foo/workspace/foo_app') + mock_os_path_islink.assert_called_once_with('/foo/testing/tests/foo_app/workspaces') + mock_os_path_exists.assert_called_once_with('/foo/workspace/foo_app') + mock_shutil_move.assert_called_once_with('/foo/testing/tests/foo_app/workspaces', '/foo/workspace/foo_app') + msg_first_info = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' + msg_second_info = 'INFO: Successfully linked "workspaces" directory to ' \ + 'TETHYS_WORKSPACES_ROOT for app "foo_app".' + + print_call_args = mock_print.call_args_list + + self.assertEqual(msg_first_info, print_call_args[0][0][0]) + + self.assertEqual(msg_second_info, print_call_args[1][0][0]) + + @mock.patch('tethys_apps.management.commands.collectworkspaces.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.shutil.rmtree') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.symlink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.shutil.move') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.exists') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.islink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.isdir') + @mock.patch('tethys_apps.management.commands.collectworkspaces.get_installed_tethys_apps') + @mock.patch('tethys_apps.management.commands.collectworkspaces.settings') + def test_collectworkspaces_handle_exists_no_force(self, mock_settings, mock_get_apps, mock_os_path_isdir, + mock_os_path_islink, mock_os_path_exists, mock_shutil_move, + mock_os_symlink, mock_shutil_rmtree, mock_print): + mock_settings.TETHYS_WORKSPACES_ROOT = '/foo/workspace' + mock_get_apps.return_value = {'foo_app': '/foo/testing/tests/foo_app'} + mock_os_path_isdir.side_effect = [True, True] + mock_os_path_islink.return_value = False + mock_os_path_exists.return_value = True + mock_shutil_move.return_value = True + mock_os_symlink.return_value = True + mock_shutil_rmtree.return_value = True + + cmd = collectworkspaces.Command() + cmd.handle(force=False) + + mock_get_apps.assert_called_once() + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_app/workspaces') + mock_os_path_isdir.assert_called_with('/foo/workspace/foo_app') + mock_os_path_islink.assert_called_once_with('/foo/testing/tests/foo_app/workspaces') + mock_os_path_exists.assert_called_once_with('/foo/workspace/foo_app') + mock_shutil_move.assert_not_called() + mock_shutil_rmtree.called_once_with('/foo/workspace/foo_app', ignore_errors=True) + + msg_first_info = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' + + msg_warning = 'WARNING: Workspace directory for app "foo_app" already exists in the ' \ + 'TETHYS_WORKSPACES_ROOT directory. A symbolic link is being created to the existing directory. ' \ + 'To force overwrite the existing directory, re-run the command with the "-f" argument.' + msg_second_info = 'INFO: Successfully linked "workspaces" directory to ' \ + 'TETHYS_WORKSPACES_ROOT for app "foo_app".' + + print_call_args = mock_print.call_args_list + + self.assertEqual(msg_first_info, print_call_args[0][0][0]) + + self.assertEqual(msg_warning, print_call_args[1][0][0]) + + self.assertEqual(msg_second_info, print_call_args[2][0][0]) + + @mock.patch('tethys_apps.management.commands.collectworkspaces.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.remove') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.shutil.rmtree') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.symlink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.shutil.move') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.exists') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.islink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.isdir') + @mock.patch('tethys_apps.management.commands.collectworkspaces.get_installed_tethys_apps') + @mock.patch('tethys_apps.management.commands.collectworkspaces.settings') + def test_collectworkspaces_handle_exists_force_exception(self, mock_settings, mock_get_apps, mock_os_path_isdir, + mock_os_path_islink, mock_os_path_exists, mock_shutil_move, + mock_os_symlink, mock_shutil_rmtree, mock_os_remove, + mock_print): + mock_settings.TETHYS_WORKSPACES_ROOT = '/foo/workspace' + mock_get_apps.return_value = {'foo_app': '/foo/testing/tests/foo_app'} + mock_os_path_isdir.side_effect = [True, True] + mock_os_path_islink.return_value = False + mock_os_path_exists.return_value = True + mock_shutil_move.return_value = True + mock_os_symlink.return_value = True + mock_shutil_rmtree.return_value = True + mock_os_remove.side_effect = OSError + + cmd = collectworkspaces.Command() + cmd.handle(force=True) + + mock_get_apps.assert_called_once() + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_app/workspaces') + mock_os_path_isdir.assert_called_with('/foo/workspace/foo_app') + mock_os_path_islink.assert_called_once_with('/foo/testing/tests/foo_app/workspaces') + mock_os_path_exists.assert_called_once_with('/foo/workspace/foo_app') + mock_shutil_move.assert_called_once_with('/foo/testing/tests/foo_app/workspaces', '/foo/workspace/foo_app') + mock_shutil_rmtree.called_once_with('/foo/testing/tests/foo_app/workspaces', ignore_errors=True) + mock_os_remove.assert_called_once_with('/foo/workspace/foo_app') + + msg_first_info = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' + + msg_second_info = 'INFO: Successfully linked "workspaces" directory to ' \ + 'TETHYS_WORKSPACES_ROOT for app "foo_app".' + + msg_warning = 'WARNING: Workspace directory for app "foo_app" already exists in the TETHYS_WORKSPACES_ROOT ' \ + 'directory. A symbolic link is being created to the existing directory. To force overwrite ' \ + 'the existing directory, re-run the command with the "-f" argument.' + + print_call_args = mock_print.call_args_list + + self.assertEqual(msg_first_info, print_call_args[0][0][0]) + + self.assertEqual(msg_second_info, print_call_args[1][0][0]) + + for i in range(len(print_call_args)): + self.assertNotEquals(msg_warning, print_call_args[i][0][0]) diff --git a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_pre_collectstatic.py b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_pre_collectstatic.py new file mode 100644 index 000000000..f34c552e4 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_pre_collectstatic.py @@ -0,0 +1,248 @@ +import unittest +import mock + +from tethys_apps.management.commands import pre_collectstatic + + +class ManagementCommandsPreCollectStaticTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.management.commands.pre_collectstatic.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.exit') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.settings') + def test_handle_no_static_root(self, mock_settings, mock_exit, mock_print): + mock_settings.STATIC_ROOT = None + # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception + # to break the code execution, which we catch below. + mock_exit.side_effect = SystemExit + + cmd = pre_collectstatic.Command() + self.assertRaises(SystemExit, cmd.handle) + + print_args = mock_print.call_args_list + + msg_warning = 'WARNING: Cannot find the STATIC_ROOT setting in the settings.py file. Please provide the ' \ + 'path to the static directory using the STATIC_ROOT setting and try again.' + self.assertEqual(msg_warning, print_args[0][0][0]) + + @mock.patch('tethys_apps.management.commands.pre_collectstatic.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.symlink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.isdir') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.remove') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_extensions') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_apps') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.settings') + def test_handle_public_not_static(self, mock_settings, mock_get_apps, mock_get_extensions, mock_os_remove, + mock_os_path_isdir, mock_os_symlink, mock_print): + mock_settings.STATIC_ROOT = '/foo/testing/tests' + mock_get_apps.return_value = {'foo_app': '/foo/testing/tests/foo_app'} + mock_get_extensions.return_value = {'foo_extension': '/foo/testing/tests/foo_extension'} + mock_os_remove.return_value = True + mock_os_path_isdir.return_value = True + mock_os_symlink.return_value = True + + cmd = pre_collectstatic.Command() + cmd.handle(options='foo') + + mock_get_apps.assert_called_once() + mock_get_extensions.assert_called_once() + mock_os_remove.assert_any_call('/foo/testing/tests/foo_app') + mock_os_remove.assert_any_call('/foo/testing/tests/foo_extension') + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_app/public') + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_extension/public') + mock_os_symlink.assert_any_call('/foo/testing/tests/foo_app/public', '/foo/testing/tests/foo_app') + mock_os_symlink.assert_any_call('/foo/testing/tests/foo_extension/public', + '/foo/testing/tests/foo_extension') + print_args = mock_print.call_args_list + + msg = 'INFO: Linking static and public directories of apps and extensions to "{0}".'\ + . format(mock_settings.STATIC_ROOT) + + msg_info_first = 'INFO: Successfully linked public directory to STATIC_ROOT for app "foo_app".' + + msg_info_second = 'INFO: Successfully linked public directory to STATIC_ROOT for app "foo_extension".' + + check_list = [] + for i in range(len(print_args)): + check_list.append(print_args[i][0][0]) + + self.assertIn(msg, check_list) + self.assertIn(msg_info_first, check_list) + self.assertIn(msg_info_second, check_list) + + msg_warning_not_in = 'WARNING: Cannot find the STATIC_ROOT setting' + msg_not_in = 'Please provide the path to the static directory' + info_not_in_first = 'INFO: Successfully linked static directory to STATIC_ROOT for app "foo_app".' + info_not_in_second = 'INFO: Successfully linked static directory to STATIC_ROOT for app "foo_extension".' + + for i in range(len(print_args)): + self.assertNotEquals(msg_warning_not_in, print_args[i][0][0]) + self.assertNotEquals(msg_not_in, print_args[i][0][0]) + self.assertNotEquals(info_not_in_first, print_args[i][0][0]) + self.assertNotEquals(info_not_in_second, print_args[i][0][0]) + + @mock.patch('tethys_apps.management.commands.pre_collectstatic.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.symlink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.isdir') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.shutil.rmtree') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.remove') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_extensions') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_apps') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.settings') + def test_handle_public_not_static_Exceptions(self, mock_settings, mock_get_apps, mock_get_extensions, + mock_os_remove, mock_shutil_rmtree, mock_os_path_isdir, + mock_os_symlink, mock_print): + mock_settings.STATIC_ROOT = '/foo/testing/tests' + mock_get_apps.return_value = {'foo_app': '/foo/testing/tests/foo_app'} + mock_get_extensions.return_value = {'foo_extension': '/foo/testing/tests/foo_extension'} + mock_os_remove.side_effect = OSError + mock_shutil_rmtree.side_effect = OSError + mock_os_path_isdir.return_value = True + mock_os_symlink.return_value = True + + cmd = pre_collectstatic.Command() + cmd.handle(options='foo') + + mock_get_apps.assert_called_once() + mock_get_extensions.assert_called_once() + mock_os_remove.assert_any_call('/foo/testing/tests/foo_app') + mock_os_remove.assert_any_call('/foo/testing/tests/foo_extension') + mock_shutil_rmtree.assert_any_call('/foo/testing/tests/foo_app') + mock_shutil_rmtree.assert_any_call('/foo/testing/tests/foo_extension') + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_app/public') + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_extension/public') + mock_os_symlink.assert_any_call('/foo/testing/tests/foo_app/public', '/foo/testing/tests/foo_app') + mock_os_symlink.assert_any_call('/foo/testing/tests/foo_extension/public', + '/foo/testing/tests/foo_extension') + msg_infor_1 = 'INFO: Linking static and public directories of apps and extensions to "{0}".'\ + .format(mock_settings.STATIC_ROOT) + msg_infor_2 = 'INFO: Successfully linked public directory to STATIC_ROOT for app "foo_app".' + msg_infor_3 = 'INFO: Successfully linked public directory to STATIC_ROOT for app "foo_extension".' + + warn_not_in = 'WARNING: Cannot find the STATIC_ROOT setting' + msg_not_in = 'Please provide the path to the static directory' + info_not_in_first = 'INFO: Successfully linked static directory to STATIC_ROOT for app "foo_app".' + info_not_in_second = 'INFO: Successfully linked static directory to STATIC_ROOT for app "foo_extension".' + + print_args = mock_print.call_args_list + + check_list = [] + for i in range(len(print_args)): + check_list.append(print_args[i][0][0]) + + self.assertIn(msg_infor_1, check_list) + self.assertIn(msg_infor_2, check_list) + self.assertIn(msg_infor_3, check_list) + + for i in range(len(print_args)): + self.assertNotEquals(warn_not_in, print_args[i][0][0]) + self.assertNotEquals(msg_not_in, print_args[i][0][0]) + self.assertNotEquals(info_not_in_first, print_args[i][0][0]) + self.assertNotEquals(info_not_in_second, print_args[i][0][0]) + + @mock.patch('tethys_apps.management.commands.pre_collectstatic.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.symlink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.isdir') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.remove') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_extensions') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_apps') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.settings') + def test_handle_not_public_static(self, mock_settings, mock_get_apps, mock_get_extensions, mock_os_remove, + mock_os_path_isdir, mock_os_symlink, mock_print): + mock_settings.STATIC_ROOT = '/foo/testing/tests' + mock_get_apps.return_value = {'foo_app': '/foo/testing/tests/foo_app'} + mock_get_extensions.return_value = {'foo_extension': '/foo/testing/tests/foo_extension'} + mock_os_remove.return_value = True + mock_os_path_isdir.side_effect = [False, True, False, True] + mock_os_symlink.return_value = True + + cmd = pre_collectstatic.Command() + cmd.handle(options='foo') + + mock_get_apps.assert_called_once() + mock_get_extensions.assert_called_once() + mock_os_remove.assert_any_call('/foo/testing/tests/foo_app') + mock_os_remove.assert_any_call('/foo/testing/tests/foo_extension') + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_app/static') + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_extension/static') + mock_os_symlink.assert_any_call('/foo/testing/tests/foo_app/static', '/foo/testing/tests/foo_app') + mock_os_symlink.assert_any_call('/foo/testing/tests/foo_extension/static', + '/foo/testing/tests/foo_extension') + + msg_info_one = 'INFO: Linking static and public directories of apps and extensions to "{0}".'\ + .format(mock_settings.STATIC_ROOT) + msg_info_two = 'INFO: Successfully linked static directory to STATIC_ROOT for app "foo_app".' + msg_info_three = 'INFO: Successfully linked static directory to STATIC_ROOT for app "foo_extension".' + + warn_not_in = 'WARNING: Cannot find the STATIC_ROOT setting' + msg_not_in = 'Please provide the path to the static directory' + info_not_in_first = 'INFO: Successfully linked public directory to STATIC_ROOT for app "foo_app".' + info_not_in_second = 'INFO: Successfully linked public directory to STATIC_ROOT for app "foo_extension".' + + print_args = mock_print.call_args_list + + check_list = [] + for i in range(len(print_args)): + check_list.append(print_args[i][0][0]) + + self.assertIn(msg_info_one, check_list) + self.assertIn(msg_info_two, check_list) + self.assertIn(msg_info_three, check_list) + + for i in range(len(print_args)): + self.assertNotEquals(warn_not_in, print_args[i][0][0]) + self.assertNotEquals(msg_not_in, print_args[i][0][0]) + self.assertNotEquals(info_not_in_first, print_args[i][0][0]) + self.assertNotEquals(info_not_in_second, print_args[i][0][0]) + + @mock.patch('tethys_apps.management.commands.pre_collectstatic.print') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.symlink') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.path.isdir') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.os.remove') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_extensions') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_apps') + @mock.patch('tethys_apps.management.commands.pre_collectstatic.settings') + def test_handle_not_public_not_static(self, mock_settings, mock_get_apps, mock_get_extensions, mock_os_remove, + mock_os_path_isdir, mock_os_symlink, mock_print): + mock_settings.STATIC_ROOT = '/foo/testing/tests' + mock_get_apps.return_value = {'foo_app': '/foo/testing/tests/foo_app'} + mock_get_extensions.return_value = {'foo_extension': '/foo/testing/tests/foo_extension'} + mock_os_remove.return_value = True + mock_os_path_isdir.side_effect = [False, False, False, False] + mock_os_symlink.return_value = True + + cmd = pre_collectstatic.Command() + cmd.handle(options='foo') + + mock_get_apps.assert_called_once() + mock_get_extensions.assert_called_once() + mock_os_remove.assert_any_call('/foo/testing/tests/foo_app') + mock_os_remove.assert_any_call('/foo/testing/tests/foo_extension') + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_app/static') + mock_os_path_isdir.assert_any_call('/foo/testing/tests/foo_extension/static') + mock_os_symlink.assert_not_called() + msg_info = 'INFO: Linking static and public directories of apps and extensions to "{0}".'\ + .format(mock_settings.STATIC_ROOT) + + warn_not_in = 'WARNING: Cannot find the STATIC_ROOT setting' + msg_not_in = 'Please provide the path to the static directory' + info_not_in_first = 'INFO: Successfully linked public directory to STATIC_ROOT for app "foo_app".' + info_not_in_second = 'INFO: Successfully linked public directory to STATIC_ROOT for app "foo_extension".' + info_not_in_third = 'INFO: Successfully linked static directory to STATIC_ROOT for app "foo_app".' + info_not_in_fourth = 'INFO: Successfully linked static directory to STATIC_ROOT for app "foo_extension".' + + print_args = mock_print.call_args_list + self.assertEqual(msg_info, print_args[0][0][0]) + + for i in range(len(print_args)): + self.assertNotEquals(warn_not_in, print_args[i][0][0]) + self.assertNotEquals(msg_not_in, print_args[i][0][0]) + self.assertNotEquals(info_not_in_first, print_args[i][0][0]) + self.assertNotEquals(info_not_in_second, print_args[i][0][0]) + self.assertNotEquals(info_not_in_third, print_args[i][0][0]) + self.assertNotEquals(info_not_in_fourth, print_args[i][0][0]) diff --git a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_syncstores.py b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_syncstores.py new file mode 100644 index 000000000..e37c7b338 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_syncstores.py @@ -0,0 +1,161 @@ +try: + from StringIO import StringIO +except ImportError: + from io import StringIO +import unittest +import mock + +from argparse import ArgumentParser +from tethys_apps.management.commands import syncstores + + +class ManagementCommandsSyncstoresTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_syncstores_add_arguments(self): + parser = ArgumentParser() + cmd = syncstores.Command() + cmd.add_arguments(parser) + self.assertIn('app_name', parser.format_usage()) + self.assertIn('[-r]', parser.format_usage()) + self.assertIn('[-f]', parser.format_usage()) + self.assertIn('[-d DATABASE]', parser.format_usage()) + self.assertIn('--refresh', parser.format_help()) + self.assertIn('--firsttime', parser.format_help()) + self.assertIn('--database DATABASE', parser.format_help()) + + @mock.patch('tethys_apps.management.commands.syncstores.Command.provision_persistent_stores') + def test_handle(self, mock_provision_persistent_stores): + # Mock the function, it will be tested elsewhere + mock_provision_persistent_stores.return_value = True + + cmd = syncstores.Command() + cmd.handle(app_name='foo') + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.models.TethysApp.persistent_store_database_settings') + @mock.patch('tethys_apps.models.TethysApp.persistent_store_database_settings') + @mock.patch('tethys_apps.models.TethysApp.persistent_store_database_settings') + @mock.patch('tethys_apps.models.TethysApp') + def test_provision_persistent_stores_all_apps_no_database(self, mock_app, mock_setting1, mock_setting2, + mock_setting3, mock_stdout): + # Mock arguments + mock_app_names = syncstores.ALL_APPS + mock_options = {'database': '', 'refresh': True, 'first_time': True} + + # Mock for ps db settings + mock_setting1.name = 'setting1_name' + mock_setting1.create_persistent_store_database.return_value = True + mock_setting2.name = 'setting2_name' + mock_setting2.create_persistent_store_database.return_value = True + mock_setting3.name = 'setting3_name' + mock_setting3.create_persistent_store_database.return_value = True + + # Mock for TethysApp (2 apps, 2 settings for first app, 1 setting for second app) + mock_app1 = mock.MagicMock() + mock_app1.persistent_store_database_settings = [mock_setting1, mock_setting2] + mock_app2 = mock.MagicMock() + mock_app2.persistent_store_database_settings = [mock_setting3] + mock_app.objects.all.return_value = [mock_app1, mock_app2] + + cmd = syncstores.Command() + cmd.provision_persistent_stores(app_names=mock_app_names, options=mock_options) + + mock_app.objects.all.assert_called_once() + mock_setting1.create_persistent_store_database.assert_called_once_with(refresh=True, force_first_time=True) + mock_setting2.create_persistent_store_database.assert_called_once_with(refresh=True, force_first_time=True) + mock_setting3.create_persistent_store_database.assert_called_once_with(refresh=True, force_first_time=True) + self.assertIn('Provisioning Persistent Stores...', mock_stdout.getvalue()) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.models.TethysApp.persistent_store_database_settings') + @mock.patch('tethys_apps.models.TethysApp.persistent_store_database_settings') + @mock.patch('tethys_apps.models.TethysApp.persistent_store_database_settings') + @mock.patch('tethys_apps.models.TethysApp') + def test_provision_persistent_stores_all_apps_database_no_match(self, mock_app, mock_setting1, mock_setting2, + mock_setting3, mock_stdout): + # Mock arguments + mock_app_names = syncstores.ALL_APPS + mock_options = {'database': '/foo/no_match', 'refresh': True, 'first_time': True} + + # Mock for ps db settings + mock_setting1.name = 'setting1_name' + mock_setting1.create_persistent_store_database.return_value = True + mock_setting2.name = 'setting2_name' + mock_setting2.create_persistent_store_database.return_value = True + mock_setting3.name = 'setting3_name' + mock_setting3.create_persistent_store_database.return_value = True + + # Mock for TethysApp (2 apps, 2 settings for first app, 1 setting for second app) + mock_app1 = mock.MagicMock() + mock_app1.persistent_store_database_settings = [mock_setting1, mock_setting2] + mock_app2 = mock.MagicMock() + mock_app2.persistent_store_database_settings = [mock_setting3] + mock_app.objects.all.return_value = [mock_app1, mock_app2] + + cmd = syncstores.Command() + cmd.provision_persistent_stores(app_names=mock_app_names, options=mock_options) + + mock_app.objects.all.assert_called_once() + mock_setting1.create_persistent_store_database.assert_not_called() + mock_setting2.create_persistent_store_database.assert_not_called() + mock_setting3.create_persistent_store_database.assert_not_called() + self.assertIn('Provisioning Persistent Stores...', mock_stdout.getvalue()) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.models.TethysApp.persistent_store_database_settings') + @mock.patch('tethys_apps.models.TethysApp.persistent_store_database_settings') + @mock.patch('tethys_apps.models.TethysApp.persistent_store_database_settings') + @mock.patch('tethys_apps.models.TethysApp') + def test_provision_persistent_stores_all_apps_database_single_match(self, mock_app, mock_setting1, mock_setting2, + mock_setting3, mock_stdout): + # Mock arguments + mock_app_names = syncstores.ALL_APPS + mock_options = {'database': '/foo/match', 'refresh': False, 'first_time': False} + + # Mock for ps db settings + mock_setting1.name = 'setting1_name' + mock_setting1.create_persistent_store_database.return_value = True + mock_setting2.name = '/foo/match' + mock_setting2.create_persistent_store_database.return_value = True + mock_setting3.name = 'setting3_name' + mock_setting3.create_persistent_store_database.return_value = True + + # Mock for TethysApp (2 apps, 2 settings for first app, 1 setting for second app) + mock_app1 = mock.MagicMock() + mock_app1.persistent_store_database_settings = [mock_setting1, mock_setting2] + mock_app2 = mock.MagicMock() + mock_app2.persistent_store_database_settings = [mock_setting3] + mock_app.objects.all.return_value = [mock_app1, mock_app2] + + cmd = syncstores.Command() + cmd.provision_persistent_stores(app_names=mock_app_names, options=mock_options) + + mock_app.objects.all.assert_called_once() + mock_setting1.create_persistent_store_database.assert_not_called() + mock_setting2.create_persistent_store_database.assert_called_once_with(refresh=False, force_first_time=False) + mock_setting3.create_persistent_store_database.assert_not_called() + self.assertIn('Provisioning Persistent Stores...', mock_stdout.getvalue()) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.models.TethysApp') + def test_provision_persistent_stores_given_apps_not_found(self, mock_app, mock_stdout): + # Mock arguments + mock_app_names = ['foo_missing'] + mock_options = {'database': '', 'refresh': True, 'first_time': True} + + # Mock for TethysApp (return no apps found) + mock_app.objects.filter.return_value = [] + + cmd = syncstores.Command() + cmd.provision_persistent_stores(app_names=mock_app_names, options=mock_options) + + mock_app.objects.filter.assert_called_once() + self.assertIn('The app named "foo_missing" cannot be found.', mock_stdout.getvalue()) + self.assertIn('Please make sure it is installed and try again.', mock_stdout.getvalue()) + self.assertIn('Provisioning Persistent Stores...', mock_stdout.getvalue()) diff --git a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py new file mode 100644 index 000000000..c84ba3ca7 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py @@ -0,0 +1,151 @@ +try: + from StringIO import StringIO +except ImportError: + from io import StringIO +import unittest +import mock + +from argparse import ArgumentParser +from tethys_apps.management.commands import tethys_app_uninstall +from tethys_apps.models import TethysApp, TethysExtension + + +class ManagementCommandsTethysAppUninstallTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_tethys_app_uninstall_add_arguments(self): + parser = ArgumentParser('foo_parser') + cmd = tethys_app_uninstall.Command() + cmd.add_arguments(parser) + self.assertIn('foo_parser', parser.format_usage()) + self.assertIn('app_or_extension', parser.format_usage()) + self.assertIn('[-e]', parser.format_usage()) + self.assertIn('--extension', parser.format_help()) + + @mock.patch('warnings.warn') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.exit') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_extensions') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_apps') + def test_tethys_app_uninstall_handle_apps_warning(self, mock_installed_apps, mock_installed_extensions, mock_exit, + mock_warnings): + mock_installed_apps.return_value = [''] + mock_installed_extensions.return_value = [''] + mock_exit.side_effect = SystemExit + + cmd = tethys_app_uninstall.Command() + self.assertRaises(SystemExit, cmd.handle, app_or_extension=['tethysapp.foo_app'], is_extension=False) + + mock_installed_apps.assert_called_once() + mock_installed_extensions.assert_not_called() + mock_warnings.assert_called_once() + + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.exit') + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.input') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_extensions') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_apps') + def test_tethys_app_uninstall_handle_apps_cancel(self, mock_installed_apps, mock_installed_extensions, mock_input, + mock_stdout, mock_exit): + mock_installed_apps.return_value = ['foo_app'] + mock_installed_extensions.return_value = {} + mock_input.side_effect = ['foo', 'no'] + mock_exit.side_effect = SystemExit + + cmd = tethys_app_uninstall.Command() + self.assertRaises(SystemExit, cmd.handle, app_or_extension=['tethysapp.foo_app'], is_extension=False) + + mock_installed_apps.assert_called_once() + mock_installed_extensions.assert_not_called() + self.assertIn('Uninstall cancelled by user.', mock_stdout.getvalue()) + + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.os.path.join') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.os.remove') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.shutil.rmtree') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.subprocess.Popen') + @mock.patch('warnings.warn') + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.input') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_extensions') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_apps') + @mock.patch('tethys_apps.models.TethysExtension') + @mock.patch('tethys_apps.models.TethysApp') + def test_tethys_app_uninstall_handle_apps_delete_rmtree_Popen_remove_exceptions(self, mock_app, mock_extension, + mock_installed_apps, + mock_installed_extensions, + mock_input, mock_stdout, + mock_warnings, mock_popen, + mock_rmtree, mock_os_remove, + mock_join): + mock_app.objects.get.return_value = mock.MagicMock() + mock_app.objects.get().delete.return_value = True + mock_extension.objects.get.return_value = mock.MagicMock() + mock_extension.objects.get().delete.return_value = True + mock_installed_apps.return_value = {'foo_app': '/foo/foo_app'} + mock_installed_extensions.return_value = {} + mock_input.side_effect = ['yes'] + mock_popen.side_effect = KeyboardInterrupt + mock_rmtree.side_effect = OSError + mock_os_remove.side_effect = [True, Exception] + mock_join.return_value = '/foo/tethysapp-foo-app-nspkg.pth' + + cmd = tethys_app_uninstall.Command() + cmd.handle(app_or_extension=['tethysapp.foo_app'], is_extension=False) + + mock_installed_apps.assert_called_once() + mock_installed_extensions.assert_not_called() + self.assertIn('successfully uninstalled', mock_stdout.getvalue()) + mock_warnings.assert_not_called() # Don't do the TethysModel.DoesNotExist exception this test + mock_app.objects.get.assert_called() + mock_app.objects.get().delete.assert_called_once() + mock_extension.objects.get.assert_called() + mock_extension.objects.get().delete.assert_not_called() + mock_popen.assert_called_once_with(['pip', 'uninstall', '-y', 'tethysapp-foo_app'], stderr=-2, stdout=-1) + mock_rmtree.assert_called_once_with('/foo/foo_app') + mock_os_remove.assert_any_call('/foo/foo_app') + mock_os_remove.assert_called_with('/foo/tethysapp-foo-app-nspkg.pth') + mock_join.assert_called() + + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.os.path.join') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.os.remove') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.subprocess.Popen') + @mock.patch('warnings.warn') + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.input') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_extensions') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_apps') + @mock.patch('tethys_apps.models.TethysExtension') + @mock.patch('tethys_apps.models.TethysApp') + def test_tethys_app_uninstall_handle_extension_DoesNotExist(self, mock_app, mock_extension, mock_installed_apps, + mock_installed_extensions, mock_input, mock_stdout, + mock_warnings, mock_popen, mock_os_remove, mock_join): + mock_app.objects.get.return_value = mock.MagicMock() + mock_app.DoesNotExist = TethysApp.DoesNotExist + mock_extension.DoesNotExist = TethysExtension.DoesNotExist + mock_app.objects.get.side_effect = TethysApp.DoesNotExist + mock_extension.objects.get.return_value = mock.MagicMock() + mock_extension.objects.get.side_effect = TethysExtension.DoesNotExist + mock_installed_apps.return_value = {} + mock_installed_extensions.return_value = {'foo_extension': '/foo/foo_extension'} + mock_input.side_effect = ['yes'] + mock_popen.side_effect = KeyboardInterrupt + mock_os_remove.return_value = True + mock_join.return_value = '/foo/tethysext-foo-extension-nspkg.pth' + + cmd = tethys_app_uninstall.Command() + cmd.handle(app_or_extension=['tethysext.foo_extension'], is_extension=True) + + mock_installed_apps.assert_not_called() + mock_installed_extensions.assert_called_once() + self.assertIn('successfully uninstalled', mock_stdout.getvalue()) + mock_warnings.assert_called_once() + mock_app.objects.get.assert_not_called() + mock_extension.objects.get.assert_called() + mock_popen.assert_called_once_with(['pip', 'uninstall', '-y', 'tethysext-foo_extension'], stderr=-2, stdout=-1) + mock_os_remove.assert_any_call('/foo/tethysext-foo-extension-nspkg.pth') + mock_os_remove.assert_called_with('/foo/tethysext-foo-extension-nspkg.pth') + mock_join.assert_called() diff --git a/tests/unit_tests/test_tethys_apps/test_models/__init__.py b/tests/unit_tests/test_tethys_apps/test_models/__init__.py new file mode 100644 index 000000000..e40fa7488 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/__init__.py @@ -0,0 +1,8 @@ +""" +******************************************************************************** +* Name: __init__.py +* Author: nswain +* Created On: August 15, 2018 +* Copyright: (c) Aquaveo 2018 +******************************************************************************** +""" diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_CustomSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_CustomSetting.py new file mode 100644 index 000000000..fae379f80 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_CustomSetting.py @@ -0,0 +1,109 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysApp, CustomSetting +from django.core.exceptions import ValidationError + + +class CustomSettingTests(TethysTestCase): + def set_up(self): + self.test_app = TethysApp.objects.get(package='test_app') + pass + + def tear_down(self): + pass + + def test_clean_empty_validation_error(self): + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = '' + custom_setting.save() + + # Check ValidationError + ret = CustomSetting.objects.get(name='default_name') + + self.assertRaises(ValidationError, ret.clean) + + def test_clean_int_validation_error(self): + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = 'test' + custom_setting.type = 'INTEGER' + custom_setting.save() + + # Check ValidationError + ret = CustomSetting.objects.get(name='default_name') + self.assertRaises(ValidationError, ret.clean) + + def test_clean_float_validation_error(self): + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = 'test' + custom_setting.type = 'FLOAT' + custom_setting.save() + + # Check ValidationError + ret = CustomSetting.objects.get(name='default_name') + self.assertRaises(ValidationError, ret.clean) + + def test_clean_bool_validation_error(self): + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = 'test' + custom_setting.type = 'BOOLEAN' + custom_setting.save() + + # Check ValidationError + ret = CustomSetting.objects.get(name='default_name') + self.assertRaises(ValidationError, ret.clean) + + def test_get_value_empty(self): + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = '' + custom_setting.save() + + self.assertIsNone(CustomSetting.objects.get(name='default_name').get_value()) + + def test_get_value_string(self): + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = 'test_string' + custom_setting.type = 'STRING' + custom_setting.save() + + ret = CustomSetting.objects.get(name='default_name').get_value() + + self.assertEqual('test_string', ret) + + def test_get_value_float(self): + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = '3.14' + custom_setting.type = 'FLOAT' + custom_setting.save() + + ret = CustomSetting.objects.get(name='default_name').get_value() + self.assertEqual(3.14, ret) + + def test_get_value_integer(self): + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = '3' + custom_setting.type = 'INTEGER' + custom_setting.save() + + ret = CustomSetting.objects.get(name='default_name').get_value() + self.assertEqual(3, ret) + + def test_get_value_boolean_true(self): + test_cases = ['true', 'yes', 't', 'y', '1'] + for test in test_cases: + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = test + custom_setting.type = 'BOOLEAN' + custom_setting.save() + + ret = CustomSetting.objects.get(name='default_name').get_value() + self.assertTrue(ret) + + def test_get_value_boolean_false(self): + test_cases = ['false', 'no', 'f', 'n', '0'] + for test in test_cases: + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = test + custom_setting.type = 'BOOLEAN' + custom_setting.save() + + ret = CustomSetting.objects.get(name='default_name').get_value() + self.assertFalse(ret) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_DatasetServiceSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_DatasetServiceSetting.py new file mode 100644 index 000000000..51980ef00 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_DatasetServiceSetting.py @@ -0,0 +1,70 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysApp, DatasetServiceSetting +from django.core.exceptions import ValidationError +from tethys_services.models import DatasetService + + +class DatasetServiceSettingTests(TethysTestCase): + def set_up(self): + self.test_app = TethysApp.objects.get(package='test_app') + + pass + + def tear_down(self): + pass + + def test_clean_empty_validation_error(self): + ds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_ckan') + ds_setting.dataset_service = None + ds_setting.save() + # Check ValidationError + self.assertRaises(ValidationError, DatasetServiceSetting.objects.get(name='primary_ckan').clean) + + def test_get_value_None(self): + ds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_ckan') + ds_setting.dataset_service = None + ds_setting.save() + + ret = DatasetServiceSetting.objects.get(name='primary_ckan').get_value() + self.assertIsNone(ret) + + def test_get_value(self): + ds = DatasetService( + name='test_ds', + endpoint='http://localhost/api/3/action/', + public_endpoint='http://publichost/api/3/action/', + ) + ds.save() + ds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_ckan') + ds_setting.dataset_service = ds + ds_setting.save() + + ret = DatasetServiceSetting.objects.get(name='primary_ckan').get_value() + + self.assertEqual('CKAN', ret.get_engine().type) + self.assertEqual('test_ds', ret.name) + self.assertEqual('http://localhost/api/3/action/', ret.endpoint) + self.assertEqual('http://publichost/api/3/action/', ret.public_endpoint) + + def test_get_value_check_if(self): + ds = DatasetService( + name='test_ds', + endpoint='http://localhost/api/3/action/', + public_endpoint='http://publichost/api/3/action/', + ) + ds.save() + ds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_ckan') + ds_setting.dataset_service = ds + ds_setting.save() + + # Check as_engine + ret = DatasetServiceSetting.objects.get(name='primary_ckan').get_value(as_engine=True) + self.assertEqual('CKAN', ret.type) + + # Check as_enpoint + ret = DatasetServiceSetting.objects.get(name='primary_ckan').get_value(as_endpoint=True) + self.assertEqual('http://localhost/api/3/action/', ret) + + # Check as_public_endpoint + ret = DatasetServiceSetting.objects.get(name='primary_ckan').get_value(as_public_endpoint=True) + self.assertEqual('http://publichost/api/3/action/', ret) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_PersistentStoreConnectionSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_PersistentStoreConnectionSetting.py new file mode 100644 index 000000000..48f3d1738 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_PersistentStoreConnectionSetting.py @@ -0,0 +1,91 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysApp, PersistentStoreConnectionSetting, PersistentStoreService +from django.core.exceptions import ValidationError +from tethys_apps.exceptions import TethysAppSettingNotAssigned +from sqlalchemy.engine.base import Engine +from sqlalchemy.orm.session import sessionmaker + + +class PersistentStoreConnectionSettingTests(TethysTestCase): + def set_up(self): + self.test_app = TethysApp.objects.get(package='test_app') + + self.pss = PersistentStoreService( + name='test_ps', + host='localhost', + port='5432', + username='foo', + password='password' + ) + self.pss.save() + pass + + def tear_down(self): + self.pss.delete() + + def test_clean_empty_validation_error(self): + ps_cs_setting = self.test_app.settings_set.select_subclasses().get(name='primary') + ps_cs_setting.persistent_store_service = None + ps_cs_setting.save() + # Check ValidationError + self.assertRaises(ValidationError, PersistentStoreConnectionSetting.objects.get(name='primary').clean) + + def test_get_value(self): + ps_cs_setting = self.test_app.settings_set.select_subclasses().get(name='primary') + ps_cs_setting.persistent_store_service = self.pss + ps_cs_setting.save() + + # Execute + ret = PersistentStoreConnectionSetting.objects.get(name='primary').get_value() + + # Check if ret is an instance of PersistentStoreService + self.assertIsInstance(ret, PersistentStoreService) + self.assertEqual('test_ps', ret.name) + self.assertEqual('localhost', ret.host) + self.assertEqual(5432, ret.port) + self.assertEqual('foo', ret.username) + self.assertEqual('password', ret.password) + + def test_get_value_none(self): + ps_cs_setting = self.test_app.settings_set.select_subclasses().get(name='primary') + ps_cs_setting.persistent_store_service = None + ps_cs_setting.save() + + # Check TethysAppSettingNotAssigned + self.assertRaises(TethysAppSettingNotAssigned, PersistentStoreConnectionSetting.objects + .get(name='primary').get_value) + + def test_get_value_as_engine(self): + ps_cs_setting = self.test_app.settings_set.select_subclasses().get(name='primary') + ps_cs_setting.persistent_store_service = self.pss + ps_cs_setting.save() + + # Execute + ret = PersistentStoreConnectionSetting.objects.get(name='primary').get_value(as_engine=True) + + # Check if ret is an instance of sqlalchemy Engine + self.assertIsInstance(ret, Engine) + self.assertEqual('postgresql://foo:password@localhost:5432', str(ret.url)) + + def test_get_value_as_sessionmaker(self): + ps_cs_setting = self.test_app.settings_set.select_subclasses().get(name='primary') + ps_cs_setting.persistent_store_service = self.pss + ps_cs_setting.save() + + # Execute + ret = PersistentStoreConnectionSetting.objects.get(name='primary').get_value(as_sessionmaker=True) + + # Check if ret is an instance of sqlalchemy sessionmaker + self.assertIsInstance(ret, sessionmaker) + self.assertEqual('postgresql://foo:password@localhost:5432', str(ret.kw['bind'].url)) + + def test_get_value_as_url(self): + ps_cs_setting = self.test_app.settings_set.select_subclasses().get(name='primary') + ps_cs_setting.persistent_store_service = self.pss + ps_cs_setting.save() + + # Execute + ret = PersistentStoreConnectionSetting.objects.get(name='primary').get_value(as_url=True) + + # Check Url + self.assertEqual('postgresql://foo:password@localhost:5432', str(ret)) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_PersistentStoreDatabaseSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_PersistentStoreDatabaseSetting.py new file mode 100644 index 000000000..b10e8aaf5 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_PersistentStoreDatabaseSetting.py @@ -0,0 +1,389 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysApp, PersistentStoreDatabaseSetting, PersistentStoreService +from django.core.exceptions import ValidationError +from django.conf import settings +from tethys_apps.exceptions import TethysAppSettingNotAssigned, PersistentStorePermissionError,\ + PersistentStoreInitializerError +from sqlalchemy.engine.base import Engine +from sqlalchemy.orm import sessionmaker +import mock + + +class PersistentStoreDatabaseSettingTests(TethysTestCase): + def set_up(self): + self.test_app = TethysApp.objects.get(package='test_app') + + # Get default database connection if there is one + if 'default' in settings.DATABASES: + self.conn = settings.DATABASES['default'] + else: + self.conn = { + 'USER': 'tethys_super', + 'PASSWORD': 'pass', + 'HOST': 'localhost', + 'PORT': '5435' + } + + self.expected_url = 'postgresql://{}:{}@{}:{}'.format( + self.conn['USER'], self.conn['PASSWORD'], self.conn['HOST'], self.conn['PORT'] + ) + + self.pss = PersistentStoreService( + name='test_ps', + host=self.conn['HOST'], + port=self.conn['PORT'], + username=self.conn['USER'], + password=self.conn['PASSWORD'] + ) + + self.pss.save() + + def tear_down(self): + pass + + def test_clean_validation_error(self): + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = None + ps_ds_setting.save() + # Check ValidationError + self.assertRaises(ValidationError, self.test_app.settings_set.select_subclasses().get(name='spatial_db').clean) + + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.create_persistent_store_database') + def test_initialize(self, mock_create): + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = self.pss + ps_ds_setting.save() + + # Execute + self.test_app.settings_set.select_subclasses().get(name='spatial_db').initialize() + + mock_create.assert_called() + + @mock.patch('tethys_apps.models.is_testing_environment') + def test_get_namespaced_persistent_store_name(self, mock_ite): + mock_ite.return_value = False + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = self.pss + ps_ds_setting.save() + + # Execute + ret = self.test_app.settings_set.select_subclasses().get(name='spatial_db').\ + get_namespaced_persistent_store_name() + + # Check result + self.assertEqual('test_app_spatial_db', ret) + + @mock.patch('tethys_apps.models.is_testing_environment') + def test_get_namespaced_persistent_store_name_testing(self, mock_ite): + mock_ite.return_value = True + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = self.pss + ps_ds_setting.save() + + # Execute + ret = self.test_app.settings_set.select_subclasses().get(name='spatial_db').\ + get_namespaced_persistent_store_name() + + # Check result + self.assertEqual('test_app_tethys-testing_spatial_db', ret) + + def test_get_value(self): + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = self.pss + ps_ds_setting.save() + + # Execute + ret = self.test_app.settings_set.select_subclasses().get(name='spatial_db').get_value(with_db=True) + + # Check results + self.assertIsInstance(ret, PersistentStoreService) + self.assertEqual('test_ps', ret.name) + self.assertEqual(self.conn['HOST'], ret.host) + self.assertEqual(int(self.conn['PORT']), ret.port) + self.assertEqual(self.conn['USER'], ret.username) + self.assertEqual(self.conn['PASSWORD'], ret.password) + + def test_get_value_none(self): + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = None + ps_ds_setting.save() + + self.assertRaises(TethysAppSettingNotAssigned, PersistentStoreDatabaseSetting.objects + .get(name='spatial_db').get_value) + + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_namespaced_persistent_store_name') + def test_get_value_with_db(self, mock_gn): + mock_gn.return_value = 'test_database' + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = self.pss + ps_ds_setting.save() + + # Execute + ret = self.test_app.settings_set.select_subclasses().get(name='spatial_db').get_value(with_db=True) + + self.assertIsInstance(ret, PersistentStoreService) + self.assertEqual('test_database', ret.database) + + def test_get_value_as_engine(self): + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = self.pss + ps_ds_setting.save() + + # Execute + ret = self.test_app.settings_set.select_subclasses().get(name='spatial_db').get_value(as_engine=True) + + self.assertIsInstance(ret, Engine) + self.assertEqual(self.expected_url, str(ret.url)) + + def test_get_value_as_sessionmaker(self): + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = self.pss + ps_ds_setting.save() + + # Execute + ret = self.test_app.settings_set.select_subclasses().get(name='spatial_db').get_value(as_sessionmaker=True) + + self.assertIsInstance(ret, sessionmaker) + self.assertEqual(self.expected_url, str(ret.kw['bind'].url)) + + def test_get_value_as_url(self): + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = self.pss + ps_ds_setting.save() + + # Execute + ret = self.test_app.settings_set.select_subclasses().get(name='spatial_db').get_value(as_url=True) + + # check URL + self.assertEqual(self.expected_url, str(ret)) + + def test_persistent_store_database_exists(self): + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = self.pss + ps_ds_setting.save() + + ps_ds_setting.get_namespaced_persistent_store_name = mock.MagicMock(return_value='foo_bar') + ps_ds_setting.get_value = mock.MagicMock() + mock_engine = ps_ds_setting.get_value() + mock_db = mock.MagicMock() + mock_db.name = 'foo_bar' + mock_engine.connect().execute.return_value = [mock_db] + + # Execute + ret = ps_ds_setting.persistent_store_database_exists() + + self.assertTrue(ret) + + def test_persistent_store_database_exists_false(self): + ps_ds_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_ds_setting.persistent_store_service = self.pss + ps_ds_setting.save() + + ps_ds_setting.get_namespaced_persistent_store_name = mock.MagicMock(return_value='foo_bar') + ps_ds_setting.get_value = mock.MagicMock() + mock_engine = ps_ds_setting.get_value() + mock_engine.connect().execute.return_value = [] + + # Execute + ret = ps_ds_setting.persistent_store_database_exists() + + self.assertFalse(ret) + + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.persistent_store_database_exists') + def test_drop_persistent_store_database_not_exists(self, mock_psd): + mock_psd.return_value = False + + # Execute + ret = self.test_app.settings_set.select_subclasses().get(name='spatial_db').drop_persistent_store_database() + + self.assertIsNone(ret) + + @mock.patch('tethys_apps.models.logging') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_value') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.persistent_store_database_exists') + def test_drop_persistent_store_database(self, mock_psd, mock_get, mock_log): + mock_psd.return_value = True + + # Execute + self.test_app.settings_set.select_subclasses().get(name='spatial_db').drop_persistent_store_database() + + # Check + mock_log.getLogger().info.assert_called_with('Dropping database "spatial_db" for app "test_app"...') + mock_get().connect.assert_called() + rts_call_args = mock_get().connect().execute.call_args_list + self.assertEqual('commit', rts_call_args[0][0][0]) + self.assertEqual('DROP DATABASE IF EXISTS "test_app_tethys-testing_spatial_db"', rts_call_args[1][0][0]) + mock_get().connect().close.assert_called() + + @mock.patch('tethys_apps.models.logging') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_value') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.persistent_store_database_exists') + def test_drop_persistent_store_database_exception(self, mock_psd, mock_get, mock_log): + mock_psd.return_value = True + mock_get().connect().execute.side_effect = [Exception('Message: being accessed by other users'), + mock.MagicMock(), mock.MagicMock(), mock.MagicMock()] + + # Execute + self.test_app.settings_set.select_subclasses().get(name='spatial_db').drop_persistent_store_database() + + # Check + mock_log.getLogger().info.assert_called_with('Dropping database "spatial_db" for app "test_app"...') + mock_get().connect.assert_called() + rts_call_args = mock_get().connect().execute.call_args_list + self.assertEqual('commit', rts_call_args[0][0][0]) + self.assertIn('SELECT pg_terminate_backend(pg_stat_activity.pid)', rts_call_args[1][0][0]) + mock_get().connect().close.assert_called() + + @mock.patch('tethys_apps.models.logging') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_value') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.persistent_store_database_exists') + def test_drop_persistent_store_database_connection_exception(self, mock_psd, mock_get, mock_log): + mock_psd.return_value = True + mock_get().connect.side_effect = [Exception('Message: being accessed by other users'), + mock.MagicMock(), mock.MagicMock()] + # Execute + self.test_app.settings_set.select_subclasses().get(name='spatial_db').drop_persistent_store_database() + + # Check + mock_log.getLogger().info.assert_called_with('Dropping database "spatial_db" for app "test_app"...') + mock_get().connect.assert_called() + mock_get().connect().execute.assert_not_called() + mock_get().connect().close.assert_not_called() + + @mock.patch('tethys_apps.models.logging') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_value') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.persistent_store_database_exists') + def test_drop_persistent_store_database_exception_else(self, mock_psd, mock_get, _): + mock_psd.return_value = True + mock_get().connect().execute.side_effect = [Exception('Error Message'), mock.MagicMock()] + + # Execute + self.assertRaises(Exception, PersistentStoreDatabaseSetting.objects. + get(name='spatial_db').drop_persistent_store_database) + + # Check + mock_get().connect().close.assert_called() + + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.drop_persistent_store_database') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_namespaced_persistent_store_name') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.persistent_store_database_exists') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_value') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.initializer_function') + @mock.patch('tethys_apps.models.logging') + def test_create_persistent_store_database(self, mock_log, mock_init, mock_get, mock_ps_de, mock_gn, mock_drop): + # Mock Get Name + mock_gn.return_value = 'spatial_db' + + # Mock Drop Database + mock_drop.return_value = '' + + # Mock persistent_store_database_exists + mock_ps_de.return_value = True + + # Mock get_values + mock_url = mock.MagicMock(username='test_app') + mock_engine = mock.MagicMock() + mock_new_db_engine = mock.MagicMock() + mock_init_param = mock.MagicMock() + mock_get.side_effect = [mock_url, mock_engine, mock_new_db_engine, mock_init_param] + + # Execute + self.test_app.settings_set.select_subclasses().get(name='spatial_db')\ + .create_persistent_store_database(refresh=True, force_first_time=True) + + # Check mock called + rts_get_args = mock_log.getLogger().info.call_args_list + check_log1 = 'Creating database "spatial_db" for app "test_app"...' + check_log2 = 'Enabling PostGIS on database "spatial_db" for app "test_app"...' + check_log3 = 'Initializing database "spatial_db" for app "test_app" ' \ + 'with initializer "appsettings.model.init_spatial_db"...' + self.assertEqual(check_log1, rts_get_args[0][0][0]) + self.assertEqual(check_log2, rts_get_args[1][0][0]) + self.assertEqual(check_log3, rts_get_args[2][0][0]) + mock_init.assert_called() + + @mock.patch('sqlalchemy.exc') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.drop_persistent_store_database') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_namespaced_persistent_store_name') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.persistent_store_database_exists') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_value') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.initializer_function') + @mock.patch('tethys_apps.models.logging') + def test_create_persistent_store_database_perm_error(self, _, __, mock_get, mock_ps_de, mock_gn, mock_drop, mock_e): + # Mock Get Name + mock_gn.return_value = 'spatial_db' + + # Mock Drop Database + mock_drop.return_value = '' + + # Mock persistent_store_database_exists + mock_ps_de.return_value = True + + # Mock get_values + mock_url = mock.MagicMock(username='test_app') + mock_engine = mock.MagicMock() + mock_e.ProgrammingError = Exception + mock_engine.connect().execute.side_effect = [mock.MagicMock(), Exception] + mock_get.side_effect = [mock_url, mock_engine] + + # Execute + self.assertRaises(PersistentStorePermissionError, PersistentStoreDatabaseSetting + .objects.get(name='spatial_db').create_persistent_store_database, refresh=True) + + @mock.patch('sqlalchemy.exc') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.drop_persistent_store_database') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_namespaced_persistent_store_name') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.persistent_store_database_exists') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_value') + @mock.patch('tethys_apps.models.logging') + def test_create_persistent_store_database_ext_perm_error(self, _, mock_get, mock_ps_de, mock_gn, mock_drop, mock_e): + # Mock Get Name + mock_gn.return_value = 'spatial_db' + + # Mock Drop Database + mock_drop.return_value = '' + + # Mock persistent_store_database_exists + mock_ps_de.return_value = True + + # Mock get_values + mock_url = mock.MagicMock(username='test_app') + mock_engine = mock.MagicMock() + mock_e.ProgrammingError = Exception + mock_new_db_engine = mock.MagicMock() + mock_new_db_engine.connect().execute.side_effect = Exception + mock_get.side_effect = [mock_url, mock_engine, mock_new_db_engine] + + # Execute + self.assertRaises(PersistentStorePermissionError, PersistentStoreDatabaseSetting + .objects.get(name='spatial_db').create_persistent_store_database) + + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.drop_persistent_store_database') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_namespaced_persistent_store_name') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.persistent_store_database_exists') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.get_value') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting.initializer_function') + @mock.patch('tethys_apps.models.logging') + def test_create_persistent_store_database_exception(self, _, mock_init, mock_get, mock_ps_de, + mock_gn, mock_drop): + # Mock initializer_function + mock_init.side_effect = Exception('Initializer Error') + # Mock Get Name + mock_gn.return_value = 'spatial_db' + + # Mock Drop Database + mock_drop.return_value = '' + + # Mock persistent_store_database_exists + mock_ps_de.return_value = True + + # Mock get_values + mock_url = mock.MagicMock(username='test_app') + mock_engine = mock.MagicMock() + mock_new_db_engine = mock.MagicMock() + mock_init_param = mock.MagicMock() + mock_get.side_effect = [mock_url, mock_engine, mock_new_db_engine, mock_init_param] + + # Execute + self.assertRaises(PersistentStoreInitializerError, PersistentStoreDatabaseSetting + .objects.get(name='spatial_db').create_persistent_store_database) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_SpatialDatasetServiceSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_SpatialDatasetServiceSetting.py new file mode 100644 index 000000000..44445d399 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_SpatialDatasetServiceSetting.py @@ -0,0 +1,95 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysApp +from django.core.exceptions import ValidationError +from tethys_services.models import SpatialDatasetService + + +class SpatialDatasetServiceTests(TethysTestCase): + def set_up(self): + self.test_app = TethysApp.objects.get(package='test_app') + + pass + + def tear_down(self): + pass + + def test_clean(self): + sds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver') + sds_setting.spatial_dataset_service = None + sds_setting.save() + # Check ValidationError + self.assertRaises(ValidationError, self.test_app.settings_set.select_subclasses(). + get(name='primary_geoserver').clean) + + def test_get_value(self): + sds = SpatialDatasetService( + name='test_sds', + endpoint='http://localhost/geoserver/rest/', + public_endpoint='http://publichost/geoserver/rest/', + apikey='test_api', + username='foo', + password='password', + ) + sds.save() + sds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver') + sds_setting.spatial_dataset_service = sds + sds_setting.save() + + ret = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver').get_value() + + # Check result + self.assertEqual('test_sds', ret.name) + self.assertEqual('http://localhost/geoserver/rest/', ret.endpoint) + self.assertEqual('http://publichost/geoserver/rest/', ret.public_endpoint) + self.assertEqual('test_api', ret.apikey) + self.assertEqual('foo', ret.username) + self.assertEqual('password', ret.password) + + def test_get_value_none(self): + sds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver') + sds_setting.spatial_dataset_service = None + sds_setting.save() + + ret = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver').get_value() + self.assertIsNone(ret) + + def test_get_value_check_if(self): + sds = SpatialDatasetService( + name='test_sds', + endpoint='http://localhost/geoserver/rest/', + public_endpoint='http://publichost/geoserver/rest/', + apikey='test_api', + username='foo', + password='password', + ) + sds.save() + sds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver') + sds_setting.spatial_dataset_service = sds + sds_setting.save() + + # Check as_engine + ret = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver').get_value(as_engine=True) + # Check result + self.assertEqual('GEOSERVER', ret.type) + self.assertEqual('http://localhost/geoserver/rest/', ret.endpoint) + + # Check as wms + ret = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver').get_value(as_wms=True) + # Check result + self.assertEqual('http://localhost/geoserver/wms', ret) + + # Check as wfs + ret = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver').get_value(as_wfs=True) + # Check result + self.assertEqual('http://localhost/geoserver/ows', ret) + + # Check as_endpoint + ret = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver').get_value(as_endpoint=True) + # Check result + self.assertEqual('http://localhost/geoserver/rest/', ret) + + # Check as_endpoint + ret = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver').\ + get_value(as_public_endpoint=True) + # Check result + self.assertEqual('http://publichost/geoserver/rest/', ret) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py b/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py new file mode 100644 index 000000000..7942d2aa9 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_TethysApp.py @@ -0,0 +1,287 @@ +""" +******************************************************************************** +* Name: test_TethysApp +* Author: nswain +* Created On: August 15, 2018 +* Copyright: (c) Aquaveo 2018 +******************************************************************************** +""" +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysApp, TethysAppSetting +from tethys_services.models import PersistentStoreService, SpatialDatasetService, DatasetService, WebProcessingService + + +class TethysAppTests(TethysTestCase): + + def set_up(self): + self.test_app = TethysApp.objects.get(package='test_app') + self.wps = WebProcessingService( + name='test_wps', + endpoint='http://localhost/wps/WebProcessingService', + username='foo', + password='password' + + ) + self.wps.save() + + self.sds = SpatialDatasetService( + name='test_sds', + endpoint='http://localhost/geoserver/rest/', + username='foo', + password='password' + ) + self.sds.save() + + self.ds = DatasetService( + name='test_ds', + endpoint='http://localhost/api/3/action/', + apikey='foo', + ) + self.ds.save() + + self.ps = PersistentStoreService( + name='test_ps', + host='localhost', + port='5432', + username='foo', + password='password' + ) + self.ps.save() + + def tear_down(self): + self.wps.delete() + self.ps.delete() + self.ds.delete() + self.sds.delete() + + def test_str(self): + ret = str(self.test_app) + self.assertEqual('Test App', ret) + + def test_add_settings(self): + new_setting = TethysAppSetting( + name='new_setting', + required=False + ) + + self.test_app.add_settings([new_setting]) + + app = TethysApp.objects.get(package='test_app') + settings = app.settings_set.filter(name='new_setting') + self.assertEqual(1, len(settings)) + + def test_add_settings_add_same_setting_twice(self): + new_setting = TethysAppSetting( + name='new_setting', + required=False + ) + new_setting_same_name = TethysAppSetting( + name='new_setting', + required=False + ) + + self.test_app.add_settings([new_setting, new_setting_same_name]) + + app = TethysApp.objects.get(package='test_app') + settings = app.settings_set.filter(name='new_setting') + self.assertEqual(1, len(settings)) + + def test_settings_prop(self): + ret = self.test_app.settings + self.assertEqual(12, len(ret)) + + for r in ret: + self.assertIsInstance(r, TethysAppSetting) + + def test_custom_settings_prop(self): + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = 'foo' + custom_setting.save() + + ret = self.test_app.custom_settings + + for r in ret: + self.assertIsInstance(r, TethysAppSetting) + if r.name == 'default_name': + self.assertEqual('foo', r.value) + + def test_dataset_service_settings_prop(self): + ds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_ckan') + ds_setting.dataset_service = self.ds + ds_setting.save() + + ret = self.test_app.dataset_service_settings + + for r in ret: + self.assertIsInstance(r, TethysAppSetting) + if r.name == 'primary_ckan': + self.assertEqual('test_ds', r.dataset_service.name) + self.assertEqual('foo', r.dataset_service.apikey) + self.assertEqual('http://localhost/api/3/action/', r.dataset_service.endpoint) + + def test_spatial_dataset_service_settings_prop(self): + sds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver') + sds_setting.spatial_dataset_service = self.sds + sds_setting.save() + + ret = self.test_app.spatial_dataset_service_settings + + for r in ret: + self.assertIsInstance(r, TethysAppSetting) + if r.name == 'primary_geoserver': + self.assertEqual('test_sds', r.spatial_dataset_service.name) + self.assertEqual('http://localhost/geoserver/rest/', r.spatial_dataset_service.endpoint) + self.assertEqual('foo', r.spatial_dataset_service.username) + self.assertEqual('password', r.spatial_dataset_service.password) + + def test_wps_services_settings_prop(self): + wps_setting = self.test_app.settings_set.select_subclasses().get(name='primary_52n') + wps_setting.web_processing_service = self.wps + wps_setting.save() + + ret = self.test_app.wps_services_settings + + for r in ret: + self.assertIsInstance(r, TethysAppSetting) + if r.name == 'primary_52n': + self.assertEqual('test_wps', r.web_processing_service.name) + self.assertEqual('http://localhost/wps/WebProcessingService', r.web_processing_service.endpoint) + self.assertEqual('foo', r.web_processing_service.username) + self.assertEqual('password', r.web_processing_service.password) + + def test_persistent_store_connection_settings_prop(self): + ps_setting = self.test_app.settings_set.select_subclasses().get(name='primary') + ps_setting.persistent_store_service = self.ps + ps_setting.save() + + ret = self.test_app.persistent_store_connection_settings + + for r in ret: + self.assertIsInstance(r, TethysAppSetting) + if r.name == 'primary': + self.assertEqual('test_ps', r.persistent_store_service.name) + self.assertEqual('localhost', r.persistent_store_service.host) + self.assertEqual(5432, r.persistent_store_service.port) + self.assertEqual('foo', r.persistent_store_service.username) + self.assertEqual('password', r.persistent_store_service.password) + + def test_persistent_store_database_settings_prop(self): + ps_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_setting.persistent_store_service = self.ps + ps_setting.save() + + ret = self.test_app.persistent_store_database_settings + + for r in ret: + self.assertIsInstance(r, TethysAppSetting) + if r.name == 'spatial_db': + self.assertEqual('test_ps', r.persistent_store_service.name) + self.assertEqual('localhost', r.persistent_store_service.host) + self.assertEqual(5432, r.persistent_store_service.port) + self.assertEqual('foo', r.persistent_store_service.username) + self.assertEqual('password', r.persistent_store_service.password) + + def test_configured_prop_required_and_set(self): + # See: test_app.app for expected settings configuration + # Set required settings + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = 'foo' + custom_setting.save() + + ds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_ckan') + ds_setting.dataset_service = self.ds + ds_setting.save() + + sds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver') + sds_setting.spatial_dataset_service = self.sds + sds_setting.save() + + wps_setting = self.test_app.settings_set.select_subclasses().get(name='primary_52n') + wps_setting.web_processing_service = self.wps + wps_setting.save() + + ps_setting = self.test_app.settings_set.select_subclasses().get(name='primary') + ps_setting.persistent_store_service = self.ps + ps_setting.save() + + ps_db_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + ps_db_setting.persistent_store_service = self.ps + ps_db_setting.save() + + ret = self.test_app.configured + + self.assertTrue(ret) + + def test_configured_prop_required_no_value(self): + # See: test_app.app for expected settings configuration + # Set required settings + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = '' # <-- NOT SET / NO VALUE + custom_setting.save() + + ds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_ckan') + ds_setting.dataset_service = self.ds + ds_setting.save() + + sds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver') + sds_setting.spatial_dataset_service = self.sds + sds_setting.save() + + wps_setting = self.test_app.settings_set.select_subclasses().get(name='primary_52n') + wps_setting.web_processing_service = self.wps + wps_setting.save() + + ps_setting = self.test_app.settings_set.select_subclasses().get(name='primary') + ps_setting.persistent_store_service = self.ps + ps_setting.save() + + psd_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + psd_setting.persistent_store_service = self.ps + psd_setting.save() + + ret = self.test_app.configured + self.assertFalse(ret) + + def test_configured_prop_not_assigned_exception(self): + # See: test_app.app for expected settings configuration + custom_setting = self.test_app.settings_set.select_subclasses().get(name='default_name') + custom_setting.value = '' + custom_setting.save() + + ds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_ckan') + ds_setting.dataset_service = None + ds_setting.save() + + sds_setting = self.test_app.settings_set.select_subclasses().get(name='primary_geoserver') + sds_setting.spatial_dataset_service = None + sds_setting.save() + + wps_setting = self.test_app.settings_set.select_subclasses().get(name='primary_52n') + wps_setting.web_processing_service = None + wps_setting.save() + + ps_setting = self.test_app.settings_set.select_subclasses().get(name='primary') + ps_setting.persistent_store_service = None + ps_setting.save() + + psd_setting = self.test_app.settings_set.select_subclasses().get(name='spatial_db') + psd_setting.persistent_store_service = None + psd_setting.save() + + ret = self.test_app.configured + + self.assertFalse(ret) + + +class TethysAppNoSettingsTests(TethysTestCase): + + def set_up(self): + self.test_app = TethysApp.objects.get(package='test_app') + + # See: test_app.app for expected settings configuration + for setting in self.test_app.settings_set.all(): + setting.delete() + + def test_configured_prop_no_settings(self): + ret = self.test_app.configured + self.assertTrue(ret) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_TethysAppSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_TethysAppSetting.py new file mode 100644 index 000000000..7525396d0 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_TethysAppSetting.py @@ -0,0 +1,30 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysAppSetting +import mock + + +class TethysAppSettingTests(TethysTestCase): + def set_up(self): + self.test_app_setting = TethysAppSetting.objects.get(name='primary_ckan') + + def tear_down(self): + pass + + def test_str(self): + ret = str(self.test_app_setting) + self.assertEqual('primary_ckan', ret) + + @mock.patch('tethys_apps.models.TethysFunctionExtractor') + def test_initializer_function_prop(self, mock_tfe): + mock_tfe.return_value = mock.MagicMock(function='test_function') + ret = self.test_app_setting.initializer_function + + self.assertEqual('test_function', ret) + + @mock.patch('tethys_apps.models.TethysAppSetting.initializer_function') + def test_initialize(self, mock_if): + self.test_app_setting.initialize() + mock_if.assert_called_with(False) + + def test_get_value(self): + self.assertRaises(NotImplementedError, self.test_app_setting.get_value, 'test') diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_TethysExtension.py b/tests/unit_tests/test_tethys_apps/test_models/test_TethysExtension.py new file mode 100644 index 000000000..81b1639c9 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_TethysExtension.py @@ -0,0 +1,15 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysExtension + + +class TethysExtensionTests(TethysTestCase): + + def set_up(self): + self.test_ext = TethysExtension.objects.get(package='test_extension') + + def tear_down(self): + pass + + def test_str(self): + ret = str(self.test_ext) + self.assertEqual('Test Extension', ret) diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_WebProcessingServiceSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_WebProcessingServiceSetting.py new file mode 100644 index 000000000..2c02785a3 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/test_WebProcessingServiceSetting.py @@ -0,0 +1,60 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_apps.models import TethysApp, WebProcessingServiceSetting +from django.core.exceptions import ValidationError +import mock + + +class WebProcessingServiceSettingTests(TethysTestCase): + def set_up(self): + self.test_app = TethysApp.objects.get(package='test_app') + + pass + + def tear_down(self): + pass + + def test_clean_empty_validation_error(self): + wps_setting = self.test_app.settings_set.select_subclasses().get(name='primary_52n') + wps_setting.web_processing_service = None + wps_setting.save() + # Check ValidationError + self.assertRaises(ValidationError, WebProcessingServiceSetting.objects.get(name='primary_52n').clean) + + def test_get_value_None(self): + wps_setting = self.test_app.settings_set.select_subclasses().get(name='primary_52n') + wps_setting.web_processing_service = None + wps_setting.save() + + ret = WebProcessingServiceSetting.objects.get(name='primary_52n').get_value() + self.assertIsNone(ret) + + @mock.patch('tethys_apps.models.WebProcessingServiceSetting.web_processing_service') + def test_get_value(self, mock_wps): + mock_wps.get_engine.return_value = 'test_wps_engine' + mock_wps.endpoint = 'test_endpoint' + mock_wps.public_endpoint = 'test_public_endpoint' + mock_wps.name = 'test_wps_name' + + ret = WebProcessingServiceSetting.objects.get(name='primary_52n').get_value() + self.assertEqual('test_wps_engine', ret.get_engine()) + self.assertEqual('test_wps_name', ret.name) + self.assertEqual('test_endpoint', ret.endpoint) + self.assertEqual('test_public_endpoint', ret.public_endpoint) + + @mock.patch('tethys_apps.models.WebProcessingServiceSetting.web_processing_service') + def test_get_value_check_if(self, mock_wps): + mock_wps.get_engine.return_value = 'test_wps_engine' + mock_wps.endpoint = 'test_endpoint' + mock_wps.public_endpoint = 'test_public_endpoint' + + # Check if as_engine + ret = WebProcessingServiceSetting.objects.get(name='primary_52n').get_value(as_engine=True) + self.assertEqual('test_wps_engine', ret) + + # Check if as_endpoint + ret = WebProcessingServiceSetting.objects.get(name='primary_52n').get_value(as_endpoint=True) + self.assertEqual('test_endpoint', ret) + + # Check if as_public_endpoint + ret = WebProcessingServiceSetting.objects.get(name='primary_52n').get_value(as_public_endpoint=True) + self.assertEqual('test_public_endpoint', ret) diff --git a/tests/unit_tests/test_tethys_apps/test_models/tests_models.py b/tests/unit_tests/test_tethys_apps/test_models/tests_models.py new file mode 100644 index 000000000..04bb0deb1 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_models/tests_models.py @@ -0,0 +1,38 @@ +""" +******************************************************************************** +* Name: tests_models.py +* Author: nswain +* Created On: August 29, 2018 +* Copyright: (c) Aquaveo 2018 +******************************************************************************** +""" +# import unittest +# import mock +# import tethys_apps.models +# from __builtin__ import __import__ as real_import +# +# +# def mock_import(name, globals={}, locals={}, fromlist=[], level=-1): +# if name == 'tethys_services.models' and len(fromlist) == 4: +# raise RuntimeError +# return real_import(name, globals, locals, fromlist, level) +# +# +# class ModelsTests(unittest.TestCase): +# def setUp(self): +# pass +# +# def tearDown(self): +# pass +# +# @mock.patch('__builtin__.__import__', side_effect=mock_import) +# @mock.patch('tethys_apps.models.log') +# def test_service_models_import_error(self, mock_log, _): +# # mock_log.exception.side_effect = SystemExit +# tethys_apps.models.logging = mock.MagicMock +# try: +# reload(tethys_apps.models) +# except SystemExit: +# pass +# +# # mock_log.exception.assert_called_with('An error occurred while trying to import tethys service models.') diff --git a/tests/unit_tests/test_tethys_apps/test_static_finders.py b/tests/unit_tests/test_tethys_apps/test_static_finders.py new file mode 100644 index 000000000..2ad749ae0 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_static_finders.py @@ -0,0 +1,66 @@ +import os +import unittest +from tethys_apps.static_finders import TethysStaticFinder + + +class TestTethysStaticFinder(unittest.TestCase): + def setUp(self): + self.src_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + self.root = os.path.join(self.src_dir, 'tethys_apps', 'tethysapp', 'test_app', 'public') + + def tearDown(self): + pass + + def test_init(self): + pass + + def test_find(self): + tethys_static_finder = TethysStaticFinder() + path = 'test_app/css/main.css' + ret = tethys_static_finder.find(path) + self.assertEqual(os.path.join(self.root, 'css/main.css'), ret) + + def test_find_all(self): + tethys_static_finder = TethysStaticFinder() + path = 'test_app/css/main.css' + ret = tethys_static_finder.find(path, all=True) + self.assertIn(os.path.join(self.root, 'css/main.css'), ret) + + def test_find_location_with_no_prefix(self): + prefix = None + path = 'css/main.css' + + tethys_static_finder = TethysStaticFinder() + ret = tethys_static_finder.find_location(self.root, path, prefix) + + self.assertEqual(os.path.join(self.root, path), ret) + + def test_find_location_with_prefix_not_in_path(self): + prefix = 'tethys_app' + path = 'css/main.css' + + tethys_static_finder = TethysStaticFinder() + ret = tethys_static_finder.find_location(self.root, path, prefix) + + self.assertIsNone(ret) + + def test_find_location_with_prefix_in_path(self): + prefix = 'tethys_app' + path = 'tethys_app/css/main.css' + + tethys_static_finder = TethysStaticFinder() + ret = tethys_static_finder.find_location(self.root, path, prefix) + + self.assertEqual(os.path.join(self.root, 'css/main.css'), ret) + + def test_list(self): + tethys_static_finder = TethysStaticFinder() + expected_ignore_patterns = '' + expected_app_paths = [] + for path, storage in tethys_static_finder.list(expected_ignore_patterns): + if 'test_app' in storage.location: + expected_app_paths.append(path) + + self.assertIn('js/main.js', expected_app_paths) + self.assertIn('images/icon.gif', expected_app_paths) + self.assertIn('css/main.css', expected_app_paths) diff --git a/tests/unit_tests/test_tethys_apps/test_template_loaders.py b/tests/unit_tests/test_tethys_apps/test_template_loaders.py new file mode 100644 index 000000000..145045261 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_template_loaders.py @@ -0,0 +1,76 @@ +import unittest +import mock +import errno + +from django.template import TemplateDoesNotExist +from tethys_apps.template_loaders import TethysTemplateLoader + + +class TestTethysTemplateLoader(unittest.TestCase): + def setUp(self): + self.mock_engine = mock.MagicMock() + + def tearDown(self): + pass + + @mock.patch('tethys_apps.template_loaders.io.open', new_callable=mock.mock_open) + @mock.patch('tethys_apps.template_loaders.BaseLoader') + def test_get_contents(self, _, mock_file): + handlers = (mock.mock_open(read_data='mytemplate').return_value, mock_file.return_value) + mock_file.side_effect = handlers + origin = mock.MagicMock(name='test_app/css/main.css') + + tethys_template_loader = TethysTemplateLoader(self.mock_engine) + + ret = tethys_template_loader.get_contents(origin) + self.assertIn('mytemplate', ret) + mock_file.assert_called_once() + + @mock.patch('tethys_apps.template_loaders.io.open') + @mock.patch('tethys_apps.template_loaders.BaseLoader') + def test_get_contents_io_error(self, _, mock_file): + mock_file.side_effect = IOError + origin = mock.MagicMock(name='test_app/css/main.css') + + tethys_template_loader = TethysTemplateLoader(self.mock_engine) + + self.assertRaises(IOError, tethys_template_loader.get_contents, origin) + mock_file.assert_called_once() + + @mock.patch('tethys_apps.template_loaders.io.open', side_effect=IOError(errno.ENOENT, 'foo')) + @mock.patch('tethys_apps.template_loaders.BaseLoader') + def test_get_contents_template_does_not_exist(self, _, mock_file): + origin = mock.MagicMock(name='test_app/css/main.css') + + tethys_template_loader = TethysTemplateLoader(self.mock_engine) + + self.assertRaises(TemplateDoesNotExist, tethys_template_loader.get_contents, origin) + mock_file.assert_called_once() + + @mock.patch('tethys_apps.template_loaders.BaseLoader') + @mock.patch('tethys_apps.template_loaders.get_directories_in_tethys') + def test_get_template_sources(self, mock_gdt, _): + tethys_template_loader = TethysTemplateLoader(self.mock_engine) + mock_gdt.return_value = ['/foo/template1'] + expected_template_name = 'foo' + + for origin in tethys_template_loader.get_template_sources(expected_template_name): + self.assertEquals('/foo/template1/foo', origin.name) + self.assertEquals('foo', origin.template_name) + self.assertTrue(isinstance(origin.loader, TethysTemplateLoader)) + + @mock.patch('tethys_apps.template_loaders.safe_join') + @mock.patch('tethys_apps.template_loaders.BaseLoader') + @mock.patch('tethys_apps.template_loaders.get_directories_in_tethys') + def test_get_template_sources_exception(self, mock_gdt, _, mock_safe_join): + from django.core.exceptions import SuspiciousFileOperation + + tethys_template_loader = TethysTemplateLoader(self.mock_engine) + mock_gdt.return_value = ['/foo/template1', '/foo/template2'] + mock_safe_join.side_effect = [SuspiciousFileOperation, '/foo/template2/foo'] + expected_template_name = 'foo' + + for origin in tethys_template_loader.get_template_sources(expected_template_name): + self.assertEquals('/foo/template2/foo', origin.name) + self.assertEquals('foo', origin.template_name) + self.assertTrue(isinstance(origin.loader, TethysTemplateLoader)) diff --git a/tests/unit_tests/test_tethys_apps/test_templatetags/__init__.py b/tests/unit_tests/test_tethys_apps/test_templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_apps/test_templatetags/test_tags.py b/tests/unit_tests/test_tethys_apps/test_templatetags/test_tags.py new file mode 100644 index 000000000..75dd696e9 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_templatetags/test_tags.py @@ -0,0 +1,26 @@ +import unittest +import mock +from tethys_apps.templatetags import tags as t + + +class TestTags(unittest.TestCase): + def setUp(self): + # app_list + self.app_names = ['app1', 'app2', 'app3', 'app4', 'app5', 'app6'] + self.tag_names = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6'] + self.mock_apps = [] + for i, ap in enumerate(self.app_names): + mock_app = mock.MagicMock(tags=','.join(self.tag_names[:i+1])) + mock_app.name = ap + self.mock_apps.append(mock_app) + + def tearDown(self): + pass + + def test_get_tags_from_apps(self): + ret_tag_list = t.get_tags_from_apps(self.mock_apps) + self.assertEqual(sorted(self.tag_names), sorted(ret_tag_list)) + + def test_get_tag_class(self): + ret_tag_list = t.get_tag_class(self.mock_apps[-1]) + self.assertEqual(' '.join(sorted(self.tag_names)), ret_tag_list) diff --git a/tests/unit_tests/test_tethys_apps/test_urls.py b/tests/unit_tests/test_tethys_apps/test_urls.py new file mode 100644 index 000000000..3903bce36 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_urls.py @@ -0,0 +1,43 @@ +from django.urls import reverse, resolve +from tethys_sdk.testing import TethysTestCase + + +class TestUrls(TethysTestCase): + + def set_up(self): + pass + + def tear_down(self): + pass + + def test_urls(self): + # This executes the code at the module level + url = reverse('app_library') + resolver = resolve(url) + self.assertEqual('/apps/', url) + self.assertEqual('library', resolver.func.__name__) + self.assertEqual('tethys_apps.views', resolver.func.__module__) + + url = reverse('send_beta_feedback') + resolver = resolve(url) + self.assertEqual('/apps/send-beta-feedback/', url) + self.assertEquals('send_beta_feedback_email', resolver.func.__name__) + self.assertEqual('tethys_apps.views', resolver.func.__module__) + + url = reverse('test_app:home', args=['foo', 'bar']) + resolver = resolve(url) + self.assertEqual('/apps/test-app/foo/bar/', url) + self.assertEquals('home', resolver.func.__name__) + self.assertEqual('tethys_apps.tethysapp.test_app.controllers', resolver.func.__module__) + + url = reverse('test_app:home', kwargs={'var1': 'foo', 'var2': 'bar'}) + resolver = resolve(url) + self.assertEqual('/apps/test-app/foo/bar/', url) + self.assertEquals('home', resolver.func.__name__) + self.assertEqual('tethys_apps.tethysapp.test_app.controllers', resolver.func.__module__) + + url = reverse('test_extension:home', args=['foo', 'bar']) + resolver = resolve(url) + self.assertEqual('/extensions/test-extension/foo/bar/', url) + self.assertEquals('home', resolver.func.__name__) + self.assertEqual('tethysext.test_extension.controllers', resolver.func.__module__) diff --git a/tests/unit_tests/test_tethys_apps/test_utilities.py b/tests/unit_tests/test_tethys_apps/test_utilities.py new file mode 100644 index 000000000..3f0e304d2 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_utilities.py @@ -0,0 +1,483 @@ +import unittest +import mock + +from tethys_apps import utilities + + +class TethysAppsUtilitiesTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_get_directories_in_tethys_templates(self): + # Get the templates directories for the test_app and test_extension + result = utilities.get_directories_in_tethys(('templates',)) + self.assertGreaterEqual(len(result), 2) + + test_app = False + test_ext = False + + for r in result: + if '/tethysapp/test_app/templates' in r: + test_app = True + if '/tethysext-test_extension/tethysext/test_extension/templates' in r: + test_ext = True + + self.assertTrue(test_app) + self.assertTrue(test_ext) + + def test_get_directories_in_tethys_templates_with_app_name(self): + # Get the templates directories for the test_app and test_extension + # Use the with_app_name argument, so that the app and extension names appear in the result + result = utilities.get_directories_in_tethys(('templates',), with_app_name=True) + self.assertGreaterEqual(len(result), 2) + self.assertEqual(2, len(result[0])) + self.assertEqual(2, len(result[1])) + + test_app = False + test_ext = False + + for r in result: + if 'test_app' in r and '/tethysapp/test_app/templates' in r[1]: + test_app = True + if 'test_extension' in r and '/tethysext-test_extension/tethysext/test_extension/templates' in r[1]: + test_ext = True + + self.assertTrue(test_app) + self.assertTrue(test_ext) + + @mock.patch('tethys_apps.utilities.SingletonHarvester') + def test_get_directories_in_tethys_templates_extension_import_error(self, mock_harvester): + # Mock the extension_modules variable with bad data, to throw an ImportError + mock_harvester().extension_modules = {'foo_invalid_foo': 'tethysext.foo_invalid_foo'} + + result = utilities.get_directories_in_tethys(('templates',)) + self.assertGreaterEqual(len(result), 1) + + test_app = False + test_ext = False + + for r in result: + if '/tethysapp/test_app/templates' in r: + test_app = True + if '/tethysext-test_extension/tethysext/test_extension/templates' in r: + test_ext = True + + self.assertTrue(test_app) + self.assertFalse(test_ext) + + def test_get_directories_in_tethys_foo(self): + # Get the foo directories for the test_app and test_extension + # foo doesn't exist + result = utilities.get_directories_in_tethys(('foo',)) + self.assertEqual(0, len(result)) + + def test_get_directories_in_tethys_foo_public(self): + # Get the foo and public directories for the test_app and test_extension + # foo doesn't exist, but public will + result = utilities.get_directories_in_tethys(('foo', 'public')) + self.assertGreaterEqual(len(result), 2) + + test_app = False + test_ext = False + + for r in result: + if '/tethysapp/test_app/public' in r: + test_app = True + if '/tethysext-test_extension/tethysext/test_extension/public' in r: + test_ext = True + + self.assertTrue(test_app) + self.assertTrue(test_ext) + + def test_get_active_app_none_none(self): + # Get the active TethysApp object, with a request of None and url of None + result = utilities.get_active_app(request=None, url=None) + self.assertEqual(None, result) + + # Try again with the defaults, which are a request of None and url of None + result = utilities.get_active_app() + self.assertEqual(None, result) + + @mock.patch('tethys_apps.models.TethysApp') + def test_get_active_app_request(self, mock_app): + # Mock up for TethysApp, and request + mock_app.objects.get.return_value = mock.MagicMock() + mock_request = mock.MagicMock() + mock_request.path = '/apps/foo/bar' + + # Result should be mock for mock_app.objects.get.return_value + result = utilities.get_active_app(request=mock_request) + self.assertEqual(mock_app.objects.get(), result) + + @mock.patch('tethys_apps.models.TethysApp') + def test_get_active_app_url(self, mock_app): + # Mock up for TethysApp + mock_app.objects.get.return_value = mock.MagicMock() + + # Result should be mock for mock_app.objects.get.return_value + result = utilities.get_active_app(url='/apps/foo/bar') + self.assertEqual(mock_app.objects.get(), result) + + @mock.patch('tethys_apps.models.TethysApp') + def test_get_active_app_request_bad_path(self, mock_app): + # Mock up for TethysApp + mock_app.objects.get.return_value = mock.MagicMock() + mock_request = mock.MagicMock() + # Path does not contain apps + mock_request.path = '/foo/bar' + + # Because 'app' not in request path, return None + result = utilities.get_active_app(request=mock_request) + self.assertEqual(None, result) + + @mock.patch('tethys_apps.utilities.tethys_log.warning') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_active_app_request_exception1(self, mock_app, mock_log_warning): + from django.core.exceptions import ObjectDoesNotExist + + # Mock up for TethysApp to raise exception, and request + mock_app.objects.get.side_effect = ObjectDoesNotExist + mock_request = mock.MagicMock() + mock_request.path = '/apps/foo/bar' + + # Result should be None due to the exception + result = utilities.get_active_app(request=mock_request) + self.assertEqual(None, result) + mock_log_warning.assert_called_once_with('Could not locate app with root url "foo".') + + @mock.patch('tethys_apps.utilities.tethys_log.warning') + @mock.patch('tethys_apps.models.TethysApp') + def test_get_active_app_request_exception2(self, mock_app, mock_log_warning): + from django.core.exceptions import MultipleObjectsReturned + + # Mock up for TethysApp to raise exception, and request + mock_app.objects.get.side_effect = MultipleObjectsReturned + mock_request = mock.MagicMock() + mock_request.path = '/apps/foo/bar' + + # Result should be None due to the exception + result = utilities.get_active_app(request=mock_request) + self.assertEqual(None, result) + mock_log_warning.assert_called_once_with('Multiple apps found with root url "foo".') + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_apps.models.TethysApp') + def test_create_ps_database_setting_app_does_not_exist(self, mock_app, mock_pretty_output): + from django.core.exceptions import ObjectDoesNotExist + + # Mock up for TethysApp to not exist + mock_app.objects.get.side_effect = ObjectDoesNotExist + mock_app_package = mock.MagicMock() + mock_name = mock.MagicMock() + + # ObjectDoesNotExist should be thrown, and False returned + result = utilities.create_ps_database_setting(app_package=mock_app_package, name=mock_name) + + self.assertEqual(False, result) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('A Tethys App with the name', po_call_args[0][0][0]) + self.assertIn('does not exist. Aborted.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting') + @mock.patch('tethys_apps.models.TethysApp') + def test_create_ps_database_setting_ps_database_setting_exists(self, mock_app, mock_ps_db_setting, + mock_pretty_output): + # Mock up for TethysApp and PersistentStoreDatabaseSetting to exist + mock_app.objects.get.return_value = mock.MagicMock() + mock_ps_db_setting.objects.get.return_value = mock.MagicMock() + mock_app_package = mock.MagicMock() + mock_name = mock.MagicMock() + + # PersistentStoreDatabaseSetting should exist, and False returned + result = utilities.create_ps_database_setting(app_package=mock_app_package, name=mock_name) + + self.assertEqual(False, result) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('A PersistentStoreDatabaseSetting with name', po_call_args[0][0][0]) + self.assertIn('already exists. Aborted.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.utilities.print') + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting') + @mock.patch('tethys_apps.models.TethysApp') + def test_create_ps_database_setting_ps_database_setting_exceptions(self, mock_app, mock_ps_db_setting, + mock_pretty_output, mock_print): + from django.core.exceptions import ObjectDoesNotExist + + # Mock up for TethysApp to exist and PersistentStoreDatabaseSetting to throw exceptions + mock_app.objects.get.return_value = mock.MagicMock() + mock_ps_db_setting.objects.get.side_effect = ObjectDoesNotExist + mock_ps_db_setting().save.side_effect = Exception('foo exception') + mock_app_package = mock.MagicMock() + mock_name = mock.MagicMock() + + # PersistentStoreDatabaseSetting should exist, and False returned + result = utilities.create_ps_database_setting(app_package=mock_app_package, name=mock_name) + + self.assertEqual(False, result) + mock_ps_db_setting.assert_called() + mock_ps_db_setting().save.assert_called() + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('The above error was encountered. Aborted.', po_call_args[0][0][0]) + rts_call_args = mock_print.call_args_list + self.assertIn('foo exception', rts_call_args[0][0][0].args[0]) + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting') + @mock.patch('tethys_apps.models.TethysApp') + def test_create_ps_database_setting_ps_database_savess(self, mock_app, mock_ps_db_setting, mock_pretty_output): + # Mock up for TethysApp to exist and PersistentStoreDatabaseSetting to not + mock_app.objects.get.return_value = mock.MagicMock() + mock_ps_db_setting.objects.get.return_value = False + mock_ps_db_setting().save.return_value = True + mock_app_package = mock.MagicMock() + mock_name = mock.MagicMock() + + # True should be returned + result = utilities.create_ps_database_setting(app_package=mock_app_package, name=mock_name) + + self.assertEqual(True, result) + mock_ps_db_setting.assert_called() + mock_ps_db_setting().save.assert_called() + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('PersistentStoreDatabaseSetting named', po_call_args[0][0][0]) + self.assertIn('created successfully!', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_apps.models.TethysApp') + def test_remove_ps_database_setting_app_not_exist(self, mock_app, mock_pretty_output): + from django.core.exceptions import ObjectDoesNotExist + + # Mock up for TethysApp to throw an exception + mock_app.objects.get.side_effect = ObjectDoesNotExist + mock_app_package = mock.MagicMock() + mock_name = mock.MagicMock() + + # An exception will be thrown and false returned + result = utilities.remove_ps_database_setting(app_package=mock_app_package, name=mock_name) + + self.assertEqual(False, result) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('A Tethys App with the name', po_call_args[0][0][0]) + self.assertIn('does not exist. Aborted.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting') + @mock.patch('tethys_apps.models.TethysApp') + def test_remove_ps_database_setting_psdbs_not_exist(self, mock_app, mock_ps_db_setting, mock_pretty_output): + from django.core.exceptions import ObjectDoesNotExist + + # Mock up for TethysApp and PersistentStoreDatabaseSetting to throw an exception + mock_app.objects.get.return_value = mock.MagicMock() + mock_ps_db_setting.objects.get.side_effect = ObjectDoesNotExist + mock_app_package = mock.MagicMock() + mock_name = mock.MagicMock() + + # An exception will be thrown and false returned + result = utilities.remove_ps_database_setting(app_package=mock_app_package, name=mock_name) + + self.assertEqual(False, result) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('An PersistentStoreDatabaseSetting with the name', po_call_args[0][0][0]) + self.assertIn(' for app ', po_call_args[0][0][0]) + self.assertIn('does not exist. Aborted.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting') + @mock.patch('tethys_apps.models.TethysApp') + def test_remove_ps_database_setting_force_delete(self, mock_app, mock_ps_db_setting, mock_pretty_output): + # Mock up for TethysApp and PersistentStoreDatabaseSetting + mock_app.objects.get.return_value = mock.MagicMock() + mock_ps_db_setting.objects.get.return_value = mock.MagicMock() + mock_ps_db_setting.objects.get().delete.return_value = True + mock_app_package = mock.MagicMock() + mock_name = mock.MagicMock() + + # Delete will be called and True returned + result = utilities.remove_ps_database_setting(app_package=mock_app_package, name=mock_name, force=True) + + self.assertEqual(True, result) + mock_ps_db_setting.objects.get().delete.assert_called_once() + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Successfully removed PersistentStoreDatabaseSetting with name', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.utilities.input') + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting') + @mock.patch('tethys_apps.models.TethysApp') + def test_remove_ps_database_setting_proceed_delete(self, mock_app, mock_ps_db_setting, mock_pretty_output, + mock_input): + # Mock up for TethysApp and PersistentStoreDatabaseSetting + mock_app.objects.get.return_value = mock.MagicMock() + mock_ps_db_setting.objects.get.return_value = mock.MagicMock() + mock_ps_db_setting.objects.get().delete.return_value = True + mock_input.side_effect = ['Y'] + mock_app_package = mock.MagicMock() + mock_name = mock.MagicMock() + + # Based on the raw_input, delete not called and None returned + result = utilities.remove_ps_database_setting(app_package=mock_app_package, name=mock_name) + + self.assertEqual(True, result) + mock_ps_db_setting.objects.get().delete.assert_called() + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('Successfully removed PersistentStoreDatabaseSetting with name', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.utilities.input') + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_apps.models.PersistentStoreDatabaseSetting') + @mock.patch('tethys_apps.models.TethysApp') + def test_remove_ps_database_setting_do_not_proceed(self, mock_app, mock_ps_db_setting, mock_pretty_output, + mock_input): + # Mock up for TethysApp and PersistentStoreDatabaseSetting + mock_app.objects.get.return_value = mock.MagicMock() + mock_ps_db_setting.objects.get.return_value = mock.MagicMock() + mock_ps_db_setting.objects.get().delete.return_value = True + mock_input.side_effect = ['foo', 'N'] + mock_app_package = mock.MagicMock() + mock_name = mock.MagicMock() + + # Based on the raw_input, delete not called and None returned + result = utilities.remove_ps_database_setting(app_package=mock_app_package, name=mock_name) + + self.assertEqual(None, result) + mock_ps_db_setting.objects.get().delete.assert_not_called() + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertEqual('Aborted. PersistentStoreDatabaseSetting not removed.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_services.models.SpatialDatasetService') + def test_link_service_to_app_setting_spatial_dss_does_not_exist(self, mock_service, mock_pretty_output): + from django.core.exceptions import ObjectDoesNotExist + + # Mock up the SpatialDatasetService to throw ObjectDoesNotExist + mock_service.objects.get.side_effect = ObjectDoesNotExist + + # Based on exception, False will be returned + result = utilities.link_service_to_app_setting(service_type='spatial', service_uid='123', + app_package='foo_app', setting_type='ds_spatial', + setting_uid='456') + + self.assertEqual(False, result) + mock_service.objects.get.assert_called_once_with(pk=123) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('with ID/Name', po_call_args[0][0][0]) + self.assertIn('does not exist.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_services.models.SpatialDatasetService') + @mock.patch('tethys_apps.models.TethysApp') + def test_link_service_to_app_setting_spatial_dss_value_error(self, mock_app, mock_service, mock_pretty_output): + from django.core.exceptions import ObjectDoesNotExist + + # Mock up TethysApp to throw ObjectDoesNotExist + mock_app.objects.get.side_effect = ObjectDoesNotExist + # Mock up the SpatialDatasetService to MagicMock + mock_service.objects.get.return_value = mock.MagicMock() + + # Based on ValueError exception casting to int, then TethysApp ObjectDoesNotExist False will be returned + result = utilities.link_service_to_app_setting(service_type='spatial', service_uid='foo_spatial_service', + app_package='foo_app', setting_type='ds_spatial', + setting_uid='456') + + self.assertEqual(False, result) + mock_service.objects.get.assert_called_once_with(name='foo_spatial_service') + mock_app.objects.get.assert_called_once_with(package='foo_app') + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('A Tethys App with the name', po_call_args[0][0][0]) + self.assertIn('does not exist. Aborted.', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_services.models.SpatialDatasetService') + @mock.patch('tethys_apps.models.TethysApp') + def test_link_service_to_app_setting_spatial_link_key_error(self, mock_app, mock_service, mock_pretty_output): + # Mock up TethysApp to MagicMock + mock_app.objects.get.return_value = mock.MagicMock() + # Mock up the SpatialDatasetService to MagicMock + mock_service.objects.get.return_value = mock.MagicMock() + + # Based on KeyError for invalid setting_type False will be returned + result = utilities.link_service_to_app_setting(service_type='spatial', service_uid='foo_spatial_service', + app_package='foo_app', setting_type='foo_invalid', + setting_uid='456') + + self.assertEqual(False, result) + mock_service.objects.get.assert_called_once_with(name='foo_spatial_service') + mock_app.objects.get.assert_called_once_with(package='foo_app') + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('The setting_type you specified ("foo_invalid") does not exist.', po_call_args[0][0][0]) + self.assertIn('Choose from: "ps_database|ps_connection|ds_spatial"', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_sdk.app_settings.SpatialDatasetServiceSetting') + @mock.patch('tethys_services.models.SpatialDatasetService') + @mock.patch('tethys_apps.models.TethysApp') + def test_link_service_to_app_setting_spatial_link_value_error_save(self, mock_app, mock_service, mock_setting, + mock_pretty_output): + # Mock up TethysApp to MagicMock + mock_app.objects.get.return_value = mock.MagicMock() + # Mock up the SpatialDatasetService to MagicMock + mock_service.objects.get.return_value = mock.MagicMock() + # Mock up the SpatialDatasetServiceSetting to MagicMock + mock_setting.objects.get.return_value = mock.MagicMock() + mock_setting.objects.get().save.return_value = True + + # True will be returned, mocked save will be called + result = utilities.link_service_to_app_setting(service_type='spatial', service_uid='foo_spatial_service', + app_package='foo_app', setting_type='ds_spatial', + setting_uid='foo_456') + + self.assertEqual(True, result) + mock_service.objects.get.assert_called_once_with(name='foo_spatial_service') + mock_app.objects.get.assert_called_once_with(package='foo_app') + mock_setting.objects.get.assert_called() + mock_setting.objects.get().save.assert_called_once() + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('was successfully linked to', po_call_args[0][0][0]) + + @mock.patch('tethys_apps.cli.cli_colors.pretty_output') + @mock.patch('tethys_sdk.app_settings.SpatialDatasetServiceSetting') + @mock.patch('tethys_services.models.SpatialDatasetService') + @mock.patch('tethys_apps.models.TethysApp') + def test_link_service_to_app_setting_spatial_link_does_not_exist(self, mock_app, mock_service, mock_setting, + mock_pretty_output): + from django.core.exceptions import ObjectDoesNotExist + + # Mock up TethysApp to MagicMock + mock_app.objects.get.return_value = mock.MagicMock() + # Mock up the SpatialDatasetService to MagicMock + mock_service.objects.get.return_value = mock.MagicMock() + # Mock up the SpatialDatasetServiceSetting to MagicMock + mock_setting.objects.get.side_effect = ObjectDoesNotExist + + # Based on KeyError for invalid setting_type False will be returned + result = utilities.link_service_to_app_setting(service_type='spatial', service_uid='foo_spatial_service', + app_package='foo_app', setting_type='ds_spatial', + setting_uid='456') + + self.assertEqual(False, result) + mock_service.objects.get.assert_called_once_with(name='foo_spatial_service') + mock_app.objects.get.assert_called_once_with(package='foo_app') + mock_setting.objects.get.assert_called() + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('with ID/Name', po_call_args[0][0][0]) + self.assertIn('does not exist.', po_call_args[0][0][0]) diff --git a/tests/unit_tests/test_tethys_apps/test_views.py b/tests/unit_tests/test_tethys_apps/test_views.py new file mode 100644 index 000000000..becd8cf96 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_views.py @@ -0,0 +1,230 @@ +import unittest +import mock + +from tethys_apps.views import library, handoff_capabilities, handoff, send_beta_feedback_email, update_job_status + + +class TethysAppsViewsTest(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.views.render') + @mock.patch('tethys_apps.views.TethysApp') + def test_library(self, mock_tethys_app, mock_render): + mock_request = mock.MagicMock() + mock_request.user.is_staff = True + mock_app1 = mock.MagicMock() + mock_app1.configured = True + mock_app2 = mock.MagicMock() + mock_app2.configured = False + mock_tethys_app.objects.all.return_value = [mock_app1, mock_app2] + mock_render.return_value = True + + ret = library(mock_request) + self.assertEqual(ret, mock_render.return_value) + mock_tethys_app.objects.all.assert_called_once() + mock_render.assert_called_once_with(mock_request, 'tethys_apps/app_library.html', + {'apps': {'configured': [mock_app1], 'unconfigured': [mock_app2]}}) + + @mock.patch('tethys_apps.views.render') + @mock.patch('tethys_apps.views.TethysApp') + def test_library_no_staff(self, mock_tethys_app, mock_render): + mock_request = mock.MagicMock() + mock_request.user.is_staff = False + mock_app1 = mock.MagicMock() + mock_app1.configured = True + mock_app2 = mock.MagicMock() + mock_app2.configured = False + mock_tethys_app.objects.all.return_value = [mock_app1, mock_app2] + mock_render.return_value = True + + ret = library(mock_request) + self.assertEqual(ret, mock_render.return_value) + mock_tethys_app.objects.all.assert_called_once() + mock_render.assert_called_once_with(mock_request, 'tethys_apps/app_library.html', + {'apps': {'configured': [mock_app1], 'unconfigured': []}}) + + @mock.patch('tethys_apps.views.HttpResponse') + @mock.patch('tethys_apps.views.TethysAppBase') + def test_handoff_capabilities(self, mock_app_base, mock_http_response): + mock_request = mock.MagicMock() + mock_app_name = 'foo-app' + mock_manager = mock.MagicMock() + mock_handlers = mock.MagicMock() + mock_app_base.get_handoff_manager.return_value = mock_manager + mock_manager.get_capabilities.return_value = mock_handlers + mock_http_response.return_value = True + + ret = handoff_capabilities(mock_request, mock_app_name) + self.assertTrue(ret) + mock_app_base.get_handoff_manager.assert_called_once() + mock_manager.get_capabilities.assert_called_once_with('foo_app', external_only=True, jsonify=True) + mock_http_response.assert_called_once_with(mock_handlers, content_type='application/javascript') + + @mock.patch('tethys_apps.views.TethysAppBase') + def test_handoff(self, mock_app_base): + mock_request = mock.MagicMock() + mock_request_dict = mock.MagicMock() + mock_request.GET.dict.return_value = mock_request_dict + mock_app_name = 'foo-app' + mock_handler_name = 'foo_handler' + mock_manager = mock.MagicMock() + mock_app_base.get_handoff_manager.return_value = mock_manager + mock_manager.handoff.return_value = True + + ret = handoff(mock_request, mock_app_name, mock_handler_name) + self.assertTrue(ret) + mock_app_base.get_handoff_manager.assert_called_once() + mock_manager.handoff.assert_called_once_with(mock_request, mock_handler_name, 'foo_app') + mock_request.GET.dict.assert_called_once() + + @mock.patch('tethys_apps.views.JsonResponse') + @mock.patch('tethys_apps.views.get_active_app') + def test_send_beta_feedback_email_app_none(self, mock_get_active_app, mock_json_response): + mock_request = mock.MagicMock() + mock_post = mock.MagicMock() + mock_request.POST = mock_post + mock_post.get.return_value = 'http://foo/beta' + mock_get_active_app.return_value = None + mock_json_response.return_value = True + + ret = send_beta_feedback_email(mock_request) + self.assertTrue(ret) + mock_post.get.assert_called_once_with('betaFormUrl') + mock_get_active_app.assert_called_once_with(url='http://foo/beta') + mock_json_response.assert_called_once_with({'success': False, + 'error': 'App not found or feedback_emails not defined in app.py'}) + + @mock.patch('tethys_apps.views.send_mail') + @mock.patch('tethys_apps.views.JsonResponse') + @mock.patch('tethys_apps.views.get_active_app') + def test_send_beta_feedback_email_send_mail(self, mock_get_active_app, mock_json_response, mock_send_mail): + mock_request = mock.MagicMock() + mock_post = mock.MagicMock() + mock_app = mock.MagicMock() + mock_app.feedback_emails = 'foo@feedback.foo' + mock_app.name = 'foo_name' + mock_request.POST = mock_post + mock_post.get.side_effect = ['http://foo/beta', 'foo_betaUser', 'foo_betaSubmitLocalTime', + 'foo_betaSubmitUTCOffset', 'foo_betaFormUrl', 'foo_betaFormUserAgent', + 'foo_betaFormVendor', 'foo_betaUserComments'] + mock_get_active_app.return_value = mock_app + mock_json_response.return_value = True + mock_send_mail.return_value = True + + ret = send_beta_feedback_email(mock_request) + self.assertTrue(ret) + mock_post.get.assert_any_call('betaFormUrl') + mock_get_active_app.assert_called_once_with(url='http://foo/beta') + mock_post.get.assert_any_call('betaUser') + mock_post.get.assert_any_call('betaSubmitLocalTime') + mock_post.get.assert_any_call('betaSubmitUTCOffset') + mock_post.get.assert_any_call('betaFormUrl') + mock_post.get.assert_any_call('betaFormUserAgent') + mock_post.get.assert_any_call('betaFormVendor') + mock_post.get.assert_called_with('betaUserComments') + expected_subject = 'User Feedback for {0}'.format(mock_app.name.encode('utf-8')) + expected_message = 'User: {0}\n'\ + 'User Local Time: {1}\n'\ + 'UTC Offset in Hours: {2}\n'\ + 'App URL: {3}\n'\ + 'User Agent: {4}\n'\ + 'Vendor: {5}\n'\ + 'Comments:\n' \ + '{6}'.\ + format('foo_betaUser', + 'foo_betaSubmitLocalTime', + 'foo_betaSubmitUTCOffset', + 'foo_betaFormUrl', + 'foo_betaFormUserAgent', + 'foo_betaFormVendor', + 'foo_betaUserComments' + ) + mock_send_mail.assert_called_once_with(expected_subject, expected_message, from_email=None, + recipient_list=mock_app.feedback_emails) + mock_json_response.assert_called_once_with({'success': True, + 'result': 'Emails sent to specified developers'}) + + @mock.patch('tethys_apps.views.send_mail') + @mock.patch('tethys_apps.views.JsonResponse') + @mock.patch('tethys_apps.views.get_active_app') + def test_send_beta_feedback_email_send_mail_exception(self, mock_get_active_app, mock_json_response, + mock_send_mail): + mock_request = mock.MagicMock() + mock_post = mock.MagicMock() + mock_app = mock.MagicMock() + mock_app.feedback_emails = 'foo@feedback.foo' + mock_app.name = 'foo_name' + mock_request.POST = mock_post + mock_post.get.side_effect = ['http://foo/beta', 'foo_betaUser', 'foo_betaSubmitLocalTime', + 'foo_betaSubmitUTCOffset', 'foo_betaFormUrl', 'foo_betaFormUserAgent', + 'foo_betaFormVendor', 'foo_betaUserComments'] + mock_get_active_app.return_value = mock_app + mock_json_response.return_value = False + mock_send_mail.side_effect = Exception('foo_error') + + ret = send_beta_feedback_email(mock_request) + self.assertFalse(ret) + mock_post.get.assert_any_call('betaFormUrl') + mock_get_active_app.assert_called_once_with(url='http://foo/beta') + mock_post.get.assert_any_call('betaUser') + mock_post.get.assert_any_call('betaSubmitLocalTime') + mock_post.get.assert_any_call('betaSubmitUTCOffset') + mock_post.get.assert_any_call('betaFormUrl') + mock_post.get.assert_any_call('betaFormUserAgent') + mock_post.get.assert_any_call('betaFormVendor') + mock_post.get.assert_called_with('betaUserComments') + expected_subject = 'User Feedback for {0}'.format(mock_app.name.encode('utf-8')) + expected_message = 'User: {0}\n' \ + 'User Local Time: {1}\n' \ + 'UTC Offset in Hours: {2}\n' \ + 'App URL: {3}\n' \ + 'User Agent: {4}\n' \ + 'Vendor: {5}\n' \ + 'Comments:\n' \ + '{6}'. \ + format('foo_betaUser', + 'foo_betaSubmitLocalTime', + 'foo_betaSubmitUTCOffset', + 'foo_betaFormUrl', + 'foo_betaFormUserAgent', + 'foo_betaFormVendor', + 'foo_betaUserComments' + ) + mock_send_mail.assert_called_once_with(expected_subject, expected_message, from_email=None, + recipient_list=mock_app.feedback_emails) + mock_json_response.assert_called_once_with({'success': False, + 'error': 'Failed to send email: foo_error'}) + + @mock.patch('tethys_apps.views.JsonResponse') + @mock.patch('tethys_apps.views.TethysJob') + def test_update_job_status(self, mock_tethysjob, mock_json_response): + mock_request = mock.MagicMock() + mock_job_id = mock.MagicMock() + mock_job1 = mock.MagicMock() + mock_job1.status = True + mock_job2 = mock.MagicMock() + mock_tethysjob.objects.filter.return_value = [mock_job1, mock_job2] + mock_json_response.return_value = True + + ret = update_job_status(mock_request, mock_job_id) + self.assertTrue(ret) + mock_tethysjob.objects.filter.assert_called_once_with(id=mock_job_id) + mock_json_response.assert_called_once_with({'success': True}) + + @mock.patch('tethys_apps.views.JsonResponse') + @mock.patch('tethys_apps.views.TethysJob') + def test_update_job_statusException(self, mock_tethysjob, mock_json_response): + mock_request = mock.MagicMock() + mock_job_id = mock.MagicMock() + mock_tethysjob.objects.filter.side_effect = Exception + mock_json_response.return_value = False + + ret = update_job_status(mock_request, mock_job_id) + self.assertFalse(ret) + mock_tethysjob.objects.filter.assert_called_once_with(id=mock_job_id) + mock_json_response.assert_called_once_with({'success': False}) diff --git a/tests/unit_tests/test_tethys_compute/test_admin.py b/tests/unit_tests/test_tethys_compute/test_admin.py new file mode 100644 index 000000000..ace0ca0de --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_admin.py @@ -0,0 +1,48 @@ +import unittest +import mock + +from tethys_compute.admin import SchedulerAdmin, JobAdmin + + +class TestTethysComputeAdmin(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_scheduler_admin(self): + mock_admin = mock.MagicMock() + mock_admin2 = mock.MagicMock() + + sa = SchedulerAdmin(mock_admin, mock_admin2) + self.assertListEqual(['name', 'host', 'username', 'password', 'private_key_path', 'private_key_pass'], + sa.list_display) + + def test_job_admin(self): + mock_admin = mock.MagicMock() + mock_admin2 = mock.MagicMock() + + ja = JobAdmin(mock_admin, mock_admin2) + self.assertListEqual(['name', 'description', 'label', 'user', 'creation_time', 'execute_time', + 'completion_time', 'status'], ja.list_display) + self.assertEquals(('name',), ja.list_display_links) + + def test_job_admin_has_add_permission(self): + mock_admin = mock.MagicMock() + mock_admin2 = mock.MagicMock() + mock_request = mock.MagicMock() + + ja = JobAdmin(mock_admin, mock_admin2) + self.assertFalse(ja.has_add_permission(mock_request)) + + def test_admin_site_register(self): + from django.contrib import admin + from tethys_compute.models import Scheduler, TethysJob + registry = admin.site._registry + self.assertIn(Scheduler, registry) + self.assertIsInstance(registry[Scheduler], SchedulerAdmin) + + self.assertIn(TethysJob, registry) + self.assertIsInstance(registry[TethysJob], JobAdmin) diff --git a/tests/unit_tests/test_tethys_compute/test_apps.py b/tests/unit_tests/test_tethys_compute/test_apps.py new file mode 100644 index 000000000..bb898f6a5 --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_apps.py @@ -0,0 +1,22 @@ +import unittest + +from django.apps import apps +from tethys_compute.apps import TethysComputeConfig + + +class TethysComputeConfigAppsTest(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_TethysComputeConfig(self): + app_config = apps.get_app_config('tethys_compute') + name = app_config.name + verbose_name = app_config.verbose_name + + self.assertEqual('tethys_compute', name) + self.assertEqual('Tethys Compute', verbose_name) + self.assertTrue(isinstance(app_config, TethysComputeConfig)) diff --git a/tests/unit_tests/test_tethys_compute/test_job_manager.py b/tests/unit_tests/test_tethys_compute/test_job_manager.py index 7d3530d4a..8df2ac173 100644 --- a/tests/unit_tests/test_tethys_compute/test_job_manager.py +++ b/tests/unit_tests/test_tethys_compute/test_job_manager.py @@ -1,38 +1,437 @@ -from django.test import TestCase -from tethys_apps.base import TethysAppBase -from tethys_compute.job_manager import JobManager, TethysJob, CondorJob -from django.contrib.auth.models import User +import unittest +import mock -def echo(arg): - return arg +from tethys_compute.job_manager import JobManager, JobTemplate, BasicJobTemplate, CondorJobTemplate,\ + CondorJobDescription, CondorWorkflowTemplate, CondorWorkflowNodeBaseTemplate, CondorWorkflowJobTemplate +from tethys_compute.models import (TethysJob, + BasicJob, + CondorJob, + CondorWorkflow, + CondorWorkflowJobNode) -class TestApp(TethysAppBase): - def job_templates(self): - return [] -class TethysJobTestCase(TestCase): +class TestJobManager(unittest.TestCase): + def setUp(self): - self.app = TestApp() - self.job_manager = JobManager(self.app) - self.user = User.objects.create_user('user', 'user@example.com', 'pass') + pass + + def tearDown(self): + pass + + def test_JobManager_init(self): + mock_app = mock.MagicMock() + mock_app.package = 'test_label' + mock_app.get_app_workspace.return_value = 'test_app_workspace' + + mock_template1 = mock.MagicMock() + mock_template1.name = 'template_1' + mock_template2 = mock.MagicMock() + mock_template2.name = 'template_2' + + mock_app.job_templates.return_value = [mock_template1, mock_template2] + + # Execute + ret = JobManager(mock_app) + + # Check Result + self.assertEqual(mock_app, ret.app) + self.assertEqual('test_label', ret.label) + self.assertEqual('test_app_workspace', ret.app_workspace) + self.assertEqual(mock_template1, ret.job_templates['template_1']) + self.assertEqual(mock_template2, ret.job_templates['template_2']) + + @mock.patch('tethys_compute.job_manager.print') + @mock.patch('tethys_compute.job_manager.warnings') + @mock.patch('tethys_compute.job_manager.JobManager.old_create_job') + def test_JobManager_create_job_template(self, mock_ocj, mock_warn, mock_print): + mock_app = mock.MagicMock() + mock_app.package = 'test_label' + mock_app.get_app_workspace.return_value = 'test_app_workspace' + mock_user_workspace = mock.MagicMock() + + mock_app.get_user_workspace.return_value = mock_user_workspace + mock_app.get_user_workspace().path = 'test_user_workspace' + + mock_template1 = mock.MagicMock() + mock_template1.name = 'template_1' + mock_template2 = mock.MagicMock() + mock_template2.name = 'template_2' + + mock_app.job_templates.return_value = [mock_template1, mock_template2] + + mock_ocj.return_value = 'old_create_job_return_value' + # Execute + ret_jm = JobManager(mock_app) + ret_job = ret_jm.create_job(name='test_name', user='test_user', template_name='template_1') + + # Check result + self.assertEqual('old_create_job_return_value', ret_job) + + mock_ocj.assert_called_with('test_name', 'test_user', 'template_1') + + # Check if warning message is called + check_msg = 'The job template "{0}" was used in the "{1}" app. Using job templates is now deprecated. ' \ + 'See docs: <>.'.format('template_1', 'test_label') + rts_call_args = mock_warn.warn.call_args_list + self.assertEqual(check_msg, rts_call_args[0][0][0]) + mock_print.assert_called_with(check_msg) + + @mock.patch('tethys_compute.job_manager.CondorJob') + def test_JobManager_create_job_template_none(self, mock_cj): + mock_app = mock.MagicMock() + mock_app.package = 'test_label' + mock_app.get_app_workspace.return_value = 'test_app_workspace' + mock_user_workspace = mock.MagicMock() + + mock_app.get_user_workspace.return_value = mock_user_workspace + mock_app.get_user_workspace().path = 'test_user_workspace' + + with mock.patch.dict('tethys_compute.job_manager.JOB_TYPES', {'CONDOR': mock_cj}): + # Execute + ret_jm = JobManager(mock_app) + ret_jm.create_job(name='test_name', user='test_user', job_type='CONDOR') + mock_cj.assert_called_with(label='test_label', name='test_name', user='test_user', + workspace='test_user_workspace') + + def test_old_create_job(self): + mock_app = mock.MagicMock() + mock_app.package = 'test_label' + mock_app.get_app_workspace.return_value = 'test_app_workspace' + mock_user_workspace = mock.MagicMock() + + mock_app.get_user_workspace.return_value = mock_user_workspace + mock_app.get_user_workspace().path = 'test_user_workspace' + + mock_template1 = mock.MagicMock() + mock_template1.name = 'template_1' + mock_template2 = mock.MagicMock() + mock_template2.name = 'template_2' + + mock_app.job_templates.return_value = [mock_template1, mock_template2] + + # Execute + ret_jm = JobManager(mock_app) + ret_jm.old_create_job(name='test_name', user='test_user', template_name='template_1') + mock_template1.create_job.assert_called_with(app_workspace='test_app_workspace', label='test_label', + name='test_name', user='test_user', + user_workspace=mock_user_workspace, + workspace='test_user_workspace') + + def test_old_create_job_key_error(self): + mock_app = mock.MagicMock() + + mock_name = 'foo' + mock_user = 'foo_user' + mock_template_name = 'bar' + mock_app.package = 'test_app_name' + + mgr = JobManager(mock_app) + self.assertRaises(KeyError, mgr.old_create_job, name=mock_name, user=mock_user, + template_name=mock_template_name) + + @mock.patch('tethys_compute.job_manager.TethysJob') + def test_JobManager_list_job(self, mock_tethys_job): + mock_args = mock.MagicMock() + mock_jobs = mock.MagicMock() + mock_tethys_job.objects.filter().order_by().select_subclasses.return_value = mock_jobs + + mock_user = 'foo_user' + + mgr = JobManager(mock_args) + ret = mgr.list_jobs(user=mock_user) + + self.assertEquals(ret, mock_jobs) + mock_tethys_job.objects.filter().order_by().select_subclasses.assert_called_once() + + @mock.patch('tethys_compute.job_manager.TethysJob') + def test_JobManager_get_job(self, mock_tethys_job): + mock_args = mock.MagicMock() + mock_app_package = mock.MagicMock() + mock_args.package = mock_app_package + mock_jobs = mock.MagicMock() + mock_tethys_job.objects.get_subclass.return_value = mock_jobs + + mock_job_id = 'fooid' + mock_user = 'bar' + + mgr = JobManager(mock_args) + ret = mgr.get_job(job_id=mock_job_id, user=mock_user) + + self.assertEquals(ret, mock_jobs) + mock_tethys_job.objects.get_subclass.assert_called_once_with(id='fooid', label=mock_app_package, user='bar') + + @mock.patch('tethys_compute.job_manager.TethysJob') + def test_JobManager_get_job_dne(self, mock_tethys_job): + mock_args = mock.MagicMock() + mock_app_package = mock.MagicMock() + mock_args.package = mock_app_package + mock_tethys_job.DoesNotExist = TethysJob.DoesNotExist # Restore original exception + mock_tethys_job.objects.get_subclass.side_effect = TethysJob.DoesNotExist + + mock_job_id = 'fooid' + mock_user = 'bar' + + mgr = JobManager(mock_args) + ret = mgr.get_job(job_id=mock_job_id, user=mock_user) + + self.assertEquals(ret, None) + mock_tethys_job.objects.get_subclass.assert_called_once_with(id='fooid', label=mock_app_package, user='bar') + + def test_JobManager_get_job_status_callback_url(self): + mock_args = mock.MagicMock() + mock_request = mock.MagicMock() + mock_job_id = 'foo' + + mgr = JobManager(mock_args) + mgr.get_job_status_callback_url(mock_request, mock_job_id) + mock_request.build_absolute_uri.assert_called_once_with(u'/update-job-status/foo/') + + def test_JobManager_replace_workspaces(self): + mock_app_workspace = mock.MagicMock() + mock_user_workspace = mock.MagicMock() + mock_app_workspace.path = 'replace_app' + mock_user_workspace.path = 'replace_user' + + mock_parameters = {str: '/foo/app/$(APP_WORKSPACE)/foo', + dict: {'foo': '/foo/user/$(USER_WORKSPACE)/foo'}, + list: ['/foo/app/$(APP_WORKSPACE)/foo', '/foo/user/$(USER_WORKSPACE)/foo'], + tuple: ('/foo/app/$(APP_WORKSPACE)/foo', '/foo/user/$(USER_WORKSPACE)/foo'), + int: 1 + } + + expected = {str: '/foo/app/replace_app/foo', + dict: {'foo': '/foo/user/replace_user/foo'}, + list: ['/foo/app/replace_app/foo', '/foo/user/replace_user/foo'], + tuple: ('/foo/app/replace_app/foo', '/foo/user/replace_user/foo'), + int: 1 + } + + ret = JobManager._replace_workspaces(mock_parameters, mock_app_workspace, mock_user_workspace) + self.assertEquals(ret, expected) + + # JobTemplate + + def test_JobTemplate_init(self): + mock_name = mock.MagicMock() + mock_type = BasicJob + mock_parameters = {list: ['/foo/app/workspace', '/foo/user/workspace']} + + ret = JobTemplate(name=mock_name, type=mock_type, parameters=mock_parameters) + self.assertEquals(mock_name, ret.name) + self.assertEquals(BasicJob, ret.type) + self.assertEquals(mock_parameters, ret.parameters) + + @mock.patch('tethys_compute.job_manager.JobManager._replace_workspaces') + def test_JobTemplate_create_job(self, mock_replace_workspaces): + mock_name = mock.MagicMock() + mock_type = BasicJob + mock_app_workspace = '/foo/APP_WORKSPACE' + mock_user_workspace = '/foo/APP_WORKSPACE' + mock_parameters = {list: ['/foo/app/workspace', '/foo/user/workspace']} + + ret = JobTemplate(name=mock_name, type=mock_type, parameters=mock_parameters) + ret2 = ret.create_job(mock_app_workspace, mock_user_workspace) + mock_replace_workspaces.assert_called_once() + self.assertTrue(isinstance(ret2, TethysJob)) + + # BasicJobTemplate + + def test_BasicJobTemplate_init(self): + mock_name = mock.MagicMock() + mock_parameters = {list: ['/foo/app/workspace', '/foo/user/workspace']} + + ret = BasicJobTemplate(name=mock_name, parameters=mock_parameters) + self.assertEquals(mock_name, ret.name) + self.assertEquals(BasicJob, ret.type) + self.assertEquals(mock_parameters, ret.parameters) + + def test_BasicJobTemplate_process_parameters(self): + mock_name = mock.MagicMock() + mock_parameters = {list: ['/foo/app/workspace', '/foo/user/workspace']} + + ret = BasicJobTemplate(name=mock_name, parameters=mock_parameters) + ret.process_parameters() + self.assertEquals(mock_name, ret.name) + self.assertEquals(BasicJob, ret.type) + self.assertEquals(mock_parameters, ret.parameters) + + # CondorJobTemplate + + def test_CondorJobTemplate_init_job_description(self): + mock_scheduler = mock.MagicMock() + mock_jd = mock.MagicMock() + mock_jd.remote_input_files = 'test_input' + mock_jd.condorpy_template_name = 'test_template' + mock_jd.attributes = 'test_attributes' + mock_param = {'workspace': 'test_workspace'} + + ret = CondorJobTemplate(name='test_name', other_params=mock_param, job_description=mock_jd, + scheduler=mock_scheduler) + + # Check result + self.assertEqual(ret.type, CondorJob) + self.assertEqual('test_name', ret.name) + self.assertEqual('test_template', ret.parameters['condorpy_template_name']) + self.assertEqual('test_template', ret.parameters['condorpy_template_name']) + self.assertEqual(mock_param, ret.parameters['other_params']) + self.assertEqual(mock_scheduler, ret.parameters['scheduler']) + self.assertEqual('test_attributes', ret.parameters['attributes']) + + def test_CondorJobTemplate_process_parameters(self): + mock_name = mock.MagicMock() + mock_job_description = mock.MagicMock() + mock_scheduler = mock.MagicMock() + mock_parameters = {list: ['/foo/app/workspace', '/foo/user/workspace'], + 'scheduler': mock_scheduler, + 'remote_input_files': mock_job_description.remote_input_files, + 'attributes': mock_job_description.attributes} + + ret = CondorJobTemplate(name=mock_name, parameters=mock_parameters, job_description=mock_job_description, + scheduler=mock_scheduler) + + self.assertIsNone(ret.process_parameters()) + + @mock.patch('tethys_compute.job_manager.CondorJob') + def test_CondorJobDescription_init(self, mock_job): + ret = CondorJobDescription(condorpy_template_name='temp1', remote_input_files='rm_files', name='foo') + self.assertEqual('rm_files', ret.remote_input_files) + self.assertEqual('temp1', ret.condorpy_template_name) + self.assertEqual('foo', ret.attributes['name']) + + @mock.patch('tethys_compute.job_manager.JobManager._replace_workspaces') + def test_CondorJobDescription_process_attributes(self, mock_jm): + + ret_cd = CondorJobDescription(condorpy_template_name='temp1', remote_input_files='rm_files', name='foo') + + mock_app_workspace = mock.MagicMock(path='/foo/app/workspacee') + mock_user_workspace = mock.MagicMock(path='/foo/user/workspacee') + before_dict = self.__dict__ + + after_dict = before_dict + after_dict['foo'] = 'bar' + mock_jm.return_value = after_dict + ret_cd.process_attributes(app_workspace=mock_app_workspace, user_workspace=mock_user_workspace) + + self.assertEqual(after_dict, ret_cd.__dict__) + + # CondorWorkflowTemplate + + def test_CondorWorkflowTemplate_init(self): + input_name = 'foo' + input_parameters = {'param1': 'inputparam1'} + input_jobs = ['job1', 'job2', 'job3'] + input_max_jobs = 10 + input_config = 'test_path_config_file' + + ret = CondorWorkflowTemplate(name=input_name, parameters=input_parameters, jobs=input_jobs, + max_jobs=input_max_jobs, config=input_config, additional_param='param1') + + self.assertEquals(input_name, ret.name) + self.assertEquals(input_parameters, ret.parameters) + self.assertEquals(set(input_jobs), ret.node_templates) + self.assertEquals(input_max_jobs, ret.parameters['max_jobs']) + self.assertEquals(input_config, ret.parameters['config']) + self.assertEquals('param1', ret.parameters['additional_param']) + + @mock.patch('tethys_compute.job_manager.JobTemplate.create_job') + def test_CondorWorkflowTemplate_create_job(self, mock_save): + input_name = 'foo' + input_parameters = {'param1': 'inputparam1'} + mock_job1 = mock.MagicMock() + mock_node_parent = mock.MagicMock() + mock_node_parent.create_node.return_value = 'add_parent_code_line' + mock_job1.parameters = {'parents': [mock_node_parent]} + mock_node1 = mock.MagicMock() + mock_job1.create_node.return_value = mock_node1 + + input_jobs = [mock_job1] + input_max_jobs = 10 + input_config = 'test_path_config_file' + + ret = CondorWorkflowTemplate(name=input_name, parameters=input_parameters, jobs=input_jobs, + max_jobs=input_max_jobs, config=input_config, additional_param='param1') + + app_workspace = '/foo/APP_WORKSPACE' + user_workspace = '/foo/USER_WORKSPACE' + + # call Execute + ret.create_job(app_workspace=app_workspace, user_workspace=user_workspace) + + # Check called + mock_node1.add_parent.assert_called_with('add_parent_code_line') + + def test_CondorWorkflowNodeBaseTemplate_init(self): + mock_name = mock.MagicMock() + mock_type = CondorWorkflowJobNode + mock_parameters = {} + + ret = CondorWorkflowNodeBaseTemplate(name=mock_name, type=mock_type, parameters=mock_parameters) + self.assertEquals(mock_name, ret.name) + self.assertEquals(CondorWorkflowJobNode, ret.type) + self.assertEquals(mock_parameters, ret.parameters) + + @mock.patch('tethys_compute.job_manager.issubclass') + def test_CondorWorkflowNodeBaseTemplate_add_dependency(self, mock_issubclass): + mock_name = mock.MagicMock() + mock_type = CondorWorkflowJobNode + mock_parameters = {} + mock_dependency = mock.MagicMock() + dep_set = set() + dep_set.add(mock_dependency) + mock_issubclass.return_value = True + + ret = CondorWorkflowNodeBaseTemplate(name=mock_name, type=mock_type, parameters=mock_parameters) + ret.add_dependency(mock_dependency) + self.assertEquals(dep_set, ret.dependencies) - def test_create_job(self): - job = self.job_manager.create_empty_job('job', self.user, CondorJob) - self.assertIsInstance(job, CondorJob, 'Empty job is not an instance of CondorJob') - self.assertIsInstance(job, TethysJob, 'Empty job is not an instance of TethysJob') + @mock.patch('tethys_compute.job_manager.CondorWorkflowNode.save') + @mock.patch('tethys_compute.job_manager.JobManager._replace_workspaces') + def test_CondorWorkflowNodeBaseTemplate_create_node(self, mock_replace, mock_node_save): + mock_name = mock.MagicMock() + mock_type = CondorWorkflowJobNode + mock_job1 = mock.MagicMock() + mock_job_parent = mock.MagicMock() + mock_job1.parameters = {'parents': [mock_job_parent]} + mock_parameters = {'parents': [mock_job_parent]} + mock_app_workspace = '/foo/APP_WORKSPACE' + mock_user_workspace = '/foo/USER_WORKSPACE' + mock_workflow = mock.MagicMock() + mock_workflow.__class__ = CondorWorkflow + mock_node_save.return_value = True + mock_replace.return_value = {'parents': [mock_job_parent], + 'num_jobs': 1, + 'remote_input_files': []} - self.assertIsInstance(job.extended_properties, dict) + ret = CondorWorkflowNodeBaseTemplate(name=mock_name, type=mock_type, parameters=mock_parameters) + node = ret.create_node(mock_workflow, mock_app_workspace, mock_user_workspace) - job.extended_properties['property'] = 'value' + self.assertTrue(isinstance(node, CondorWorkflowJobNode)) + self.assertEquals(mock_parameters, ret.parameters) + self.assertEquals('JOB', node.type) + self.assertEquals('', node.workspace) + # CondorWorkflowJobTemplate - job.save() + def test_CondorWorkflowJobTemplate_init(self): + mock_jd = mock.MagicMock() + mock_jd.remote_input_files = 'test_input' + mock_jd.condorpy_template_name = 'test_template' + mock_jd.attributes = 'test_attributes' - self.assertDictEqual(job.extended_properties, {'property': 'value'}) + ret = CondorWorkflowJobTemplate(name='test_name', job_description=mock_jd, other_params='test_kwargs') + # Check result + self.assertEqual(ret.type, CondorWorkflowJobNode) + self.assertEqual('test_name', ret.name) + self.assertEqual('test_template', ret.parameters['condorpy_template_name']) + self.assertEqual('test_attributes', ret.parameters['attributes']) + self.assertEqual('test_kwargs', ret.parameters['other_params']) - job.process_results = echo + def test_CondorWorkflowJobTemplate_process_parameters(self): + mock_jd = mock.MagicMock() + mock_jd.remote_input_files = 'test_input' + mock_jd.condorpy_template_name = 'test_template' + mock_jd.attributes = 'test_attributes' - job.save() + ret = CondorWorkflowJobTemplate(name='test_name', job_description=mock_jd, other_params='test_kwargs') - self.assertEqual(job.process_results('test'), 'test') - self.assertTrue(hasattr(job.process_results, '__call__')) \ No newline at end of file + self.assertIsNone(ret.process_parameters()) diff --git a/tests/unit_tests/test_tethys_compute/test_models.py b/tests/unit_tests/test_tethys_compute/test_models.py deleted file mode 100644 index 43e770091..000000000 --- a/tests/unit_tests/test_tethys_compute/test_models.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.test import TestCase -from tethys_compute.models import TethysJob - -class TethysJobTestCase(TestCase): - def setUp(self): - job = TethysJob() - - def test_job(self): - pass \ No newline at end of file diff --git a/tests/unit_tests/test_tethys_compute/test_models/__init__.py b/tests/unit_tests/test_tethys_compute/test_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_BasicJob.py b/tests/unit_tests/test_tethys_compute/test_models/test_BasicJob.py new file mode 100644 index 000000000..be251c5e9 --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_models/test_BasicJob.py @@ -0,0 +1,42 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_compute.models import BasicJob +from django.contrib.auth.models import User + + +class CondorBaseTest(TethysTestCase): + def set_up(self): + self.user = User.objects.create_user('tethys_super', 'user@example.com', 'pass') + self.basic_job = BasicJob( + name='test_basicjob', + description='test_description', + user=self.user, + label='test_label' + ) + self.basic_job.save() + + def tear_down(self): + self.basic_job.delete() + + def test_execute(self): + ret = BasicJob.objects.get(name='test_basicjob')._execute() + self.assertIsNone(ret) + + def test__update_status(self): + ret = BasicJob.objects.get(name='test_basicjob')._update_status() + self.assertIsNone(ret) + + def test_process_results(self): + ret = BasicJob.objects.get(name='test_basicjob')._process_results() + self.assertIsNone(ret) + + def test_stop(self): + ret = BasicJob.objects.get(name='test_basicjob').stop() + self.assertIsNone(ret) + + def test_pause(self): + ret = BasicJob.objects.get(name='test_basicjob').pause() + self.assertIsNone(ret) + + def test_resume(self): + ret = BasicJob.objects.get(name='test_basicjob').resume() + self.assertIsNone(ret) diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorBase.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorBase.py new file mode 100644 index 000000000..d559093d5 --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorBase.py @@ -0,0 +1,173 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_compute.models import Scheduler, CondorBase +from django.contrib.auth.models import User +from datetime import datetime, timedelta +from django.utils import timezone +import mock + + +class CondorBaseTest(TethysTestCase): + def set_up(self): + self.user = User.objects.create_user('tethys_super', 'user@example.com', 'pass') + + self.scheduler = Scheduler( + name='test_scheduler', + host='localhost', + username='tethys_super', + password='pass', + private_key_path='test_path', + private_key_pass='test_pass' + ) + self.scheduler.save() + + self.condorbase = CondorBase( + name='test_condorbase', + description='test_description', + user=self.user, + label='test_label', + cluster_id='1', + remote_id='test_machine', + scheduler=self.scheduler + ) + self.condorbase.save() + + self.condorbase_exe = CondorBase( + name='test_condorbase_exe', + description='test_description', + user=self.user, + label='test_label', + execute_time=timezone.now(), + cluster_id='1', + remote_id='test_machine', + scheduler=self.scheduler + ) + self.condorbase_exe.save() + + def tear_down(self): + self.scheduler.delete() + self.condorbase.delete() + self.condorbase_exe.delete() + + @mock.patch('tethys_compute.models.CondorBase._condor_object') + def test_condor_object_pro(self, mock_co): + ret = CondorBase.objects.get(name='test_condorbase') + mock_co.return_value = ret + + ret.condor_object + + # Check result + self.assertEqual(mock_co, ret.condor_object) + self.assertEqual(1, ret.condor_object._cluster_id) + self.assertEqual('test_machine', ret.condor_object._remote_id) + mock_co.set_scheduler.assert_called_with('localhost', 'tethys_super', 'pass', 'test_path', 'test_pass') + + def test_condor_obj_abs(self): + ret = CondorBase.objects.get(name='test_condorbase')._condor_object() + + # Check result. + self.assertIsNone(ret) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_statuses_prop(self, mock_co): + mock_co.statuses = 'test_statuses' + + condor_obj = CondorBase.objects.get(name='test_condorbase') + + # to set updated inside if statement = False + d = datetime.now() - timedelta(days=1) + condor_obj._last_status_update = d + + # Execute + ret = condor_obj.statuses + + # Check result + self.assertEqual('test_statuses', ret) + + # to set updated inside if statement = True + d = datetime.now() + condor_obj._last_status_update = d + + mock_co.statuses = 'test_statuses2' + ret = condor_obj.statuses + + # Check result, should not set statuses from condor_object again. Same ret as previous. + self.assertEqual('test_statuses', ret) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_execute_abs(self, mock_co): + mock_co.submit.return_value = 111 + + # Execute + CondorBase.objects.get(name='test_condorbase')._execute() + + ret = CondorBase.objects.get(name='test_condorbase') + + # Check result + self.assertEqual(111, ret.cluster_id) + + def test_update_status_not_execute_time(self): + ret = CondorBase.objects.get(name='test_condorbase')._update_status() + + # Check result + self.assertEqual('PEN', ret) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_update_status(self, mock_co): + mock_co.status = 'Various' + mock_co.statuses = {'Unexpanded': '', 'Idle': '', 'Running': ''} + CondorBase.objects.get(name='test_condorbase_exe')._update_status() + + ret = CondorBase.objects.get(name='test_condorbase_exe')._status + + # Check result + self.assertEqual('VCP', ret) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_update_status_exception(self, mock_co): + mock_co.status = 'Various' + mock_co.statuses = {} + CondorBase.objects.get(name='test_condorbase_exe')._update_status() + + ret = CondorBase.objects.get(name='test_condorbase_exe')._status + + # Check result + self.assertEqual('ERR', ret) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_process_results(self, mock_co): + CondorBase.objects.get(name='test_condorbase_exe')._process_results() + + # Check result + mock_co.sync_remote_output.assert_called() + mock_co.close_remote.assert_called() + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_stop(self, mock_co): + CondorBase.objects.get(name='test_condorbase_exe').stop() + + # Check result + mock_co.remove.assert_called() + + def test_pause(self): + ret = CondorBase.objects.get(name='test_condorbase_exe').pause() + + # Check result + self.assertIsNone(ret) + + def test_resume(self): + ret = CondorBase.objects.get(name='test_condorbase_exe').resume() + + # Check result + self.assertIsNone(ret) + + @mock.patch('tethys_compute.models.CondorBase._condor_object') + def test_update_database_fields(self, mock_co): + mock_co._remote_id = 'test_update_remote_id' + ret = CondorBase.objects.get(name='test_condorbase_exe') + + # _condor_object is an abstract method returning a condorpyjob or condorpyworkflow. + # We'll test condor_object.remote_id in condorpyjob test + ret.update_database_fields() + + # Check result + self.assertEqual('test_update_remote_id', ret.remote_id) diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorJob.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorJob.py new file mode 100644 index 000000000..98d4477a0 --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorJob.py @@ -0,0 +1,110 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_compute.models import TethysJob, CondorJob, Scheduler, CondorBase, CondorPyJob +from django.contrib.auth.models import User +import mock +import os +import shutil +import os.path + + +class CondorJobTest(TethysTestCase): + def set_up(self): + self.user = User.objects.create_user('tethys_super', 'user@example.com', 'pass') + + self.scheduler = Scheduler( + name='test_scheduler', + host='localhost', + username='tethys_super', + password='pass', + ) + self.scheduler.save() + + path = os.path.dirname(__file__) + self.workspace_dir = os.path.join(path, 'workspace') + + self.condorjob = CondorJob( + name='test condorbase', + description='test_description', + user=self.user, + label='test_label', + cluster_id='1', + remote_id='test_machine', + workspace=self.workspace_dir, + scheduler=self.scheduler, + condorpyjob_id='99', + _attributes={'foo': 'bar'}, + _remote_input_files=['test_file1.txt', 'test_file2.txt'], + ) + self.condorjob.save() + + self.id_val = TethysJob.objects.get(name='test condorbase').id + + def tear_down(self): + self.scheduler.delete() + if self.condorjob.condorpyjob_ptr_id == 99: + self.condorjob.delete() + + if os.path.exists(self.workspace_dir): + shutil.rmtree(self.workspace_dir) + + def test_condor_object_prop(self): + condorpy_job = self.condorjob._condor_object + + # Check result + self.assertEqual('test_condorbase', condorpy_job.name) + self.assertEqual('test_condorbase', condorpy_job.attributes['job_name']) + self.assertEqual('bar', condorpy_job.attributes['foo']) + self.assertIn('test_file1.txt', condorpy_job.remote_input_files) + self.assertIn('test_file2.txt', condorpy_job.remote_input_files) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_execute(self, mock_cos): + # TODO: Check if we can mock this or we can provide an executable. + # Mock condor_object.submit() + mock_cos.submit.return_value = 111 + self.condorjob._execute(queue=2) + + # Check result + self.assertEqual(111, self.condorjob.cluster_id) + self.assertEqual(2, self.condorjob.num_jobs) + + @mock.patch('tethys_compute.models.CondorPyJob.update_database_fields') + @mock.patch('tethys_compute.models.CondorBase.update_database_fields') + def test_update_database_fields(self, mock_cb_update, mock_cj_update): + # Mock condor_object.submit() + self.condorjob.update_database_fields() + + # Check result + mock_cb_update.assert_called() + mock_cj_update.assert_called() + + def test_condor_job_pre_save(self): + # Check if CondorBase is updated + self.assertIsInstance(CondorBase.objects.get(tethysjob_ptr_id=self.id_val), CondorBase) + + # Check if CondorPyJob is updated + self.assertIsInstance(CondorPyJob.objects.get(condorpyjob_id=99), CondorPyJob) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_condor_job_pre_delete(self, mock_co): + if not os.path.exists(self.workspace_dir): + os.makedirs(self.workspace_dir) + file_path = os.path.join(self.workspace_dir, 'test_file.txt') + open(file_path, 'a').close() + + self.condorjob.delete() + + # Check if close_remote is called + mock_co.close_remote.assert_called() + + # Check if file has been removed + self.assertFalse(os.path.isfile(file_path)) + + @mock.patch('tethys_compute.models.log') + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_condor_job_pre_delete_exception(self, mock_co, mock_log): + mock_co.close_remote.side_effect = Exception('test error') + self.condorjob.delete() + + # Check if close_remote is called + mock_log.exception.assert_called_with('test error') diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyJob.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyJob.py new file mode 100644 index 000000000..ef2c6dff6 --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyJob.py @@ -0,0 +1,143 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_compute.models import Scheduler, CondorPyJob, CondorJob +from condorpy import Templates +from django.contrib.auth.models import User +from condorpy import Job +import mock + + +class CondorPyJobTest(TethysTestCase): + def set_up(self): + self.condor_py = CondorPyJob( + condorpyjob_id='99', + _attributes={'foo': 'bar'}, + _remote_input_files=['test_file1.txt', 'test_file2.txt'], + ) + + self.condor_py.save() + + user = User.objects.create_user('tethys_super', 'user@example.com', 'pass') + + scheduler = Scheduler( + name='test_scheduler', + host='localhost', + username='tethys_super', + password='pass', + ) + + self.condorjob = CondorJob( + name='test condorbase', + description='test_description', + user=user, + label='test_label', + workspace='test_workspace', + scheduler=scheduler, + condorpyjob_id='98', + ) + + def tear_down(self): + self.condor_py.delete() + + def test_init(self): + ret = CondorPyJob(_attributes={'foo': 'bar'}, + condorpy_template_name='vanilla_base', + ) + # Check result + # Instance of CondorPyJob + self.assertIsInstance(ret, CondorPyJob) + # Check return vanilla Django base + self.assertEqual('vanilla', ret.attributes['universe']) + + def test_get_condorpy_template(self): + ret = CondorPyJob.get_condorpy_template('vanilla_base') + + # Check result + self.assertEqual(ret, Templates.vanilla_base) + + def test_get_condorpy_template_default(self): + ret = CondorPyJob.get_condorpy_template(None) + + # Check result + self.assertEqual(ret, Templates.base) + + def test_get_condorpy_template_no_template(self): + ret = CondorPyJob.get_condorpy_template('test') + + # Check result + self.assertEqual(ret, Templates.base) + + def test_condorpy_job(self): + ret = self.condorjob.condorpy_job + + # Check result for Django Job + self.assertIsInstance(ret, Job) + self.assertEqual('test_condorbase', ret.name) + self.assertEqual('test_workspace', ret._cwd) + self.assertEqual('test_condorbase', ret.attributes['job_name']) + self.assertEqual(1, ret.num_jobs) + + def test_attributes(self): + ret = CondorPyJob.objects.get(condorpyjob_id='99').attributes + + self.assertEqual({'foo': 'bar'}, ret) + + @mock.patch('tethys_compute.models.CondorPyJob.condorpy_job') + def test_set_attributes(self, mock_ca): + set_attributes = {'baz': 'qux'} + ret = CondorPyJob.objects.get(condorpyjob_id='99') + + ret.attributes = set_attributes + + # Mock setter + mock_ca._attributes = set_attributes + + self.assertEqual({'baz': 'qux'}, ret.attributes) + + @mock.patch('tethys_compute.models.CondorPyJob.condorpy_job') + def test_numjobs(self, mock_cj): + num_job = 5 + ret = CondorPyJob.objects.get(condorpyjob_id='99') + ret.num_jobs = num_job + + # Mock setter + mock_cj.numb_jobs = num_job + + # self.assertEqual(5, ret) + self.assertEqual(num_job, ret.num_jobs) + + @mock.patch('tethys_compute.models.CondorPyJob.condorpy_job') + def test_remote_input_files(self, mock_cj): + ret = CondorPyJob.objects.get(condorpyjob_id='99') + ret.remote_input_files = ['test_newfile1.txt'] + + # Mock setter + mock_cj.remote_input_files = ['test_newfile1.txt'] + + # Check result + self.assertEqual(['test_newfile1.txt'], ret.remote_input_files) + + def test_initial_dir(self): + ret = self.condorjob.initial_dir + + # Check result + self.assertEqual('test_workspace/.', ret) + + def test_set_and_get_attribute(self): + self.condorjob.set_attribute('test', 'value') + + ret = self.condorjob.get_attribute('test') + + # Check Result + self.assertEqual('value', ret) + + def test_update_database_fields(self): + ret = self.condorjob + + # Before Update + self.assertFalse(ret.attributes) + + # Execute + ret.update_database_fields() + + # Check after update + self.assertEqual('test_condorbase', ret.attributes['job_name']) diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyWorkflow.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyWorkflow.py new file mode 100644 index 000000000..d8a9f3d2c --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyWorkflow.py @@ -0,0 +1,165 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_compute.models import CondorPyWorkflow, CondorWorkflow, Scheduler, CondorWorkflowJobNode +from django.contrib.auth.models import User +import mock +import os +import os.path + + +class CondorPyWorkflowTest(TethysTestCase): + def set_up(self): + path = os.path.dirname(__file__) + self.workspace_dir = os.path.join(path, 'workspace') + + self.user = User.objects.create_user('tethys_super', 'user@example.com', 'pass') + + self.scheduler = Scheduler( + name='test_scheduler', + host='localhost', + username='tethys_super', + password='pass', + private_key_path='test_path', + private_key_pass='test_pass' + ) + self.scheduler.save() + + self.condorworkflow = CondorWorkflow( + _max_jobs={'foo': 10}, + _config='test_config', + name='test name', + workspace=self.workspace_dir, + user=self.user, + scheduler=self.scheduler, + ) + self.condorworkflow.save() + + self.id_value = CondorWorkflow.objects.get(name='test name').condorpyworkflow_ptr_id + self.condorpyworkflow = CondorPyWorkflow.objects.get(condorpyworkflow_id=self.id_value) + + self.condorworkflowjobnode_a = CondorWorkflowJobNode( + name='Job1_a', + workflow=self.condorpyworkflow, + _attributes={'foo': 'one'}, + _num_jobs=1, + _remote_input_files=['test1.txt'], + ) + + self.condorworkflowjobnode_a.save() + + self.condorworkflowjobnode_a1 = CondorWorkflowJobNode( + name='Job1_a1', + workflow=self.condorpyworkflow, + _attributes={'foo': 'one'}, + _num_jobs=1, + _remote_input_files=['test1.txt'], + ) + + self.condorworkflowjobnode_a1.save() + + # Django model many to many relationship add method + # self.condorworkflowjobnode.parent_nodes.add(self.condorworkflowjobnode_job) + + def tear_down(self): + self.scheduler.delete() + self.condorworkflow.delete() + self.condorworkflowjobnode_a.delete() + self.condorworkflowjobnode_a1.delete() + + # pass + + def test_condorpy_workflow_prop(self): + ret = self.condorworkflow.condorpy_workflow + + # Check Result + self.assertEqual('', repr(ret)) + self.assertEqual(self.workspace_dir, ret._cwd) + self.assertEqual('test_config', ret.config) + + @mock.patch('tethys_compute.models.Workflow') + def test_max_jobs(self, mock_wf): + max_jobs = {'foo': 5} + self.condorpyworkflow.name = 'test_name' + self.condorpyworkflow.workspace = 'test_dict' + self.condorpyworkflow.max_jobs = max_jobs + + ret = self.condorpyworkflow.max_jobs + + # Check result + self.assertEqual(5, ret['foo']) + mock_wf.assert_called_with(config='test_config', max_jobs={'foo': 10}, + name='test_name', working_directory='test_dict') + + @mock.patch('tethys_compute.models.CondorPyWorkflow.condorpy_workflow') + def test_config(self, mock_cw): + test_config_value = 'test_config2' + + # Mock condorpy_workflow.config = test_config_value. We have already tested condorpy_workflow. + mock_cw.config = test_config_value + + # Setter + self.condorpyworkflow.config = test_config_value + + # Property + ret = self.condorpyworkflow.config + + # Check result + self.assertEqual('test_config2', ret) + + def test_nodes(self): + ret = self.condorworkflow.nodes + # Check result after loading nodes + # self.assertEqual('Node_1', ret[0].name) + + # Check result in CondorPyWorkflow object + self.assertEqual({'foo': 10}, ret[0].workflow.max_jobs) + self.assertEqual('test_config', ret[0].workflow.config) + + def test_load_nodes(self): + # Before load nodes. Set should be empty + ret_before = self.condorworkflow.condorpy_workflow.node_set + list_before = [] + + list_after = [] + for e in ret_before: + list_before.append(e) + + # Check list_before is empty + self.assertFalse(list_before) + + # Add parent + self.condorworkflowjobnode_a1.add_parent(self.condorworkflowjobnode_a) + + # Execute load nodes + self.condorworkflow.load_nodes() + + # After load nodes, Set should have two elements. One parent and one child + ret_after = self.condorworkflow.condorpy_workflow.node_set + # Convert to list for checking result + for e in ret_after: + list_after.append(e) + + # Check list_after is not empty + self.assertTrue(list_after) + + # sort list and compare result + list_after.sort(key=lambda node: node.job.name) + self.assertEqual('Job1_a', list_after[0].job.name) + self.assertEqual('Job1_a1', list_after[1].job.name) + + def test_add_max_jobs_throttle(self): + # Set max_jobs + self.condorworkflow.add_max_jobs_throttle('foo1', 20) + + # Get return value + ret = self.condorworkflow.condorpy_workflow + + # Check result + self.assertEqual(20, ret.max_jobs['foo1']) + + @mock.patch('tethys_compute.models.CondorWorkflowJobNode.update_database_fields') + def test_update_database_fields(self, mock_update): + # Set attribute for node + self.condorpyworkflow.update_database_fields() + + # Check if mock is called twice for node and child node + self.assertTrue(mock_update.call_count == 2) diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflow.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflow.py new file mode 100644 index 000000000..781a8b95d --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflow.py @@ -0,0 +1,150 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_compute.models import CondorPyWorkflow, CondorWorkflow, Scheduler, CondorWorkflowJobNode +from django.contrib.auth.models import User +import mock +import os +import shutil +import os.path + + +class CondorWorkflowTest(TethysTestCase): + def set_up(self): + path = os.path.dirname(__file__) + self.workspace_dir = os.path.join(path, 'workspace') + self.user = User.objects.create_user('tethys_super', 'user@example.com', 'pass') + + self.scheduler = Scheduler( + name='test_scheduler', + host='localhost', + username='tethys_super', + password='pass', + private_key_path='test_path', + private_key_pass='test_pass' + ) + self.scheduler.save() + + self.condorworkflow = CondorWorkflow( + _max_jobs={'foo': 10}, + _config='test_config', + name='test name', + workspace=self.workspace_dir, + user=self.user, + scheduler=self.scheduler, + ) + self.condorworkflow.save() + + self.id_value = CondorWorkflow.objects.get(name='test name').condorpyworkflow_ptr_id + self.condorpyworkflow = CondorPyWorkflow.objects.get(condorpyworkflow_id=self.id_value) + + self.condorworkflowjobnode_child = CondorWorkflowJobNode( + name='Node_child', + workflow=self.condorpyworkflow, + _attributes={'test': 'one'}, + _num_jobs=1, + _remote_input_files=['test1.txt'], + ) + self.condorworkflowjobnode_child.save() + + # self.child_id = CondorWorkflowJobNode.objects.get(name='Node_child').id + + self.condorworkflowjobnode = CondorWorkflowJobNode( + name='Node_1', + workflow=self.condorpyworkflow, + _attributes={'test': 'one'}, + _num_jobs=1, + _remote_input_files=['test1.txt'], + ) + self.condorworkflowjobnode.save() + + # Django model many to many relationship add method + self.condorworkflowjobnode.parent_nodes.add(self.condorworkflowjobnode_child) + + self.condorbase_id = CondorWorkflow.objects.get(name='test name').condorbase_ptr_id + self.condorpyworkflow_id = CondorWorkflow.objects.get(name='test name').condorpyworkflow_ptr_id + + def tear_down(self): + self.scheduler.delete() + + if self.condorworkflow.condorbase_ptr_id == self.condorbase_id: + self.condorworkflow.delete() + + if os.path.exists(self.workspace_dir): + shutil.rmtree(self.workspace_dir) + + def test_condor_object_prop(self): + ret = self.condorworkflow._condor_object + + # Check workflow return + self.assertEqual({'foo': 10}, ret.max_jobs) + self.assertEqual('test_config', ret.config) + self.assertEqual('', repr(ret)) + + @mock.patch('tethys_compute.models.CondorPyWorkflow.load_nodes') + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_execute(self, mock_co, mock_ln): + # Mock submit to return a 111 cluster id + mock_co.submit.return_value = 111 + + # Execute + self.condorworkflow._execute(options=['foo']) + + # We already tested load_nodes in CondorPyWorkflow, just mocked to make sure it's called here. + mock_ln.assert_called() + mock_co.submit.assert_called_with(options=['foo']) + + # Check cluster_id from _execute in condorbase + self.assertEqual(111, self.condorworkflow.cluster_id) + + def test_get_job(self): + ret = self.condorworkflow.get_job(job_name='Node_1') + + # Check result + self.assertIsInstance(ret, CondorWorkflowJobNode) + self.assertEqual('Node_1', ret.name) + + def test_get_job_does_not_exist(self): + ret = self.condorworkflow.get_job(job_name='Node_2') + # Check result + self.assertIsNone(ret) + + @mock.patch('tethys_compute.models.CondorBase.update_database_fields') + @mock.patch('tethys_compute.models.CondorPyWorkflow.update_database_fields') + def test_update_database_fieds(self, mock_pw_update, mock_ba_update): + # Execute + self.condorworkflow.update_database_fields() + + # Check if mock is called + mock_pw_update.assert_called() + mock_ba_update.assert_called() + + @mock.patch('tethys_compute.models.CondorWorkflow.update_database_fields') + def test_condor_workflow_presave(self, mock_update): + # Excute + self.condorworkflow.save() + + # Check if update_database_fields is called + mock_update.assert_called() + + @mock.patch('tethys_compute.models.CondorWorkflow.condor_object') + def test_condor_job_pre_delete(self, mock_co): + if not os.path.exists(self.workspace_dir): + os.makedirs(self.workspace_dir) + file_path = os.path.join(self.workspace_dir, 'test_file.txt') + open(file_path, 'a').close() + + self.condorworkflow.delete() + + # Check if close_remote is called + mock_co.close_remote.assert_called() + + # Check if file has been removed + self.assertFalse(os.path.isfile(file_path)) + + @mock.patch('tethys_compute.models.log') + @mock.patch('tethys_compute.models.CondorWorkflow.condor_object') + def test_condor_job_pre_delete_exception(self, mock_co, mock_log): + mock_co.close_remote.side_effect = Exception('test error') + self.condorworkflow.delete() + + # Check if close_remote is called + mock_log.exception.assert_called_with('test error') diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflowJobNode.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflowJobNode.py new file mode 100644 index 000000000..3679d5572 --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflowJobNode.py @@ -0,0 +1,88 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_compute.models import CondorPyWorkflow, CondorWorkflow, CondorWorkflowJobNode, TethysJob +from django.contrib.auth.models import User +import mock +import os +import os.path + + +class CondorPyWorkflowJobNodeTest(TethysTestCase): + def set_up(self): + path = os.path.dirname(__file__) + self.workspace_dir = os.path.join(path, 'workspace') + + self.user = User.objects.create_user('tethys_super', 'user@example.com', 'pass') + + self.condorworkflow = CondorWorkflow( + _max_jobs={'foo': 10}, + _config='test_config', + name='foo{id}', + workspace=self.workspace_dir, + user=self.user, + ) + self.condorworkflow.save() + + # To have a flow Node, we need to have a Condor Job which requires a CondorBase which requires a TethysJob + self.id_value = CondorWorkflow.objects.get(name='foo{id}').condorpyworkflow_ptr_id + self.condorpyworkflow = CondorPyWorkflow.objects.get(condorpyworkflow_id=self.id_value) + + self.condorworkflowjobnode = CondorWorkflowJobNode( + name='Job1_NodeA', + workflow=self.condorpyworkflow, + _attributes={'test': 'one'}, + _num_jobs=1, + _remote_input_files=['test1.txt'], + ) + self.condorworkflowjobnode.save() + + def tear_down(self): + self.condorworkflow.delete() + self.condorworkflowjobnode.delete() + + def test_type_prop(self): + self.assertEqual('JOB', self.condorworkflowjobnode.type) + + def test_workspace_prop(self): + self.assertEqual('', self.condorworkflowjobnode.workspace) + + @mock.patch('tethys_compute.models.CondorPyJob.condorpy_job') + def test_job_prop(self, mock_cpj): + # Condorpy_job Prop is already tested in CondorPyJob Test case + self.assertEqual(mock_cpj, self.condorworkflowjobnode.job) + + @mock.patch('tethys_compute.models.CondorWorkflowNode.update_database_fields') + @mock.patch('tethys_compute.models.CondorPyJob.update_database_fields') + def test_update_database_fields(self, mock_pj_update, mock_wfn_update): + # Execute + self.condorworkflowjobnode.update_database_fields() + + # Check result + mock_pj_update.assert_called_once() + mock_wfn_update.assert_called_once() + + @mock.patch('tethys_compute.models.CondorWorkflowNode.update_database_fields') + @mock.patch('tethys_compute.models.CondorPyJob.update_database_fields') + def test_receiver_pre_save(self, mock_pj_update, mock_wfn_update): + self.condorworkflowjobnode.save() + + # Check result + mock_pj_update.assert_called_once() + mock_wfn_update.assert_called_once() + + def test_job_post_save(self): + # get the job + tethys_job = TethysJob.objects.get(name='foo{id}') + id_val = tethys_job.id + + # Run save to activate post save + tethys_job.save() + + # Set up new name + new_name = 'foo{id}'.format(id=id_val) + + # Get same tethys job with new name + tethys_job = TethysJob.objects.get(name=new_name) + + # Check results + self.assertIsInstance(tethys_job, TethysJob) + self.assertEqual(new_name, tethys_job.name) diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_Scheduler.py b/tests/unit_tests/test_tethys_compute/test_models/test_Scheduler.py new file mode 100644 index 000000000..e5be2aab5 --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_models/test_Scheduler.py @@ -0,0 +1,30 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_compute.models import Scheduler + + +class SchedulerTest(TethysTestCase): + def set_up(self): + self.scheduler = Scheduler( + name='test_scheduler', + host='localhost', + username='tethys_super', + password='pass', + private_key_path='test_path', + private_key_pass='test_pass' + ) + self.scheduler.save() + + def tear_down(self): + self.scheduler.delete() + + def test_Scheduler(self): + ret = Scheduler.objects.get(name='test_scheduler') + + # Check result + self.assertIsInstance(ret, Scheduler) + self.assertEqual('test_scheduler', ret.name) + self.assertEqual('localhost', ret.host) + self.assertEqual('tethys_super', ret.username) + self.assertEqual('pass', ret.password) + self.assertEqual('test_path', ret.private_key_path) + self.assertEqual('test_pass', ret.private_key_pass) diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_TethysJob.py b/tests/unit_tests/test_tethys_compute/test_models/test_TethysJob.py new file mode 100644 index 000000000..2876e33ce --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_models/test_TethysJob.py @@ -0,0 +1,243 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_compute.models import TethysJob, CondorBase, Scheduler +from django.contrib.auth.models import User +from datetime import datetime, timedelta +from pytz import timezone +from django.utils import timezone as dt +import mock + + +def test_function(): + pass + + +class TethysJobTest(TethysTestCase): + def set_up(self): + self.tz = timezone('America/Denver') + + self.user = User.objects.create_user('tethys_super', 'user@example.com', 'pass') + + self.scheduler = Scheduler( + name='test_scheduler', + host='localhost', + username='tethys_super', + password='pass', + private_key_path='test_path', + private_key_pass='test_pass' + ) + + self.scheduler.save() + + self.tethysjob = TethysJob( + name='test_tethysjob', + description='test_description', + user=self.user, + label='test_label', + ) + self.tethysjob.save() + + self.tethysjob_execute_time = TethysJob( + name='test_tethysjob_execute_time', + description='test_description', + user=self.user, + label='test_label', + execute_time=datetime(year=2018, month=1, day=1, tzinfo=self.tz), + completion_time=datetime(year=2018, month=1, day=1, hour=1, tzinfo=self.tz), + _status='VAR', + _process_results_function=test_function + + ) + self.tethysjob_execute_time.save() + + def tear_down(self): + self.tethysjob.delete() + self.tethysjob_execute_time.delete() + self.scheduler.delete() + + def test_update_status_interval_prop(self): + ret = TethysJob.objects.get(name='test_tethysjob').update_status_interval + + # Check result + self.assertIsInstance(ret, timedelta) + self.assertEqual(timedelta(0, 10), ret) + + def test_last_status_update_prop(self): + ret = TethysJob.objects.get(name='test_tethysjob') + check_date = datetime(year=2018, month=1, day=1, tzinfo=self.tz) + ret._last_status_update = check_date + + # Check result + self.assertEqual(check_date, ret.last_status_update) + + def test_status_prop(self): + ret = TethysJob.objects.get(name='test_tethysjob').status + + # Check result + self.assertEqual('Pending', ret) + + def test_run_time_execute_time_prop(self): + ret = TethysJob.objects.get(name='test_tethysjob_execute_time').run_time + + # Check result + self.assertIsInstance(ret, timedelta) + self.assertEqual(timedelta(0, 3600), ret) + + # TODO: How to get to inside the if self.completion_time and self.execute_time: statement + + def test_execute(self): + ret_old = TethysJob.objects.get(name='test_tethysjob_execute_time') + TethysJob.objects.get(name='test_tethysjob_execute_time').execute() + ret_new = TethysJob.objects.get(name='test_tethysjob_execute_time') + + self.assertNotEqual(ret_old.execute_time, ret_new.execute_time) + self.assertEqual('Various', ret_old.status) + self.assertEqual('Pending', ret_new.status) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_update_status_run(self, mock_co): + tethysjob = CondorBase( + name='test_tethysjob', + description='test_description', + user=self.user, + label='test_label', + scheduler=self.scheduler, + execute_time=dt.now(), + ) + + mock_co.status = 'Running' + tethysjob.update_status() + + # Check result + self.assertIsNotNone(tethysjob.last_status_update) + self.assertIsInstance(tethysjob.last_status_update, datetime) + self.assertIsNotNone(tethysjob.start_time) + self.assertIsInstance(tethysjob.start_time, datetime) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_update_status_com(self, mock_co): + tethysjob = CondorBase( + name='test_tethysjob', + description='test_description', + user=self.user, + label='test_label', + scheduler=self.scheduler, + execute_time=dt.now(), + ) + + mock_co.status = 'Completed' + tethysjob.update_status() + + # Check result + self.assertIsNotNone(tethysjob.last_status_update) + self.assertIsInstance(tethysjob.last_status_update, datetime) + self.assertIsNotNone(tethysjob.completion_time) + self.assertIsInstance(tethysjob.completion_time, datetime) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_update_status_vcp(self, mock_co): + tethysjob = CondorBase( + name='test_tethysjob', + description='test_description', + user=self.user, + label='test_label', + scheduler=self.scheduler, + execute_time=dt.now(), + ) + + mock_co.status = 'Various-Complete' + tethysjob.update_status() + + # Check result + self.assertIsNotNone(tethysjob.last_status_update) + self.assertIsInstance(tethysjob.last_status_update, datetime) + self.assertIsNotNone(tethysjob.completion_time) + self.assertIsInstance(tethysjob.completion_time, datetime) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_update_status_err(self, mock_co): + tethysjob = CondorBase( + name='test_tethysjob', + description='test_description', + user=self.user, + label='test_label', + scheduler=self.scheduler, + execute_time=dt.now(), + ) + + mock_co.status = 'Held' + tethysjob.update_status() + + # Check result + self.assertIsNotNone(tethysjob.last_status_update) + self.assertIsInstance(tethysjob.last_status_update, datetime) + self.assertIsNotNone(tethysjob.completion_time) + self.assertIsInstance(tethysjob.completion_time, datetime) + + @mock.patch('tethys_compute.models.CondorBase.condor_object') + def test_update_status_abt(self, mock_co): + tethysjob = CondorBase( + name='test_tethysjob', + description='test_description', + user=self.user, + label='test_label', + scheduler=self.scheduler, + execute_time=dt.now(), + ) + + mock_co.status = 'Removed' + tethysjob.update_status() + + # Check result + self.assertIsNotNone(tethysjob.last_status_update) + self.assertIsInstance(tethysjob.last_status_update, datetime) + self.assertIsNotNone(tethysjob.completion_time) + self.assertIsInstance(tethysjob.completion_time, datetime) + + @mock.patch('tethys_compute.models.TethysFunctionExtractor') + def test_process_results_function(self, mock_tfe): + mock_tfe().valid = True + mock_tfe().function = 'test_function_return' + + # Setter + TethysJob.objects.get(name='test_tethysjob_execute_time').process_results_function = test_function + + # Property + ret = TethysJob.objects.get(name='test_tethysjob_execute_time').process_results_function + + # Check result + self.assertEqual(ret, 'test_function_return') + mock_tfe.assert_called_with(str(test_function), None) + + def test_process_results(self): + ret = TethysJob.objects.get(name='test_tethysjob') + + ret.process_results('test', name='test_name') + + # Check result + self.assertIsInstance(ret.completion_time, datetime) + self.assertIsNotNone(ret.completion_time) + + def test_abs_method(self): + # Execute + ret = TethysJob.objects.get(name='test_tethysjob')._execute() + + # Check result + self.assertIsNone(ret) + + # Update Status + ret = TethysJob.objects.get(name='test_tethysjob')._update_status() + + # Check result + self.assertIsNone(ret) + + # Execute + ret = TethysJob.objects.get(name='test_tethysjob')._process_results() + + # Check result + self.assertIsNone(ret) + + self.assertRaises(NotImplementedError, TethysJob.objects.get(name='test_tethysjob').stop) + + self.assertRaises(NotImplementedError, TethysJob.objects.get(name='test_tethysjob').pause) + + self.assertRaises(NotImplementedError, TethysJob.objects.get(name='test_tethysjob').resume) diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_WorkflowNode.py b/tests/unit_tests/test_tethys_compute/test_models/test_WorkflowNode.py new file mode 100644 index 000000000..64b1368b2 --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_models/test_WorkflowNode.py @@ -0,0 +1,114 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_compute.models import CondorPyWorkflow, CondorWorkflow, Scheduler, CondorWorkflowNode, \ + CondorWorkflowJobNode +from django.contrib.auth.models import User +from condorpy import Job +import mock +import os +import os.path + + +class CondorPyWorkflowNodeTest(TethysTestCase): + def set_up(self): + path = os.path.dirname(__file__) + self.workspace_dir = os.path.join(path, 'workspace') + + self.user = User.objects.create_user('tethys_super', 'user@example.com', 'pass') + + self.scheduler = Scheduler( + name='test_scheduler', + host='localhost', + username='tethys_super', + password='pass', + private_key_path='test_path', + private_key_pass='test_pass' + ) + self.scheduler.save() + + self.condorworkflow = CondorWorkflow( + _max_jobs={'foo': 10}, + _config='test_config', + name='test name', + workspace=self.workspace_dir, + user=self.user, + scheduler=self.scheduler, + ) + self.condorworkflow.save() + + self.id_value = CondorWorkflow.objects.get(name='test name').condorpyworkflow_ptr_id + self.condorpyworkflow = CondorPyWorkflow.objects.get(condorpyworkflow_id=self.id_value) + + # One node can have many children nodes + self.condorworkflowjobnode_child = CondorWorkflowJobNode( + name='Job1', + workflow=self.condorpyworkflow, + _attributes={'test': 'one'}, + _num_jobs=1, + _remote_input_files=['test1.txt'], + ) + self.condorworkflowjobnode_child.save() + + # One node can have many children nodes + self.condorworkflowjobnode_child2 = CondorWorkflowJobNode( + name='Job2', + workflow=self.condorpyworkflow, + _attributes={'test': 'one'}, + _num_jobs=1, + _remote_input_files=['test1.txt'], + ) + self.condorworkflowjobnode_child2.save() + + self.condorworkflownode = CondorWorkflowNode( + name='test_condorworkflownode', + workflow=self.condorpyworkflow, + ) + self.condorworkflownode.save() + + def tear_down(self): + self.condorworkflow.delete() + self.condorworkflowjobnode_child.delete() + self.condorworkflowjobnode_child2.delete() + + def test_type_abs_prop(self): + ret = self.condorworkflownode.type() + + # Check result + self.assertIsNone(ret) + + def test_job_abs_prop(self): + ret = self.condorworkflownode.job() + + # Check result + self.assertIsNone(ret) + + @mock.patch('tethys_compute.models.CondorWorkflowNode.job') + def test_condorpy_node(self, mock_job): + mock_job_return = Job(name='test_job', + attributes={'foo': 'bar'}, + num_jobs=1, + remote_input_files=['test_file.txt'], + working_directory=self.workspace_dir) + mock_job.return_value = mock_job_return + + self.condorworkflownode.job = mock_job_return + ret = self.condorworkflownode.condorpy_node + + # Check result + self.assertEqual('', repr(ret)) + + def test_add_parents_and_parents_prop(self): + # Add parent should add parent to condorwoflownode + self.condorworkflownode.add_parent(self.condorworkflowjobnode_child) + self.condorworkflownode.add_parent(self.condorworkflowjobnode_child2) + + # Get this Parent Nodes here + ret = self.condorworkflownode.parents + + # Check result + self.assertIsInstance(ret[0], CondorWorkflowJobNode) + self.assertEqual('Job1', ret[0].name) + self.assertIsInstance(ret[1], CondorWorkflowJobNode) + self.assertEqual('Job2', ret[1].name) + + def test_update_database_fields(self): + self.assertIsNone(self.condorworkflownode.update_database_fields()) diff --git a/tests/unit_tests/test_tethys_compute/test_scheduler_manager.py b/tests/unit_tests/test_tethys_compute/test_scheduler_manager.py index e69de29bb..154ae8065 100644 --- a/tests/unit_tests/test_tethys_compute/test_scheduler_manager.py +++ b/tests/unit_tests/test_tethys_compute/test_scheduler_manager.py @@ -0,0 +1,54 @@ +import unittest +import mock + +from tethys_compute.scheduler_manager import list_schedulers, get_scheduler, create_scheduler + + +class SchedulerManagerTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_compute.scheduler_manager.Scheduler') + def test_list_schedulers(self, mock_scheduler): + mock_scheduler.objects.all.return_value = ['foo'] + ret = list_schedulers() + self.assertListEqual(['foo'], ret) + mock_scheduler.objects.all.assert_called() + + @mock.patch('tethys_compute.scheduler_manager.Scheduler') + def test_get_scheduler(self, mock_scheduler): + mock_filter_sche = mock.MagicMock() + mock_filter_foo = mock.MagicMock() + mock_filter_bar = mock.MagicMock() + + def my_filter(name): + if name == 'foo': + return [mock_filter_foo] + elif name == 'bar': + return [mock_filter_bar] + else: + return [mock_filter_sche] + + mock_scheduler.objects.filter.side_effect = my_filter + + self.assertEquals(mock_filter_foo, get_scheduler('foo')) + self.assertEquals(mock_filter_bar, get_scheduler('bar')) + self.assertEquals(mock_filter_sche, get_scheduler('asdf')) + mock_scheduler.objects.filter.assert_any_call(name='foo') + mock_scheduler.objects.filter.assert_any_call(name='bar') + mock_scheduler.objects.filter.assert_any_call(name='asdf') + + @mock.patch('tethys_compute.scheduler_manager.Scheduler') + def test_create_scheduler(self, mock_scheduler): + name = 'foo' + host = 'localhost' + mock_sch = mock.MagicMock() + + mock_scheduler.return_value = mock_sch + + self.assertEquals(mock_sch, create_scheduler(name, host)) + mock_scheduler.assert_called_once_with('foo', 'localhost', None, None, None, None) diff --git a/tests/unit_tests/test_tethys_compute/test_utilities.py b/tests/unit_tests/test_tethys_compute/test_utilities.py new file mode 100644 index 000000000..a81fcc40c --- /dev/null +++ b/tests/unit_tests/test_tethys_compute/test_utilities.py @@ -0,0 +1,251 @@ +from django.test import TestCase +from captcha.models import CaptchaStore +from django.contrib.auth.models import User +import mock +from tethys_compute.utilities import ListField, DictionaryField, Creator + + +def test_fun(): + return 'test' + + +class TestObject(object): + test_name = 'test' + + +class TethysComputeUtilitiesTests(TestCase): + + def setUp(self): + CaptchaStore.generate_key() + self.hashkey = CaptchaStore.objects.all()[0].hashkey + self.response = CaptchaStore.objects.all()[0].response + self.user = User.objects.create_user(username='user_exist', + email='foo_exist@aquaveo.com', + password='glass_onion') + + def tearDown(self): + pass + + def test_Creator_init(self): + mock_obj = mock.MagicMock() + ret = Creator(mock_obj) + self.assertEqual(mock_obj, ret.field) + + def test_Creator_get_set(self): + mock_field = mock.MagicMock() + mock_field.name = 'test_name' + + mock_field.to_python.return_value = 'test_name_value' + + # Set Object Value + TestObject.test_name = 'test2' + + ret = Creator(mock_field) + + ret_value = ret.__get__(TestObject) + + self.assertEqual('test2', ret_value) + + # Object is None + ret_value = ret.__get__(None) + + # Check result + self.assertEqual(ret_value, ret) + + # ListField + + def test_ListField(self): + ret = ListField() + self.assertEqual('List object', ret.description) + + def test_list_field_get_internal_type(self): + ret = ListField() + self.assertEqual('TextField', ret.get_internal_type()) + + def test_list_field_to_python_none(self): + ret = ListField() + self.assertIsNone(ret.to_python(value=None)) + + def test_list_field_to_python_empty_str(self): + ret = ListField() + self.assertListEqual([], ret.to_python(value="")) + + @mock.patch('tethys_compute.utilities.json.loads') + def test_list_field_to_python_str(self, mock_jl): + ret = ListField() + ret.to_python(value='foo') + mock_jl.assert_called_with('foo') + + @mock.patch('tethys_compute.utilities.json.loads') + def test_list_field_to_python_str_ValueError(self, mock_jl): + ret = ListField() + mock_jl.side_effect = ValueError + self.assertRaises(ValueError, ret.to_python, value='foo') + + def test_list_field_to_python_list(self): + ret = ListField() + input_value = ['foo', 'bar'] + output = ret.to_python(value=input_value) + self.assertListEqual(input_value, output) + + def test_list_field_to_python_dict(self): + ret = ListField() + input_value = {'name': 'bar'} + output = ret.to_python(value=input_value) + self.assertListEqual([], output) + + @mock.patch('tethys_compute.utilities.ListField.to_python') + def test_list_field_from_db_value(self, mock_tp): + ret = ListField() + ret.from_db_value(value='foo', expression='exp', connection='con', context='ctx') + mock_tp.assert_called_with('foo') + + def test_list_field_get_prep_value(self): + ret = ListField() + self.assertEqual('', ret.get_prep_value(value='')) + + def test_list_field_get_prep_value_str(self): + ret = ListField() + self.assertEqual('foo', ret.get_prep_value(value='foo')) + + @mock.patch('tethys_compute.utilities.json.dumps') + def test_list_field_get_prep_value_list(self, mock_jd): + ret = ListField() + input_value = ['foo', 'bar'] + ret.get_prep_value(value=input_value) + mock_jd.assert_called_with(input_value) + + @mock.patch('tethys_compute.utilities.ListField.get_prep_value') + @mock.patch('tethys_compute.utilities.ListField._get_val_from_obj') + def test_list_field_value_to_string(self, mock_gpvo, mock_gpv): + ret = ListField() + + output = mock_gpvo.return_value + + mock_obj = mock.MagicMock() + + ret.value_to_string(obj=mock_obj) + + mock_gpvo.assert_called_with(mock_obj) + + mock_gpv.assert_called_with(output) + + @mock.patch('tethys_compute.utilities.ListField.get_prep_value') + @mock.patch('django.db.models.fields.Field.clean') + def test_list_field_clean(self, mock_sc, mock_gpv): + ret = ListField() + input_value = 'foo' + input_model_instance = mock.MagicMock() + + output = mock_sc.return_value + + ret.clean(value=input_value, model_instance=input_model_instance) + + mock_sc.assert_called_with(input_value, input_model_instance) + + mock_gpv.assert_called_with(output) + + @mock.patch('django.db.models.fields.Field.formfield') + def test_list_field_formfield(self, mock_ff): + ret = ListField() + ret.formfield(additional='test2') + mock_ff.assert_called_once() + + # DictionaryField + + def test_DictionaryField(self): + ret = DictionaryField() + self.assertEqual('Dictionary object', ret.description) + + def test_dictionary_field_get_internal_type(self): + ret = DictionaryField() + self.assertEqual('TextField', ret.get_internal_type()) + + def test_dictionary_field_to_python_none(self): + ret = DictionaryField() + self.assertIsNone(ret.to_python(value=None)) + + def test_dictionary_field_to_python_empty_str(self): + ret = DictionaryField() + self.assertDictEqual({}, ret.to_python(value="")) + + @mock.patch('tethys_compute.utilities.json.loads') + def test_dictionary_field_to_python_str(self, mock_jl): + ret = DictionaryField() + ret.to_python(value='foo') + mock_jl.assert_called_with('foo') + + @mock.patch('tethys_compute.utilities.json.loads') + def test_dictionary_field_to_python_str_value_error(self, mock_jl): + ret = DictionaryField() + mock_jl.side_effect = ValueError + self.assertRaises(ValueError, ret.to_python, value='foo') + + def test_dictionary_field_to_python_dict(self): + ret = DictionaryField() + input_dict = {'name': 'foo', 'extra': 'bar'} + res = ret.to_python(value=input_dict) + self.assertDictEqual(input_dict, res) + + def test_dictionary_field_to_python_empty_dict(self): + ret = DictionaryField() + input_value = ['test1', 'test2'] + res = ret.to_python(value=input_value) + self.assertDictEqual({}, res) + + @mock.patch('tethys_compute.utilities.DictionaryField.to_python') + def test_dictionary_field_from_db_value(self, mock_tp): + ret = DictionaryField() + ret.from_db_value(value='foo', expression='exp', connection='con', context='ctx') + mock_tp.assert_called_with('foo') + + def test_dictionary_field_get_prep_value(self): + ret = DictionaryField() + self.assertEqual('', ret.get_prep_value(value='')) + + def test_dictionary_field_get_prep_value_str(self): + ret = DictionaryField() + self.assertEqual('foo', ret.get_prep_value(value='foo')) + + @mock.patch('tethys_compute.utilities.json.dumps') + def test_dictionary_field_get_prep_value_list(self, mock_jd): + ret = DictionaryField() + input_value = ['foo', 'bar'] + ret.get_prep_value(value=input_value) + mock_jd.assert_called_with(input_value) + + @mock.patch('tethys_compute.utilities.DictionaryField.get_prep_value') + @mock.patch('tethys_compute.utilities.DictionaryField._get_val_from_obj') + def test_dictionary_field_value_to_string(self, mock_gpvo, mock_gpv): + ret = DictionaryField() + + output = mock_gpvo.return_value + + mock_obj = mock.MagicMock() + + ret.value_to_string(obj=mock_obj) + + mock_gpvo.assert_called_with(mock_obj) + + mock_gpv.assert_called_with(output) + + @mock.patch('tethys_compute.utilities.DictionaryField.get_prep_value') + @mock.patch('django.db.models.fields.Field.clean') + def test_dictionary_field_clean(self, mock_sc, mock_gpv): + ret = DictionaryField() + input_value = 'foo' + input_model_instance = mock.MagicMock() + + output = mock_sc.return_value + + ret.clean(value=input_value, model_instance=input_model_instance) + + mock_sc.assert_called_with(input_value, input_model_instance) + + mock_gpv.assert_called_with(output) + + @mock.patch('django.db.models.fields.Field.formfield') + def test_dictionary_field_formfield(self, mock_ff): + ret = DictionaryField() + ret.formfield(additional='test2') + mock_ff.assert_called_once() diff --git a/tests/unit_tests/test_tethys_config/test_admin.py b/tests/unit_tests/test_tethys_config/test_admin.py new file mode 100644 index 000000000..af3bd293b --- /dev/null +++ b/tests/unit_tests/test_tethys_config/test_admin.py @@ -0,0 +1,51 @@ +import unittest +import mock + +from tethys_config.models import SettingsCategory, Setting +from tethys_config.admin import SettingInline, SettingCategoryAdmin + + +class TethysConfigAdminTest(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_SettingInline(self): + expected_fields = ('name', 'content', 'date_modified') + expected_readonly_fields = ('name', 'date_modified') + ret = SettingInline(mock.MagicMock(), mock.MagicMock()) + + self.assertEquals(expected_fields, ret.fields) + self.assertEquals(expected_readonly_fields, ret.readonly_fields) + self.assertEquals(Setting, ret.model) + self.assertEquals(0, ret.extra) + self.assertIsNotNone(ret.formfield_overrides) + + def test_SettingCategoryAdmin(self): + expected_fields = ('name',) + expected_readonly_fields = ('name',) + expected_inlines = [SettingInline] + ret = SettingCategoryAdmin(mock.MagicMock(), mock.MagicMock()) + + self.assertEquals(expected_fields, ret.fields) + self.assertEquals(expected_readonly_fields, ret.readonly_fields) + self.assertEquals(expected_inlines, ret.inlines) + + def test_has_delete_permission(self): + mock_request = mock.MagicMock() + ret = SettingCategoryAdmin(mock.MagicMock(), mock.MagicMock()) + self.assertFalse(ret.has_delete_permission(mock_request)) + + def test_has_add_permission(self): + mock_request = mock.MagicMock() + ret = SettingCategoryAdmin(mock.MagicMock(), mock.MagicMock()) + self.assertFalse(ret.has_add_permission(mock_request)) + + def test_admin_site_register(self): + from django.contrib import admin + registry = admin.site._registry + self.assertIn(SettingsCategory, registry) + self.assertIsInstance(registry[SettingsCategory], SettingCategoryAdmin) diff --git a/tests/unit_tests/test_tethys_config/test_apps.py b/tests/unit_tests/test_tethys_config/test_apps.py new file mode 100644 index 000000000..e7c88489a --- /dev/null +++ b/tests/unit_tests/test_tethys_config/test_apps.py @@ -0,0 +1,22 @@ +import unittest + +from django.apps import apps +from tethys_config.apps import TethysPortalConfig + + +class TethysConfigAppsTest(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_TethysPortalConfig(self): + app_config = apps.get_app_config('tethys_config') + name = app_config.name + verbose_name = app_config.verbose_name + + self.assertEqual('tethys_config', name) + self.assertEqual('Tethys Portal', verbose_name) + self.assertTrue(isinstance(app_config, TethysPortalConfig)) diff --git a/tests/unit_tests/test_tethys_config/test_context_processors.py b/tests/unit_tests/test_tethys_config/test_context_processors.py new file mode 100644 index 000000000..8cd9da31f --- /dev/null +++ b/tests/unit_tests/test_tethys_config/test_context_processors.py @@ -0,0 +1,45 @@ +import unittest +import mock + +from tethys_config.context_processors import tethys_global_settings_context + + +class TestTethysConfigContextProcessors(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('termsandconditions.models.TermsAndConditions') + @mock.patch('tethys_config.models.Setting') + def test_tethys_global_settings_context(self, mock_setting, mock_terms): + mock_request = mock.MagicMock() + mock_setting.as_dict.return_value = dict() + mock_terms.get_active_terms_list.return_value = ['active_terms'] + mock_terms.get_active_list.return_value = ['active_list'] + + ret = tethys_global_settings_context(mock_request) + + mock_setting.as_dict.assert_called_once() + mock_terms.get_active_terms_list.assert_called_once() + mock_terms.get_active_list.assert_not_called() + + self.assertEquals({'site_globals': {'documents': ['active_terms']}}, ret) + + @mock.patch('termsandconditions.models.TermsAndConditions') + @mock.patch('tethys_config.models.Setting') + def test_tethys_global_settings_context_exception(self, mock_setting, mock_terms): + mock_request = mock.MagicMock() + mock_setting.as_dict.return_value = dict() + mock_terms.get_active_terms_list.side_effect = AttributeError + mock_terms.get_active_list.return_value = ['active_list'] + + ret = tethys_global_settings_context(mock_request) + + mock_setting.as_dict.assert_called_once() + mock_terms.get_active_terms_list.assert_called_once() + mock_terms.get_active_list.assert_called_once_with(as_dict=False) + + self.assertEquals({'site_globals': {'documents': ['active_list']}}, ret) diff --git a/tests/unit_tests/test_tethys_config/test_init.py b/tests/unit_tests/test_tethys_config/test_init.py new file mode 100644 index 000000000..944120e1b --- /dev/null +++ b/tests/unit_tests/test_tethys_config/test_init.py @@ -0,0 +1,142 @@ +import unittest +import mock + +from tethys_config.init import initial_settings, reverse_init + + +class TestInit(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_config.init.timezone.now') + @mock.patch('tethys_config.init.SettingsCategory') + def test_initial_settings(self, mock_settings, mock_now): + mock_apps = mock.MagicMock() + mock_schema_editor = mock.MagicMock() + + initial_settings(apps=mock_apps, schema_editor=mock_schema_editor) + + mock_settings.assert_any_call(name='General Settings') + mock_settings(name='General Settings').save.assert_called() + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Favicon", + content="/tethys_portal/images/" + "default_favicon.png", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Brand Text", + content="Tethys Portal", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Brand Image", + content="/tethys_portal/images/" + "tethys-logo-75.png", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Brand Image Height", content="", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Brand Image Width", content="", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Brand Image Padding", + content="", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Apps Library Title", + content="Apps Library", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Primary Color", + content="#0a62a9", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Secondary Color", + content="#1b95dc", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Background Color", content="", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Primary Text Color", content="", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Primary Text Hover Color", + content="", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Secondary Text Color", + content="", + date_modified=mock_now.return_value) + mock_settings(name='General Settings').setting_set.create.assert_any_call(name="Secondary Text Hover Color", + content="", + date_modified=mock_now.return_value) + + # Home page settings + mock_settings.assert_any_call(name='Home Page') + mock_settings(name='Home Page').save.assert_called() + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Hero Text", + content="Welcome to Tethys Portal,\nthe hub " + "for your apps.", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Blurb Text", + content="Tethys Portal is designed to be " + "customizable, so that you can host " + "apps for your\norganization. You " + "can change everything on this page " + "from the Home Page settings.", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Feature 1 Heading", + content="Feature 1", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Feature 1 Body", + content="Use these features to brag about " + "all of the things users can do " + "with your instance of Tethys " + "Portal.", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Feature 1 Image", + content="/tethys_portal/images/" + "placeholder.gif", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Feature 2 Heading", + content="Feature 2", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Feature 2 Body", + content="Describe the apps and tools that " + "your Tethys Portal provides and " + "add custom pictures to each " + "feature as a finishing touch.", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Feature 2 Image", + content="/tethys_portal/images/" + "placeholder.gif", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Feature 3 Heading", + content="Feature 3", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Feature 3 Body", + content="You can change the color theme and " + "branding of your Tethys Portal in " + "a jiffy. Visit the Site Admin " + "settings from the user menu and " + "select General Settings.", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Feature 3 Image", + content="/tethys_portal/images/" + "placeholder.gif", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Call to Action", + content="Ready to get started?", + date_modified=mock_now.return_value) + mock_settings(name='Home Page').setting_set.create.assert_any_call(name="Call to Action Button", + content="Start Using Tethys!", + date_modified=mock_now.return_value) + + @mock.patch('tethys_config.init.Setting') + @mock.patch('tethys_config.init.SettingsCategory') + def test_reverse_init(self, mock_categories, mock_settings): + mock_apps = mock.MagicMock + mock_schema_editor = mock.MagicMock() + mock_cat = mock.MagicMock() + mock_set = mock.MagicMock() + mock_categories.objects.all.return_value = [mock_cat] + mock_settings.objects.all.return_value = [mock_set] + + reverse_init(apps=mock_apps, schema_editor=mock_schema_editor) + + mock_categories.objects.all.assert_called_once() + mock_settings.objects.all.assert_called_once() + mock_cat.delete.assert_called_once() + mock_set.delete.assert_called_once() diff --git a/tests/unit_tests/test_tethys_config/test_models/__init__.py b/tests/unit_tests/test_tethys_config/test_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_config/test_models/test_Setting.py b/tests/unit_tests/test_tethys_config/test_models/test_Setting.py new file mode 100644 index 000000000..ac6d4af08 --- /dev/null +++ b/tests/unit_tests/test_tethys_config/test_models/test_Setting.py @@ -0,0 +1,29 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_config.models import Setting + + +class SettingTest(TethysTestCase): + def set_up(self): + pass + + def tear_down(self): + pass + + def test_Setting_unicode(self): + set_title = Setting.objects.get(name='Site Title') + + # Check result + self.assertEqual('Site Title', str(set_title)) + + def test_Setting_str(self): + set_title = Setting.objects.get(name='Site Title') + + # Check result + self.assertEqual('Site Title', str(set_title)) + + def test_Setting_as_dict(self): + set_all = Setting.as_dict() + + # Check result + self.assertIsInstance(set_all, dict) + self.assertIn('site_title', set_all) diff --git a/tests/unit_tests/test_tethys_config/test_models/test_SettingsCategory.py b/tests/unit_tests/test_tethys_config/test_models/test_SettingsCategory.py new file mode 100644 index 000000000..7fbd4a14e --- /dev/null +++ b/tests/unit_tests/test_tethys_config/test_models/test_SettingsCategory.py @@ -0,0 +1,19 @@ +from tethys_sdk.testing import TethysTestCase +from tethys_config.models import SettingsCategory + + +class SettingsCategoryTest(TethysTestCase): + def set_up(self): + self.sc_gen = SettingsCategory.objects.get(name='General Settings') + self.sc_home = SettingsCategory.objects.get(name='Home Page') + + def tear_down(self): + pass + + def test_Settings_Category_unicode(self): + self.assertEqual('General Settings', str(self.sc_gen)) + self.assertEqual('Home Page', str(self.sc_home)) + + def test_Settings_Category_str(self): + self.assertEqual('General Settings', str(self.sc_gen)) + self.assertEqual('Home Page', str(self.sc_home)) diff --git a/tests/unit_tests/test_tethys_gizmos/__init__.py b/tests/unit_tests/test_tethys_gizmos/__init__.py new file mode 100644 index 000000000..fc1fe764a --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/__init__.py @@ -0,0 +1,8 @@ +""" +******************************************************************************** +* Name: __init__.py +* Author: nswain +* Created On: July 23, 2018 +* Copyright: (c) Aquaveo 2018 +******************************************************************************** +""" diff --git a/tests/unit_tests/test_tethys_gizmos/test_context_processors.py b/tests/unit_tests/test_tethys_gizmos/test_context_processors.py new file mode 100644 index 000000000..ff8ee2515 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_context_processors.py @@ -0,0 +1,16 @@ +import unittest +import tethys_gizmos.context_processors as gizmos_context_processor + + +class TestContextProcessor(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_tethys_gizmos_context(self): + result = gizmos_context_processor.tethys_gizmos_context('request') + + # Check Result + self.assertEqual({'gizmos_rendered': []}, result) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/__init__.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/__init__.py new file mode 100644 index 000000000..fc1fe764a --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/__init__.py @@ -0,0 +1,8 @@ +""" +******************************************************************************** +* Name: __init__.py +* Author: nswain +* Created On: July 23, 2018 +* Copyright: (c) Aquaveo 2018 +******************************************************************************** +""" diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_base.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_base.py new file mode 100644 index 000000000..7e1cd8e7e --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_base.py @@ -0,0 +1,59 @@ +""" +******************************************************************************** +* Name: base.py +* Author: nswain +* Created On: July 23, 2018 +* Copyright: (c) Aquaveo 2018 +******************************************************************************** +""" +import unittest +import tethys_gizmos.gizmo_options.base as basetest + + +class TestTethysGizmosBase(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_TethysGizmoOptions(self): + test_dict = 'key1="value with spaces" key2="value_with_no_spaces"' + test_class = 'Map Type' + + result = basetest.TethysGizmoOptions(test_dict, test_class) + + self.assertIsInstance(result['attributes'], dict) + self.assertEqual('value with spaces', result['attributes']['key1']) + self.assertEqual('value_with_no_spaces', result['attributes']['key2']) + self.assertEqual('Map Type', result['classes']) + + def test_get_tethys_gizmos_js(self): + result = basetest.TethysGizmoOptions.get_tethys_gizmos_js() + self.assertIn('tethys_gizmos.js', result[0]) + self.assertNotIn('.css', result[0]) + + def test_get_tethys_gizmos_css(self): + result = basetest.TethysGizmoOptions.get_tethys_gizmos_css() + self.assertIn('tethys_gizmos.css', result[0]) + self.assertNotIn('.js', result[0]) + + def test_get_vendor_js(self): + result = basetest.TethysGizmoOptions.get_vendor_js() + self.assertFalse(result) + + def test_get_gizmo_js(self): + result = basetest.TethysGizmoOptions.get_gizmo_js() + self.assertFalse(result) + + def test_get_vendor_css(self): + result = basetest.TethysGizmoOptions.get_vendor_css() + self.assertFalse(result) + + def test_get_gizmo_css(self): + result = basetest.TethysGizmoOptions.get_gizmo_css() + self.assertFalse(result) + + def test_SecondaryGizmoOptions(self): + result = basetest.SecondaryGizmoOptions() + self.assertFalse(result) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_bokeh_view.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_bokeh_view.py new file mode 100644 index 000000000..bba71f653 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_bokeh_view.py @@ -0,0 +1,31 @@ +import unittest +import tethys_gizmos.gizmo_options.bokeh_view as bokeh_view +from bokeh.plotting import figure + + +class TestBokehView(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_BokehView(self): + plot = figure(plot_height=300) + plot.circle([1, 2], [3, 4]) + attr = {'title': 'test title', 'description': 'test attributes'} + result = bokeh_view.BokehView(plot, attributes=attr) + + self.assertIn('test attributes', result['attributes']['description']) + self.assertIn('Circle', result['script']) + + def test_get_vendor_css(self): + result = bokeh_view.BokehView.get_vendor_css() + + self.assertIn('.css', result[0]) + self.assertNotIn('.js', result[0]) + + def test_get_vendor_js(self): + result = bokeh_view.BokehView.get_vendor_js() + self.assertIn('.js', result[0]) + self.assertNotIn('.css', result[0]) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_button.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_button.py new file mode 100644 index 000000000..89ec8f967 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_button.py @@ -0,0 +1,38 @@ +import unittest +import tethys_gizmos.gizmo_options.button as gizmo_button + + +class TestButton(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_ButtonGroup(self): + buttons = [{'display_text': 'Add', 'style': 'success'}, + {'display_text': 'Delete', 'style': 'danger'}] + result = gizmo_button.ButtonGroup(buttons) + + self.assertIn(buttons[0], result['buttons']) + self.assertIn(buttons[1], result['buttons']) + + def test_Button(self): + display_text = 'Add' + name = 'Aquaveo' + style = 'success' + icon = 'glyphicon glyphicon-globe' + href = 'linktest' + attr = {'title': 'test title', 'description': 'test attributes'} + test_class = 'Test Class' + result = gizmo_button.Button(display_text=display_text, name=name, style=style, + icon=icon, href=href, submit=True, disabled=False, + attributes=attr, classes=test_class) + + self.assertEqual(display_text, result['display_text']) + self.assertEqual(name, result['name']) + self.assertEqual(style, result['style']) + self.assertEqual(icon, result['icon']) + self.assertEqual(href, result['href']) + self.assertEqual(attr, result['attributes']) + self.assertEqual(test_class, result['classes']) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_datatable_view.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_datatable_view.py new file mode 100644 index 000000000..dd6b7936e --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_datatable_view.py @@ -0,0 +1,41 @@ +import unittest +import tethys_gizmos.gizmo_options.datatable_view as gizmo_datatable_view + + +class TestDatatableView(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_DataTableView(self): + column_names = ['Name', 'Age', 'Job'] + datatable_options = {'rows': [['Bill', '30', 'contractor'], ['Fred', '18', 'programmer']]} + rows = 2 + result = gizmo_datatable_view.DataTableView(rows=rows, column_names=column_names, + datatable_options=datatable_options) + # Check Result + self.assertEqual(rows, result['rows']) + self.assertEqual(column_names, result['column_names']) + self.assertIn(datatable_options['rows'][0][0], result['datatable_options']['datatable_options']) + self.assertIn(datatable_options['rows'][0][1], result['datatable_options']['datatable_options']) + self.assertIn(datatable_options['rows'][0][2], result['datatable_options']['datatable_options']) + self.assertIn(datatable_options['rows'][1][0], result['datatable_options']['datatable_options']) + self.assertIn(datatable_options['rows'][1][1], result['datatable_options']['datatable_options']) + self.assertIn(datatable_options['rows'][1][2], result['datatable_options']['datatable_options']) + + result = gizmo_datatable_view.DataTableView.get_vendor_css() + # Check Result + self.assertIn('.css', result[0]) + self.assertNotIn('.js', result[0]) + + result = gizmo_datatable_view.DataTableView.get_vendor_js() + # Check Result + self.assertIn('.js', result[0]) + self.assertNotIn('.css', result[0]) + + result = gizmo_datatable_view.DataTableView.get_gizmo_js() + # Check Result + self.assertIn('.js', result[0]) + self.assertNotIn('.css', result[0]) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_date_picker.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_date_picker.py new file mode 100644 index 000000000..897c54425 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_date_picker.py @@ -0,0 +1,42 @@ +import unittest +import tethys_gizmos.gizmo_options.date_picker as gizmo_date_picker + + +class TestButton(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_DatePicker(self): + name = 'Date Picker' + display_text = 'Unit Test' + autoclose = True + calendar_weeks = True + clear_button = True + days_of_week_disabled = '6' + min_view_mode = 'days' + + result = gizmo_date_picker.DatePicker(name=name, display_text=display_text, autoclose=autoclose, + calendar_weeks=calendar_weeks, clear_button=clear_button, + days_of_week_disabled=days_of_week_disabled, min_view_mode=min_view_mode) + + # Check Result + self.assertIn(name, result['name']) + self.assertIn(display_text, result['display_text']) + self.assertTrue(result['autoclose']) + self.assertTrue(result['calendar_weeks']) + self.assertTrue(result['clear_button']) + self.assertIn(days_of_week_disabled, result['days_of_week_disabled']) + self.assertIn(min_view_mode, result['min_view_mode']) + + result = gizmo_date_picker.DatePicker.get_vendor_css() + + # Check Result + self.assertIn('.css', result[0]) + self.assertNotIn('.js', result[0]) + + result = gizmo_date_picker.DatePicker.get_vendor_js() + self.assertIn('.js', result[0]) + self.assertNotIn('.css', result[0]) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_esri_map.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_esri_map.py new file mode 100644 index 000000000..2acab3e65 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_esri_map.py @@ -0,0 +1,51 @@ +import unittest +import tethys_gizmos.gizmo_options.esri_map as gizmo_esri_map + + +class TestESRI(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_ESRIMap(self): + layers = ['layer1', 'layer2'] + basemap = 'Aerial' + result = gizmo_esri_map.ESRIMap(basemap=basemap, layers=layers) + # Check Result + self.assertIn(basemap, result['basemap']) + self.assertEqual(layers, result['layers']) + + result = gizmo_esri_map.ESRIMap.get_vendor_js() + # Check Result + self.assertIn('js', result[0]) + self.assertNotIn('css', result[0]) + + result = gizmo_esri_map.ESRIMap.get_gizmo_js() + # Check Result + self.assertIn('.js', result[0]) + self.assertNotIn('.css', result[0]) + + result = gizmo_esri_map.ESRIMap.get_vendor_css() + # Check Result + self.assertIn('.css', result[0]) + self.assertNotIn('.js', result[0]) + + def test_EMView(self): + center = ['40.276039', '-111.651120'] + zoom = 4 + + result = gizmo_esri_map.EMView(center=center, zoom=zoom) + # Check Result + self.assertEqual(zoom, result['zoom']) + self.assertEqual(center, result['center']) + + def test_EMLayer(self): + type = 'ImageryLayer' + url = 'www.aquaveo.com' + + result = gizmo_esri_map.EMLayer(type=type, url=url) + # Check Result + self.assertEqual(type, result['type']) + self.assertEqual(url, result['url']) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_google_map_view.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_google_map_view.py new file mode 100644 index 000000000..cc47ae487 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_google_map_view.py @@ -0,0 +1,47 @@ +import unittest +import tethys_gizmos.gizmo_options.google_map_view as gizmo_google_map_view + + +class TestGoogleMapView(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_GoogleMapView(self): + height = '600px' + width = '80%' + maps_api_key = 'api-key' + reference_kml_action = 'gizmos:get_kml' + drawing_types_enabled = ['POLYGONS', 'POINTS', 'POLYLINES'] + initial_drawing_mode = 'POINTS' + output_format = 'WKT' + result = gizmo_google_map_view.GoogleMapView(height=height, width=width, maps_api_key=maps_api_key, + reference_kml_action=reference_kml_action, + output_format=output_format, + drawing_types_enabled=drawing_types_enabled, + initial_drawing_mode=initial_drawing_mode) + # Check Result + self.assertIn(height, result['height']) + self.assertIn(width, result['width']) + self.assertIn(maps_api_key, result['maps_api_key']) + self.assertIn(reference_kml_action, result['reference_kml_action']) + self.assertEqual(drawing_types_enabled, result['drawing_types_enabled']) + self.assertIn(initial_drawing_mode, result['initial_drawing_mode']) + self.assertIn(output_format, result['output_format']) + + result = gizmo_google_map_view.GoogleMapView.get_vendor_js() + # Check Result + self.assertIn('.js', result[0]) + self.assertNotIn('.css', result[0]) + + result = gizmo_google_map_view.GoogleMapView.get_vendor_css() + # Check Result + self.assertIn('.css', result[0]) + self.assertNotIn('.js', result[0]) + + result = gizmo_google_map_view.GoogleMapView.get_gizmo_js() + # Check Result + self.assertIn('.js', result[0]) + self.assertNotIn('.css', result[0]) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_jobs_table.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_jobs_table.py new file mode 100644 index 000000000..2f5ccddd3 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_jobs_table.py @@ -0,0 +1,79 @@ +import unittest +import tethys_gizmos.gizmo_options.jobs_table as gizmo_jobs_table +import mock + + +class JobObject(object): + def __init__(self, id, name, description, creation_time, run_time): + self.id = id + self.name = name + self.description = description + self.creation_time = creation_time + self.run_time = run_time + + +class TestJobsTable(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_gizmos.gizmo_options.jobs_table.JobsTable.set_rows_and_columns') + def test_JobsTable_init(self, mock_set): + job1 = JobObject(1, 'name1', 'des1', 1, 1) + job2 = JobObject(2, 'name2', 'des2', 2, 2) + jobs = [job1, job2] + column_fields = ['id', 'name', 'description', 'creation_time', 'run_time'] + + ret = gizmo_jobs_table.JobsTable(jobs=jobs, column_fields=column_fields) + + mock_set.assert_called_with(jobs, ['id', 'name', 'description', 'creation_time', 'run_time']) + self.assertTrue(ret.status_actions) + self.assertTrue(ret.run) + self.assertTrue(ret.delete) + self.assertTrue(ret.delay_loading_status) + self.assertFalse(ret.hover) + self.assertFalse(ret.bordered) + self.assertFalse(ret.striped) + self.assertFalse(ret.condensed) + self.assertFalse(ret.attributes) + self.assertEqual('', ret.results_url) + self.assertEqual('', ret.classes) + self.assertEqual(5000, ret.refresh_interval) + + def test_set_rows_and_columns(self): + job1 = JobObject(1, 'name1', 'des1', 1, 1) + job2 = JobObject(2, 'name2', 'des2', 2, 2) + jobs = [job1, job2] + column_fields = ['id', 'name', 'description', 'creation_time', 'run_time'] + + # This set_rows_and_columns method is called at the init + result = gizmo_jobs_table.JobsTable(jobs=jobs, column_fields=column_fields) + self.assertIn(job1.id, result['rows'][0]) + self.assertIn(job1.name, result['rows'][0]) + self.assertIn(job1.description, result['rows'][0]) + self.assertIn(job1.creation_time, result['rows'][0]) + self.assertIn(job1.run_time, result['rows'][0]) + self.assertIn(job2.id, result['rows'][1]) + self.assertIn(job2.name, result['rows'][1]) + self.assertIn(job2.description, result['rows'][1]) + self.assertIn(job2.creation_time, result['rows'][1]) + self.assertIn(job2.run_time, result['rows'][1]) + self.assertTrue(result['status_actions']) + + @mock.patch('tethys_gizmos.gizmo_options.jobs_table.log.warning') + def test_set_rows_and_columns_warning(self, mock_log): + job1 = JobObject(1, 'name1', 'des1', 1, 1) + jobs = [job1] + column_name = 'not_exist' + column_fields = [column_name] + + gizmo_jobs_table.JobsTable(jobs=jobs, column_fields=column_fields) + + mock_log.assert_called_with('Column %s was not added because the %s has no attribute %s.', + 'Not Exist', str(job1), column_name) + + def test_get_gizmo_js(self): + self.assertIn('jobs_table.js', gizmo_jobs_table.JobsTable.get_gizmo_js()[0]) + self.assertNotIn('.css', gizmo_jobs_table.JobsTable.get_gizmo_js()[0]) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_map_view.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_map_view.py new file mode 100644 index 000000000..5a892aa06 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_map_view.py @@ -0,0 +1,210 @@ +import unittest +import tethys_gizmos.gizmo_options.map_view as gizmo_map_view +import mock + + +class MockObject(object): + def __init__(self, debug=True): + self.DEBUG = debug + + +class TestMapView(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_MapView(self): + height = '500px' + width = '90%' + basemap = 'Aerial' + controls = ['ZoomSlider', 'Rotate', 'FullScreen', 'ScaleLine'] + + result = gizmo_map_view.MapView(height=height, width=width, basemap=basemap, controls=controls) + + # Check Result + self.assertIn(height, result['height']) + self.assertIn(width, result['width']) + self.assertIn(basemap, result['basemap']) + self.assertEqual(controls, result['controls']) + + self.assertIn('.js', gizmo_map_view.MapView.get_vendor_js()[0]) + self.assertIn('.js', gizmo_map_view.MapView.get_gizmo_js()[0]) + self.assertIn('.css', gizmo_map_view.MapView.get_vendor_css()[0]) + self.assertIn('.css', gizmo_map_view.MapView.get_gizmo_css()[0]) + + @mock.patch('tethys_gizmos.gizmo_options.map_view.settings') + def test_MapView_debug(self, mock_settings): + ms = mock_settings() + ms.return_value = MockObject() + + self.assertIn('-debug.js', gizmo_map_view.MapView.get_vendor_js()[0]) + + def test_MVView(self): + projection = 'EPSG:4326' + center = [-100, 40] + zoom = 10 + maxZoom = 20 + minZoom = 2 + + result = gizmo_map_view.MVView(projection=projection, center=center, zoom=zoom, + maxZoom=maxZoom, minZoom=minZoom) + + # Check result + self.assertIn(projection, result['projection']) + self.assertEqual(center, result['center']) + self.assertEqual(zoom, result['zoom']) + self.assertEqual(maxZoom, result['maxZoom']) + self.assertEqual(minZoom, result['minZoom']) + + def test_MVDraw(self): + controls = ['Modify', 'Delete', 'Move', 'Point', 'LineString', 'Polygon', 'Box'] + initial = 'Point' + output_format = 'GeoJSON' + fill_color = 'rgba(255,255,255,0.2)' + line_color = '#663399' + point_color = '#663399' + + result = gizmo_map_view.MVDraw(controls=controls, initial=initial, output_format=output_format, + line_color=line_color, fill_color=fill_color, point_color=point_color) + + # Check result + self.assertEqual(controls, result['controls']) + self.assertEqual(initial, result['initial']) + self.assertEqual(output_format, result['output_format']) + self.assertEqual(fill_color, result['fill_color']) + self.assertEqual(line_color, result['line_color']) + self.assertEqual(point_color, result['point_color']) + + def test_MVDraw_no_ini(self): + controls = ['Modify'] + + # Raise Error if Initial is not in Controls list + self.assertRaises(ValueError, gizmo_map_view.MVDraw, controls=controls, initial='Point') + + def test_MVLayer(self): + source = 'KML' + legend_title = 'Park City Watershed' + options = {'url': '/static/tethys_gizmos/data/model.kml'} + + result = gizmo_map_view.MVLayer(source=source, legend_title=legend_title, options=options) + + # Check Result + self.assertEqual(source, result['source']) + self.assertEqual(legend_title, result['legend_title']) + self.assertEqual(options, result['options']) + + @mock.patch('tethys_gizmos.gizmo_options.map_view.log.warning') + def test_MVLayer_warning(self, mock_log): + source = 'KML' + legend_title = 'Park City Watershed' + options = {'url': '/static/tethys_gizmos/data/model.kml'} + feature_selection = True + + gizmo_map_view.MVLayer(source=source, legend_title=legend_title, options=options, + feature_selection=feature_selection) + + mock_log.assert_called_with("geometry_attribute not defined -using default value 'the_geom'") + + def test_MVLegendClass(self): + # Point + type_value = 'point' + value = 'Point Legend' + fill = '#00ff00' + + result = gizmo_map_view.MVLegendClass(type=type_value, value=value, fill=fill) + + # Check Result + self.assertEqual(value, result['value']) + self.assertEqual(fill, result['fill']) + self.assertEqual(value, result['value']) + + # Point with No Fill + self.assertRaises(ValueError, gizmo_map_view.MVLegendClass, type=type_value, value=value) + + # Not Valid Type + self.assertRaises(ValueError, gizmo_map_view.MVLegendClass, type='points', value=value) + + # Line + type_value = 'line' + value = 'Line Legend' + stroke = '#00ff01' + result = gizmo_map_view.MVLegendClass(type=type_value, value=value, stroke=stroke) + + # Check Result + self.assertEqual(type_value, result['type']) + self.assertEqual(stroke, result['stroke']) + self.assertEqual(value, result['value']) + + # Line with No Stroke + self.assertRaises(ValueError, gizmo_map_view.MVLegendClass, type=type_value, value=value) + + # Polygon + type_value = 'polygon' + value = 'Polygon Legend' + fill = '#00ff00' + stroke = '#00ff01' + + result = gizmo_map_view.MVLegendClass(type=type_value, value=value, stroke=stroke, fill=fill) + + # Check Result + self.assertEqual(type_value, result['type']) + self.assertEqual(stroke, result['stroke']) + self.assertEqual(fill, result['fill']) + self.assertEqual(value, result['value']) + + # Polygon with no Stroke + result = gizmo_map_view.MVLegendClass(type=type_value, value=value, fill=fill) + + # Check Result + self.assertEqual(type_value, result['type']) + self.assertEqual(fill, result['fill']) + self.assertEqual(fill, result['line']) + + # Polygon with no fill + self.assertRaises(ValueError, gizmo_map_view.MVLegendClass, type=type_value, value=value) + + # Raster + type_value = 'raster' + value = 'Raster Legend' + ramp = ['#00ff00', '#00ff01', '#00ff02'] + + result = gizmo_map_view.MVLegendClass(type=type_value, value=value, ramp=ramp) + + # Check Result + self.assertEqual(type_value, result['type']) + self.assertEqual(ramp, result['ramp']) + + # Raster without ramp + self.assertRaises(ValueError, gizmo_map_view.MVLegendClass, type=type_value, value=value) + + def test_MVLegendImageClass(self): + value = 'image legend' + image_url = 'www.aquaveo.com/image.png' + + result = gizmo_map_view.MVLegendImageClass(value=value, image_url=image_url) + + # Check Result + self.assertEqual(value, result['value']) + self.assertEqual(image_url, result['image_url']) + + def test_MVLegendGeoServerImageClass(self): + value = 'Cities' + geoserver_url = 'http://localhost:8181/geoserver' + style = 'green' + layer = 'rivers' + width = 20 + height = 10 + + image_url = "{0}/wms?REQUEST=GetLegendGraphic&VERSION=1.0.0&" \ + "STYLE={1}&FORMAT=image/png&WIDTH={2}&HEIGHT={3}&" \ + "LEGEND_OPTIONS=forceRule:true&" \ + "LAYER={4}".format(geoserver_url, style, width, height, layer) + + result = gizmo_map_view.MVLegendGeoServerImageClass(value=value, geoserver_url=geoserver_url, + style=style, layer=layer, width=width, height=height) + + # Check Result + self.assertEqual(value, result['value']) + self.assertEqual(image_url, result['image_url']) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_message_box.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_message_box.py new file mode 100644 index 000000000..275c0b529 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_message_box.py @@ -0,0 +1,20 @@ +import unittest +import tethys_gizmos.gizmo_options.message_box as gizmo_message_box + + +class TestMessageBox(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_MessageBox(self): + name = 'MB Name' + title = 'MB Title' + + result = gizmo_message_box.MessageBox(name=name, title=title) + + # Check Result + self.assertEqual(name, result['name']) + self.assertEqual(title, result['title']) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_plot_view.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_plot_view.py new file mode 100644 index 000000000..f835bde41 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_plot_view.py @@ -0,0 +1,259 @@ +import unittest +import tethys_gizmos.gizmo_options.plot_view as gizmo_plot_view +import datetime + + +class TestPlotView(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_PlotViewBase(self): + engine = 'highcharts' + result = gizmo_plot_view.PlotViewBase(engine=engine) + + # Check Result + self.assertEqual(engine, result['engine']) + + # Engine is not d3 or hightcharts + self.assertRaises(ValueError, gizmo_plot_view.PlotViewBase, engine='d2') + + # Check Get Method + self.assertIn('.js', gizmo_plot_view.PlotViewBase.get_vendor_js()[0]) + self.assertNotIn('.css', gizmo_plot_view.PlotViewBase.get_vendor_js()[0]) + + self.assertIn('.js', gizmo_plot_view.PlotViewBase.get_gizmo_js()[0]) + self.assertNotIn('.css', gizmo_plot_view.PlotViewBase.get_gizmo_js()[0]) + + self.assertIn('.css', gizmo_plot_view.PlotViewBase.get_gizmo_css()[0]) + self.assertNotIn('.js', gizmo_plot_view.PlotViewBase.get_gizmo_css()[0]) + + def test_PlotObject(self): + chart = 'test chart' + xAxis = 'Distance' + yAxis = 'Time' + title = 'Title' + subtitle = 'Subtitle' + tooltip_format = 'Format' + custom = {'key1': 'value1', 'key2': 'value2'} + result = gizmo_plot_view.PlotObject(chart=chart, xAxis=xAxis, yAxis=yAxis, title=title, + subtitle=subtitle, tooltip_format=tooltip_format, custom=custom) + # Check Result + self.assertEqual(chart, result['chart']) + self.assertEqual(xAxis, result['xAxis']) + self.assertEqual(yAxis, result['yAxis']) + self.assertEqual(title, result['title']['text']) + self.assertEqual(subtitle, result['subtitle']['text']) + self.assertEqual(tooltip_format, result['tooltip']) + self.assertIn(custom['key1'], result['custom']['key1']) + self.assertIn(custom['key2'], result['custom']['key2']) + + def test_LinePlot(self): + series = [ + { + 'name': 'Air Temp', + 'color': '#0066ff', + 'marker': {'enabled': False}, + 'data': [ + [0, 5], [10, -70], + [20, -86.5], [30, -66.5], + [40, -32.1], + [50, -12.5], [60, -47.7], + [70, -85.7], [80, -106.5] + ] + }, + { + 'name': 'Water Temp', + 'color': '#ff6600', + 'data': [ + [0, 15], [10, -50], + [20, -56.5], [30, -46.5], + [40, -22.1], + [50, -2.5], [60, -27.7], + [70, -55.7], [80, -76.5] + ] + } + ] + + result = gizmo_plot_view.LinePlot(series=series) + # Check result + self.assertEqual(series[0]['color'], result['plot_object']['series'][0]['color']) + self.assertEqual(series[0]['name'], result['plot_object']['series'][0]['name']) + self.assertEqual(series[0]['data'], result['plot_object']['series'][0]['data']) + + x_axis_title = 'Distance' + x_axis_units = 'm' + y_axis_title = 'Time' + y_axis_units = 's' + + result = gizmo_plot_view.LinePlot(series=series, spline=True, x_axis_title=x_axis_title, + y_axis_title=y_axis_title, x_axis_units=x_axis_units, + y_axis_units=y_axis_units) + + # Check result + x_text = '{0} ({1})'.format(x_axis_title, x_axis_units) + y_text = '{0} ({1})'.format(y_axis_title, y_axis_units) + self.assertEqual(x_text, result['plot_object']['xAxis']['title']['text']) + self.assertEqual(y_text, result['plot_object']['yAxis']['title']['text']) + + def test_PolarPlot(self): + series = [ + { + 'name': 'Park City', + 'data': [0.2, 0.5, 0.1, 0.8, 0.2, 0.6, 0.8, 0.3], + 'pointPlacement': 'on' + }, + { + 'name': 'Little Dell', + 'data': [0.8, 0.3, 0.2, 0.5, 0.1, 0.8, 0.2, 0.6], + 'pointPlacement': 'on' + } + ] + + result = gizmo_plot_view.PolarPlot(series=series) + # Check result + self.assertEqual(series[0]['pointPlacement'], result['plot_object']['series'][0]['pointPlacement']) + self.assertEqual(series[0]['name'], result['plot_object']['series'][0]['name']) + self.assertEqual(series[0]['data'], result['plot_object']['series'][0]['data']) + + def test_ScatterPlot(self): + male_dataset = { + 'name': 'Male', + 'color': '#0066ff', + 'data': [ + [174.0, 65.6], [175.3, 71.8], [193.5, 80.7], [186.5, 72.6] + ] + } + + female_dataset = { + 'name': 'Female', + 'color': '#ff6600', + 'data': [ + [161.2, 51.6], [167.5, 59.0], [159.5, 49.2], [157.0, 63.0] + ] + } + + series = [male_dataset, female_dataset] + x_axis_title = 'Distance' + x_axis_units = 'm' + y_axis_title = 'Time' + y_axis_units = 's' + + result = gizmo_plot_view.ScatterPlot(series=series, x_axis_title=x_axis_title, + y_axis_title=y_axis_title, x_axis_units=x_axis_units, + y_axis_units=y_axis_units) + + # Check result + x_text = '{0} ({1})'.format(x_axis_title, x_axis_units) + y_text = '{0} ({1})'.format(y_axis_title, y_axis_units) + self.assertEqual(x_text, result['plot_object']['xAxis']['title']['text']) + self.assertEqual(y_text, result['plot_object']['yAxis']['title']['text']) + self.assertEqual(series[0]['color'], result['plot_object']['series'][0]['color']) + self.assertEqual(series[0]['name'], result['plot_object']['series'][0]['name']) + self.assertEqual(series[0]['data'], result['plot_object']['series'][0]['data']) + self.assertEqual(series[1]['color'], result['plot_object']['series'][1]['color']) + self.assertEqual(series[1]['name'], result['plot_object']['series'][1]['name']) + self.assertEqual(series[1]['data'], result['plot_object']['series'][1]['data']) + + def test_PiePlot(self): + series = [ + { + 'name': 'Park City', + 'data': [0.2, 0.5, 0.1, 0.8, 0.2, 0.6, 0.8, 0.3] + }, + ] + + result = gizmo_plot_view.PiePlot(series=series) + + # Check result + self.assertEqual(series[0]['name'], result['plot_object']['series'][0]['name']) + self.assertEqual(series[0]['data'], result['plot_object']['series'][0]['data']) + + def test_BarPlot(self): + series = [{ + 'name': "Year 1800", + 'data': [100, 31, 635, 203, 275, 487, 872, 671, 736, 568, 487, 432] + }, { + 'name': "Year 1900", + 'data': [133, 200, 947, 408, 682, 328, 917, 171, 482, 140, 176, 237] + } + ] + + result = gizmo_plot_view.BarPlot(series=series) + # Check result + self.assertEqual(series[0]['name'], result['plot_object']['series'][0]['name']) + self.assertEqual(series[0]['data'], result['plot_object']['series'][0]['data']) + + axis_title = 'Population' + axis_units = 'Millions' + result = gizmo_plot_view.BarPlot(series=series, horizontal=True, axis_title=axis_title, + axis_units=axis_units) + # Check result + y_text = '{0} ({1})'.format(axis_title, axis_units) + self.assertEqual(y_text, result['plot_object']['yAxis']['title']['text']) + self.assertEqual(series[0]['name'], result['plot_object']['series'][0]['name']) + self.assertEqual(series[0]['data'], result['plot_object']['series'][0]['data']) + + def test_TimeSeries(self): + series = [{ + 'name': 'Winter 2007-2008', + 'data': [ + ['12/02/2008', 0.8], + ['12/09/2008', 0.6] + ] + }] + + result = gizmo_plot_view.TimeSeries(series=series) + # Check result + self.assertEqual(series[0]['name'], result['plot_object']['series'][0]['name']) + self.assertEqual(series[0]['data'], result['plot_object']['series'][0]['data']) + + def test_AreaRange(self): + averages = [ + [datetime.date(2009, 7, 1), 21.5], [datetime.date(2009, 7, 2), 22.1], [datetime.date(2009, 7, 3), 23] + ] + ranges = [ + [datetime.date(2009, 7, 1), 14.3, 27.7], [datetime.date(2009, 7, 2), 14.5, 27.8], + [datetime.date(2009, 7, 3), 15.5, 29.6] + ] + series = [{ + 'name': 'Temperature', + 'data': averages, + 'zIndex': 1, + 'marker': { + 'lineWidth': 2, + } + }, { + 'name': 'Range', + 'data': ranges, + 'type': 'arearange', + 'lineWidth': 0, + 'linkedTo': ':previous', + 'fillOpacity': 0.3, + 'zIndex': 0 + }] + result = gizmo_plot_view.AreaRange(series=series) + # Check result + self.assertEqual(series[0]['name'], result['plot_object']['series'][0]['name']) + self.assertEqual(series[0]['data'], result['plot_object']['series'][0]['data']) + + def test_HeatMap(self): + sales_data = [ + [0, 0, 10], [0, 1, 19], [0, 2, 8], [0, 3, 24], [0, 4, 67], [1, 0, 92] + ] + series = [{ + 'name': 'Sales per employee', + 'borderWidth': 1, + 'data': sales_data, + 'dataLabels': { + 'enabled': True, + 'color': '#000000' + } + }] + + result = gizmo_plot_view.HeatMap(series=series) + # Check result + self.assertEqual(series[0]['name'], result['plot_object']['series'][0]['name']) + self.assertEqual(series[0]['data'], result['plot_object']['series'][0]['data']) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_plotly_view.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_plotly_view.py new file mode 100644 index 000000000..ba32c734c --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_plotly_view.py @@ -0,0 +1,32 @@ +import unittest +import tethys_gizmos.gizmo_options.plotly_view as gizmo_plotly_view +import plotly.graph_objs as go + + +class TestPlotlyView(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_PlotlyView(self): + trace0 = go.Scatter( + x=[1, 2, 3, 4], + y=[10, 15, 13, 17] + ) + trace1 = go.Scatter( + x=[1, 2, 3, 4], + y=[16, 5, 11, 9] + ) + plot_input = [trace0, trace1] + + result = gizmo_plotly_view.PlotlyView(plot_input) + # Check Result + self.assertIn(', '.join(str(e) for e in trace0.x), result['plotly_div']) + self.assertIn(', '.join(str(e) for e in trace0.y), result['plotly_div']) + self.assertIn(', '.join(str(e) for e in trace1.x), result['plotly_div']) + self.assertIn(', '.join(str(e) for e in trace1.y), result['plotly_div']) + + self.assertIn('.js', gizmo_plotly_view.PlotlyView.get_vendor_js()[0]) + self.assertNotIn('.css', gizmo_plotly_view.PlotlyView.get_vendor_js()[0]) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_range_slider.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_range_slider.py new file mode 100644 index 000000000..715475061 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_range_slider.py @@ -0,0 +1,29 @@ +import unittest +import tethys_gizmos.gizmo_options.range_slider as gizmo_range_slider + + +class TestRangeSlider(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_RangeSlider(self): + name = 'Test Range Slider' + min = 0 + max = 100 + initial = 50 + step = 1 + + result = gizmo_range_slider.RangeSlider(name=name, min=min, max=max, initial=initial, step=step) + + # Check Result + self.assertEqual(name, result['name']) + self.assertEqual(min, result['min']) + self.assertEqual(max, result['max']) + self.assertEqual(initial, result['initial']) + self.assertEqual(step, result['step']) + + self.assertIn('.js', gizmo_range_slider.RangeSlider.get_gizmo_js()[0]) + self.assertNotIn('.css', gizmo_range_slider.RangeSlider.get_gizmo_js()[0]) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_select_input.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_select_input.py new file mode 100644 index 000000000..7633e5bc2 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_select_input.py @@ -0,0 +1,35 @@ +import unittest +import tethys_gizmos.gizmo_options.select_input as gizmo_select_input + + +class TestSelectInput(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_SelectInput(self): + display_text = 'Select2 Multiple' + name = 'select21' + multiple = True + options = [('One', '1'), ('Two', '2'), ('Three', '3')] + initial = ['Two', 'One'] + + result = gizmo_select_input.SelectInput(name=name, display_text=display_text, multiple=multiple, + options=options, initial=initial) + + self.assertEqual(name, result['name']) + self.assertEqual(display_text, result['display_text']) + self.assertTrue(result['multiple']) + self.assertEqual(options, result['options']) + self.assertEqual(initial, result['initial']) + + self.assertIn('.js', gizmo_select_input.SelectInput.get_vendor_js()[0]) + self.assertNotIn('.css', gizmo_select_input.SelectInput.get_vendor_js()[0]) + + self.assertIn('.css', gizmo_select_input.SelectInput.get_vendor_css()[0]) + self.assertNotIn('.js', gizmo_select_input.SelectInput.get_vendor_css()[0]) + + self.assertIn('.js', gizmo_select_input.SelectInput.get_gizmo_js()[0]) + self.assertNotIn('.css', gizmo_select_input.SelectInput.get_gizmo_js()[0]) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_table_view.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_table_view.py new file mode 100644 index 000000000..a9b970dd4 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_table_view.py @@ -0,0 +1,21 @@ +import unittest +import tethys_gizmos.gizmo_options.table_view as gizmo_table_view + + +class TestTableView(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_TableView(self): + rows = [('Bill', '30', 'contractor'), + ('Fred', '18', 'programmer'), + ('Bob', '26', 'boss')] + column_names = ['Name', 'Age', 'Job'] + + result = gizmo_table_view.TableView(rows=rows, column_names=column_names) + + self.assertEqual(rows, result['rows']) + self.assertEqual(column_names, result['column_names']) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_text_input.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_text_input.py new file mode 100644 index 000000000..0497075da --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_text_input.py @@ -0,0 +1,26 @@ +import unittest +import tethys_gizmos.gizmo_options.text_input as gizmo_text_input + + +class TestTextInput(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_TextInput(self): + display_text = 'Text Error' + name = 'inputEmail' + initial = 'bob@example.com' + icon_append = 'glyphicon glyphicon-envelope' + error = 'Here is my error text' + + result = gizmo_text_input.TextInput(name=name, display_text=display_text, initial=initial, + icon_append=icon_append, error=error) + # Check Result + self.assertEqual(display_text, result['display_text']) + self.assertEqual(name, result['name']) + self.assertEqual(initial, result['initial']) + self.assertEqual(icon_append, result['icon_append']) + self.assertEqual(error, result['error']) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_toogle_switch.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_toogle_switch.py new file mode 100644 index 000000000..f67b64844 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_toogle_switch.py @@ -0,0 +1,41 @@ +import unittest +import tethys_gizmos.gizmo_options.toggle_switch as gizmo_toggle_switch + + +class TestToggleSwitch(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_ToggleSwitch(self): + display_text = 'Styled Toggle' + name = 'toggle2' + on_label = 'Yes' + off_label = 'No' + on_style = 'success' + off_style = 'danger' + initial = True + size = 'large' + + result = gizmo_toggle_switch.ToggleSwitch(name=name, display_text=display_text, on_label=on_label, + off_label=off_label, on_style=on_style, off_style=off_style, + initial=initial, size=size) + # Check Result + self.assertEqual(display_text, result['display_text']) + self.assertEqual(name, result['name']) + self.assertEqual(on_label, result['on_label']) + self.assertEqual(off_label, result['off_label']) + self.assertEqual(on_style, result['on_style']) + self.assertTrue(result['initial']) + self.assertEqual(size, result['size']) + + self.assertIn('.js', gizmo_toggle_switch.ToggleSwitch.get_vendor_js()[0]) + self.assertNotIn('.css', gizmo_toggle_switch.ToggleSwitch.get_vendor_js()[0]) + + self.assertIn('.css', gizmo_toggle_switch.ToggleSwitch.get_vendor_css()[0]) + self.assertNotIn('.js', gizmo_toggle_switch.ToggleSwitch.get_vendor_css()[0]) + + self.assertIn('.js', gizmo_toggle_switch.ToggleSwitch.get_gizmo_js()[0]) + self.assertNotIn('.css', gizmo_toggle_switch.ToggleSwitch.get_gizmo_js()[0]) diff --git a/tests/unit_tests/test_tethys_gizmos/test_templatetags/__init__.py b/tests/unit_tests/test_tethys_gizmos/test_templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py b/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py new file mode 100644 index 000000000..c6ddfd81e --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py @@ -0,0 +1,330 @@ +import mock +import unittest +import tethys_gizmos.templatetags.tethys_gizmos as gizmos_templatetags +from tethys_gizmos.gizmo_options.base import TethysGizmoOptions +from datetime import datetime, date +from django.template import base +from django.template import TemplateSyntaxError +from django.template import Context +try: + reload +except NameError: # Python 3 + from imp import reload + + +class TestGizmo(TethysGizmoOptions): + + gizmo_name = 'test_gizmo' + + def __init__(self, name, *args, **kwargs): + super(TestGizmo, self).__init__(*args, **kwargs) + self.name = name + + @staticmethod + def get_vendor_js(): + return ('tethys_gizmos/vendor/openlayers/ol.js',) + + @staticmethod + def get_gizmo_js(): + return ('tethys_gizmos/js/plotly-load_from_python.js',) + + @staticmethod + def get_vendor_css(): + return ('tethys_gizmos/vendor/openlayers/ol.css',) + + @staticmethod + def get_gizmo_css(): + return ('tethys_gizmos/css/tethys_map_view.min.css',) + + +class TestTethysGizmos(unittest.TestCase): + def setUp(self): + self.gizmo_name = 'tethysext.test_extension' + pass + + def tearDown(self): + pass + + @mock.patch('tethys_apps.harvester.SingletonHarvester') + def test_TestTethysGizmos(self, mock_harvest): + mock_harvest().extension_modules = {'Test Extension': 'tethysext.test_extension'} + gizmos_templatetags.EXTENSION_PATH_MAP = {} + reload(gizmos_templatetags) + self.assertIn('custom_select_input', gizmos_templatetags.EXTENSION_PATH_MAP) + + @mock.patch('tethys_apps.harvester.SingletonHarvester') + def test_TestTethysGizmos_import_error(self, mock_harvest): + mock_harvest().extension_modules = {'Test Extension': 'tethysext.test_extension1'} + reload(gizmos_templatetags) + # self.assertRaises(ImportError, reload, gizmos_templatetags) + + def test_HighchartsDateEncoder(self): + result = gizmos_templatetags.HighchartsDateEncoder().default(datetime(2018, 1, 1)) + + # Timestamp should be 1514764800 + self.assertEqual(1514764800000.0, result) + + def test_HighchartsDateEncoder_no_dt(self): + result = gizmos_templatetags.HighchartsDateEncoder().default(date(2018, 1, 1)) + + # Check Result + self.assertEqual('2018-01-01', result) + + def test_isstring(self): + result = gizmos_templatetags.isstring(type('string')) + + # Check Result + self.assertTrue(result) + + result = gizmos_templatetags.isstring(type(['list'])) + + # Check Result + self.assertFalse(result) + + def test_return_item(self): + result = gizmos_templatetags.return_item(['0', '1'], 1) + + # Check Result + self.assertEqual('1', result) + + def test_return_item_none(self): + result = gizmos_templatetags.return_item(['0', '1'], 2) + + # Check Result + self.assertFalse(result) + + def test_json_date_handler(self): + result = gizmos_templatetags.json_date_handler(datetime(2018, 1, 1)) + + # Timestamp should be 1514764800 + self.assertEqual(1514764800000.0, result) + + def test_json_date_handler_no_datetime(self): + result = gizmos_templatetags.json_date_handler('2018') + + # Check Result + self.assertEqual('2018', result) + + def test_jsonify(self): + data = ['foo', {'bar': ('baz', None, 1.0, 2)}] + + result = gizmos_templatetags.jsonify(data) + + # Check Result + self.assertEqual('["foo", {"bar": ["baz", null, 1.0, 2]}]', result) + + def test_divide(self): + value = 10 + divisor = 2 + expected_result = 5 + + result = gizmos_templatetags.divide(value, divisor) + + # Check Result + self.assertEqual(expected_result, result) + + +class TestTethysGizmoIncludeDependency(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_load_gizmo_name(self): + gizmo_name = '"plotly_view"' + # _load_gizmo_name is loaded in init + result = gizmos_templatetags.TethysGizmoIncludeDependency(gizmo_name=gizmo_name) + + # Check result + self.assertEqual('plotly_view', result.gizmo_name) + + def test_load_gizmos_rendered(self): + gizmo_name = 'plotly_view' + context = {} + + # _load_gizmos_rendered is loaded in render + gizmos_templatetags.TethysGizmoIncludeDependency(gizmo_name=gizmo_name).render(context=context) + + self.assertEqual(['plotly_view'], context['gizmos_rendered']) + + @mock.patch('tethys_gizmos.templatetags.tethys_gizmos.settings') + def test_load_gizmos_rendered_syntax_error(self, mock_settings): + mock_settings.return_value = mock.MagicMock(TEMPLATE_DEBUG=True) + gizmo_name = 'plotly_view1' + context = {} + + t = gizmos_templatetags.TethysGizmoIncludeDependency(gizmo_name=gizmo_name) + + self.assertRaises(TemplateSyntaxError, t.render, context=context) + + +class TestTethysGizmoIncludeNode(unittest.TestCase): + def setUp(self): + self.gizmo_name = 'tethysext.test_extension' + pass + + def tearDown(self): + pass + + def test_render(self): + gizmos_templatetags.GIZMO_NAME_MAP[TestGizmo.gizmo_name] = TestGizmo + result = gizmos_templatetags.TethysGizmoIncludeNode(options='foo', gizmo_name=TestGizmo.gizmo_name) + + context = {'foo': TestGizmo(name='test_render')} + result_render = result.render(context) + + # Check Result + self.assertEqual('test_render', result_render) + + def test_render_no_gizmo_name(self): + result = gizmos_templatetags.TethysGizmoIncludeNode(options='foo', gizmo_name=None) + + context = {'foo': TestGizmo(name='test_render_no_name')} + result_render = result.render(context) + + # Check Result + self.assertEqual('test_render_no_name', result_render) + + @mock.patch('tethys_gizmos.templatetags.tethys_gizmos.get_template') + def test_render_in_extension_path(self, mock_gt): + # Reset EXTENSION_PATH_MAP + gizmos_templatetags.EXTENSION_PATH_MAP = {TestGizmo.gizmo_name: 'tethys_gizmos'} + mock_gt.return_value = mock.MagicMock() + result = gizmos_templatetags.TethysGizmoIncludeNode(options='foo', gizmo_name=TestGizmo.gizmo_name) + context = Context({'foo': TestGizmo(name='test_render')}) + result.render(context) + + # Check Result + mock_gt.assert_called_with('tethys_gizmos/templates/gizmos/test_gizmo.html') + + # We need to delete this extension path map to avoid template not exist error on the + # previous test + del gizmos_templatetags.EXTENSION_PATH_MAP[TestGizmo.gizmo_name] + + @mock.patch('tethys_gizmos.templatetags.tethys_gizmos.settings') + @mock.patch('tethys_gizmos.templatetags.tethys_gizmos.template') + def test_render_syntax_error_debug(self, mock_template, mock_setting): + mock_resolve = mock_template.Variable().resolve() + mock_resolve.return_value = mock.MagicMock() + del mock_resolve.gizmo_name + mock_setting.TEMPLATES = [{'OPTIONS': {'debug': True}}] + + context = Context({'foo': TestGizmo(name='test_render')}) + tgin = gizmos_templatetags.TethysGizmoIncludeNode(options='foo', gizmo_name='not_gizmo') + + self.assertRaises(TemplateSyntaxError, tgin.render, context=context) + + @mock.patch('tethys_gizmos.templatetags.tethys_gizmos.settings') + @mock.patch('tethys_gizmos.templatetags.tethys_gizmos.template') + def test_render_syntax_error_no_debug(self, mock_template, mock_setting): + mock_resolve = mock_template.Variable().resolve() + mock_resolve.return_value = mock.MagicMock() + del mock_resolve.gizmo_name + mock_setting.TEMPLATES = [{'OPTIONS': {'debug': False}}] + + context = Context({'foo': TestGizmo(name='test_render')}) + + result = gizmos_templatetags.TethysGizmoIncludeNode(options='foo', gizmo_name=TestGizmo.gizmo_name) + self.assertEqual('', result.render(context=context)) + + +class TestTags(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_gizmos.templatetags.tethys_gizmos.TethysGizmoIncludeNode') + def test_gizmo(self, mock_tgin): + token1 = base.Token(token_type='TOKEN_TEXT', contents='token test_options') + gizmos_templatetags.gizmo(parser='', token=token1) + + # Check Result + mock_tgin.assert_called_with('test_options', None) + + token2 = base.Token(token_type='TOKEN_TEXT', contents='token test_gizmo_name test_options') + gizmos_templatetags.gizmo(parser='', token=token2) + + # Check Result + mock_tgin.assert_called_with('test_options', 'test_gizmo_name') + + def test_gizmo_syntax_error(self): + token = base.Token(token_type='TOKEN_TEXT', contents='token') + + # Check Error + self.assertRaises(TemplateSyntaxError, gizmos_templatetags.gizmo, parser='', token=token) + + @mock.patch('tethys_gizmos.templatetags.tethys_gizmos.TethysGizmoIncludeDependency') + def test_import_gizmo_dependency(self, mock_tgid): + token = base.Token(token_type='TOKEN_TEXT', contents='test_tag_name test_gizmo_name') + + gizmos_templatetags.import_gizmo_dependency(parser='', token=token) + # Check Result + mock_tgid.assert_called_with('test_gizmo_name') + + def test_import_gizmo_dependency_syntax_error(self): + token = base.Token(token_type='TOKEN_TEXT', contents='token') + + self.assertRaises(TemplateSyntaxError, gizmos_templatetags.import_gizmo_dependency, + parser='', token=token) + + @mock.patch('tethys_gizmos.templatetags.tethys_gizmos.TethysGizmoDependenciesNode') + def test_gizmo_dependencies(self, mock_tgdn): + token = base.Token(token_type='TOKEN_TEXT', contents='token "css"') + gizmos_templatetags.gizmo_dependencies(parser='', token=token) + + # Check Result + mock_tgdn.assert_called_with('css') + + def test_gizmo_dependencies_syntax_error(self): + token = base.Token(token_type='TOKEN_TEXT', contents='token css js') + self.assertRaises(TemplateSyntaxError, gizmos_templatetags.gizmo_dependencies, + parser='', token=token) + + def test_gizmo_dependencies_not_valid(self): + token = base.Token(token_type='TOKEN_TEXT', contents='token css1') + self.assertRaises(TemplateSyntaxError, gizmos_templatetags.gizmo_dependencies, + parser='', token=token) + + +class TestTethysGizmoDependenciesNode(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_render(self): + gizmos_templatetags.GIZMO_NAME_MAP[TestGizmo.gizmo_name] = TestGizmo + output_global_css = 'global_css' + output_css = 'css' + output_global_js = 'global_js' + output_js = 'js' + result = gizmos_templatetags.TethysGizmoDependenciesNode(output_type=output_global_css) + + # Check result + self.assertEqual(output_global_css, result.output_type) + + # TEST render + context = Context({'foo': TestGizmo(name='test_render')}) + context.update({'gizmos_rendered': []}) + + # unless it has the same gizmo name as the predefined one + render_globalcss = gizmos_templatetags.TethysGizmoDependenciesNode(output_type=output_global_css).\ + render(context=context) + render_css = gizmos_templatetags.TethysGizmoDependenciesNode(output_type=output_css).\ + render(context=context) + render_globaljs = gizmos_templatetags.TethysGizmoDependenciesNode(output_type=output_global_js).\ + render(context=context) + render_js = gizmos_templatetags.TethysGizmoDependenciesNode(output_type=output_js).\ + render(context=context) + + self.assertIn('openlayers/ol.css', render_globalcss) + self.assertNotIn('tethys_gizmos.css', render_globalcss) + self.assertIn('tethys_gizmos.css', render_css) + self.assertNotIn('openlayers/ol.css', render_css) + self.assertIn('openlayers/ol.js', render_globaljs) + self.assertIn('plotly-load_from_python.js', render_js) + self.assertNotIn('openlayers/ol.js', render_js) diff --git a/tests/unit_tests/test_tethys_gizmos/test_views/__init__.py b/tests/unit_tests/test_tethys_gizmos/test_views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_gizmos/test_views/test_gizmo_showcase.py b/tests/unit_tests/test_tethys_gizmos/test_views/test_gizmo_showcase.py new file mode 100644 index 000000000..a397db3f0 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_views/test_gizmo_showcase.py @@ -0,0 +1,120 @@ +import unittest +import tethys_gizmos.views.gizmo_showcase as gizmo_showcase +from requests.exceptions import ConnectionError +import mock +from django.test import RequestFactory +from tests.factories.django_user import UserFactory + + +class TestGizmoShowcase(unittest.TestCase): + def setUp(self): + self.user = UserFactory() + self.request_factory = RequestFactory() + + def tearDown(self): + pass + + @mock.patch('tethys_gizmos.views.gizmo_showcase.list_spatial_dataset_engines') + def test_get_geoserver_wms(self, mock_list_sdes): + endpoint = 'http://localhost:8080/geoserver/rest' + expected_endpoint = 'http://localhost:8080/geoserver/wms' + mock_sde = mock.MagicMock(type='GEOSERVER', + endpoint=endpoint) + mock_list_sdes.return_value = [mock_sde] + result = gizmo_showcase.get_geoserver_wms() + + # Check Result + self.assertEqual(expected_endpoint, result) + + @mock.patch('tethys_gizmos.views.gizmo_showcase.list_spatial_dataset_engines') + def test_get_geoserver_wms_connection_error(self, mock_list_sdes): + # Connection Error Case + endpoint = 'http://localhost:8080/geoserver/rest' + expected_endpoint = 'http://ciwmap.chpc.utah.edu:8080/geoserver/wms' + mock_sde = mock.MagicMock(type='GEOSERVER', + endpoint=endpoint) + mock_sde.validate.side_effect = ConnectionError + mock_list_sdes.return_value = [mock_sde] + result = gizmo_showcase.get_geoserver_wms() + + # Check Result + self.assertEqual(expected_endpoint, result) + + def test_index(self): + request = self.request_factory.post('/jobs', {'editable_map_submit': '1', 'geometry': '[100, 40]'}) + request.user = self.user + result = gizmo_showcase.index(request) + + self.assertEqual(200, result.status_code) + + def test_get_kml(self): + request = self.request_factory + result = gizmo_showcase.get_kml(request) + + self.assertIn('kml_link', result._container[0].decode()) + self.assertEqual(200, result.status_code) + + def test_swap_kml(self): + request = self.request_factory + result = gizmo_showcase.swap_kml(request) + + self.assertIn('.kml', result._container[0].decode()) + self.assertEqual(200, result.status_code) + + def test_swap_overlays(self): + request = self.request_factory + result = gizmo_showcase.swap_overlays(request) + + self.assertIn('"type": "GeometryCollection"', result._container[0].decode()) + self.assertEqual(200, result.status_code) + + @mock.patch('tethys_gizmos.views.gizmo_showcase.messages') + def test_google_map_view(self, mock_messages): + mock_mi = mock_messages.info + request = self.request_factory.post('/jobs', {'editable_map_submit': '1', 'geometry': '[100, 40]'}) + request.user = self.user + # Need this to fix the You cannot add messages without installing + # django.contrib.messages.middleware.MessageMiddleware + result = gizmo_showcase.google_map_view(request) + + # Check result + mock_mi.assert_called_with(request, '[100, 40]') + self.assertEqual(200, result.status_code) + + @mock.patch('tethys_gizmos.views.gizmo_showcase.messages') + def test_map_view(self, mock_messages): + mock_mi = mock_messages.info + request = self.request_factory.post('/jobs', {'editable_map_submit': '1', 'geometry': '[100, 40]'}) + request.user = self.user + # Need this to fix the You cannot add messages without installing + # django.contrib.messages.middleware.MessageMiddleware + result = gizmo_showcase.map_view(request) + + # Check result + mock_mi.assert_called_with(request, '[100, 40]') + self.assertEqual(200, result.status_code) + + def test_esri_map(self): + request = self.request_factory.post('/jobs', {'editable_map_submit': '1', 'geometry': '[100, 40]'}) + request.user = self.user + result = gizmo_showcase.esri_map(request) + + self.assertEqual(200, result.status_code) + + def test_jobs_table_result(self): + request = self.request_factory.post('/jobs', {'editable_map_submit': '1', 'geometry': '[100, 40]'}) + request.user = self.user + result = gizmo_showcase.jobs_table_results(request=request, job_id='1') + + self.assertEqual(302, result.status_code) + + @mock.patch('tethys_gizmos.views.gizmo_showcase.BasicJob') + def test_create_sample_jobs(self, mock_bj): + mock_bj().return_value = mock.MagicMock() + request = self.request_factory + request.user = 'test_user' + gizmo_showcase.create_sample_jobs(request) + + # Check BasicJob Call + mock_bj.assert_called_with(_status='VCP', description='Completed multi-process job with some errors', + label='gizmos_showcase', name='job_8', user='test_user') diff --git a/tests/unit_tests/test_tethys_gizmos/test_views/test_gizmos/__init__.py b/tests/unit_tests/test_tethys_gizmos/test_views/test_gizmos/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_gizmos/test_views/test_gizmos/test_jobs_table.py b/tests/unit_tests/test_tethys_gizmos/test_views/test_gizmos/test_jobs_table.py new file mode 100644 index 000000000..8b16d4d09 --- /dev/null +++ b/tests/unit_tests/test_tethys_gizmos/test_views/test_gizmos/test_jobs_table.py @@ -0,0 +1,144 @@ +import unittest +import tethys_gizmos.views.gizmos.jobs_table as gizmo_jobs_table +import mock +from django.test import RequestFactory + + +class TestJobsTable(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.TethysJob.objects.get_subclass') + def test_execute(self, mock_tj): + tj = mock_tj() + tj.execute.return_value = mock.MagicMock() + + result = gizmo_jobs_table.execute(request='', job_id='1') + + self.assertEqual(200, result.status_code) + + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.log') + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.TethysJob') + def test_execute_exception(self, mock_tj, mock_log): + tj = mock_tj.objects.get_subclass() + tj.execute.side_effect = Exception('error') + + gizmo_jobs_table.execute(request='', job_id='1') + + mock_log.error.assert_called_with('The following error occurred when executing job %s: %s', '1', 'error') + + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.TethysJob') + def test_delete(self, mock_tj): + tj = mock_tj.objects.get_subclass() + tj.delete.return_value = mock.MagicMock() + + result = gizmo_jobs_table.delete(request='', job_id='1') + + self.assertEqual(200, result.status_code) + + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.log') + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.TethysJob') + def test_delete_exception(self, mock_tj, mock_log): + tj = mock_tj.objects.get_subclass() + tj.delete.side_effect = Exception('error') + + gizmo_jobs_table.delete(request='', job_id='1') + + mock_log.error.assert_called_with('The following error occurred when deleting job %s: %s', '1', 'error') + + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.render_to_string') + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.TethysJob') + def test_update_row(self, mock_tj, mock_rts): + mock_rts.return_value = '{"job_statuses":[]}' + mock_tj.objects.get_subclass.return_value = mock.MagicMock(status='Various', label='gizmos_showcase') + rows = [('1', '30')] + column_names = ['ID', 'Time(s)'] + request = RequestFactory().post('/jobs', {'column_fields': column_names, 'row': rows}) + result = gizmo_jobs_table.update_row(request, job_id='1') + + # Check Result + rts_call_args = mock_rts.call_args_list + self.assertIn('job_statuses', rts_call_args[0][0][1]) + self.assertEqual({'Completed': 40, 'Error': 10, 'Running': 30, 'Aborted': 5}, + rts_call_args[0][0][1]['job_statuses']) + self.assertEqual(200, result.status_code) + + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.render_to_string') + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.TethysJob') + def test_update_row_not_gizmos(self, mock_tj, mock_rts): + # Another Case where job.label is not gizmos_showcase + mock_rts.return_value = '{"job_statuses":[]}' + mock_tj.objects.get_subclass.return_value = mock.MagicMock(status='Various', label='test_label', + statuses={'Completed': 1}) + rows = [('1', '30')] + column_names = ['ID', 'Time(s)'] + request = RequestFactory().post('/jobs', {'column_fields': column_names, 'row': rows}) + result = gizmo_jobs_table.update_row(request, job_id='1') + + # Check Result + rts_call_args = mock_rts.call_args_list + self.assertIn('job_statuses', rts_call_args[0][0][1]) + self.assertEqual({'Completed': 1}, rts_call_args[0][0][1]['job_statuses']) + self.assertEqual(200, result.status_code) + + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.log') + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.TethysJob') + def test_update_row_exception(self, mock_tj, mock_log): + mock_tj.objects.get_subclass.side_effect = Exception('error') + rows = [('1', '30'), + ('2', '18'), + ('3', '26')] + column_names = ['ID', 'Time(s)'] + request = RequestFactory().post('/jobs', {'column_fields': column_names, 'row': rows}) + gizmo_jobs_table.update_row(request, job_id='1') + + # Check Result + mock_log.error.assert_called_with('The following error occurred when updating row for job %s: %s', '1', + str('error')) + + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.TethysJob') + def test_update_status(self, mock_tj): + mock_tj.objects.get_subclass.return_value = mock.MagicMock(status='Various', label='gizmos_showcase') + rows = [('1', '30'), + ('2', '18'), + ('3', '26')] + column_names = ['ID', 'Time(s)'] + request = RequestFactory().post('/jobs', {'column_fields': column_names, 'row': rows}) + result = gizmo_jobs_table.update_status(request, job_id='1') + + # Check Result + self.assertEqual(200, result.status_code) + + # Another Case + mock_tj.objects.get_subclass.return_value = mock.MagicMock(status='Various', label='test_label') + result = gizmo_jobs_table.update_status(request, job_id='1') + + # Check Result + self.assertEqual(200, result.status_code) + + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.log') + @mock.patch('tethys_gizmos.views.gizmos.jobs_table.TethysJob') + def test_update_status_exception(self, mock_tj, mock_log): + mock_tj.objects.get_subclass.side_effect = Exception('error') + rows = [('1', '30'), + ('2', '18'), + ('3', '26')] + column_names = ['ID', 'Time(s)'] + request = RequestFactory().post('/jobs', {'column_fields': column_names, 'row': rows}) + gizmo_jobs_table.update_status(request, job_id='1') + + mock_log.error.assert_called_with('The following error occurred when updating status for job %s: %s', '1', + str('error')) + + def test_parse_value(self): + result = gizmo_jobs_table._parse_value('True') + self.assertTrue(result) + + result = gizmo_jobs_table._parse_value('False') + self.assertFalse(result) + + result = gizmo_jobs_table._parse_value('Test') + self.assertEqual('Test', result) diff --git a/tests/unit_tests/test_tethys_portal/__init__.py b/tests/unit_tests/test_tethys_portal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_portal/test_forms.py b/tests/unit_tests/test_tethys_portal/test_forms.py new file mode 100644 index 000000000..7228a677c --- /dev/null +++ b/tests/unit_tests/test_tethys_portal/test_forms.py @@ -0,0 +1,244 @@ +from django.test import TestCase +from captcha.models import CaptchaStore +from tethys_portal.forms import LoginForm, RegisterForm, UserSettingsForm, UserPasswordChangeForm +from django.contrib.auth.models import User +from django import forms +import mock + + +class TethysPortalFormsTests(TestCase): + + def setUp(self): + CaptchaStore.generate_key() + self.hashkey = CaptchaStore.objects.all()[0].hashkey + self.response = CaptchaStore.objects.all()[0].response + self.user = User.objects.create_user(username='user_exist', + email='foo_exist@aquaveo.com', + password='glass_onion') + + def tearDown(self): + pass + # Login Form + + def test_LoginForm(self): + login_data = {'username': 'admin', 'password': 'test1231', 'captcha_0': self.hashkey, + 'captcha_1': self.response} + login_form = LoginForm(login_data) + self.assertTrue(login_form.is_valid()) + + def test_LoginForm_invalid_username(self): + login_data = {'username': '$!admin', 'password': 'test1231', 'captcha_0': self.hashkey, + 'captcha_1': self.response} + login_form = LoginForm(login_data) + err_msg = "This value may contain only letters, numbers and @/./+/-/_ characters." + self.assertEquals(login_form.errors['username'], [err_msg]) + self.assertFalse(login_form.is_valid()) + + def test_LoginForm_invalid_password(self): + login_data = {'username': 'admin', 'password': '', 'captcha_0': self.hashkey, + 'captcha_1': self.response} + login_form = LoginForm(login_data) + self.assertFalse(login_form.is_valid()) + + def test_LoginForm_invalid(self): + login_invalid_data = {'username': 'admin', 'password': 'test1231', 'captcha_0': self.hashkey, + 'captcha_1': ''} + login_form = LoginForm(login_invalid_data) + self.assertFalse(login_form.is_valid()) + + # Register Form + + def test_RegisterForm(self): + register_data = {'username': 'user1', 'email': 'foo@aquaveo.com', 'password1': 'abc123', + 'password2': 'abc123', 'captcha_0': self.hashkey, 'captcha_1': self.response} + register_form = RegisterForm(data=register_data) + self.assertTrue(register_form.is_valid()) + + def test_RegisterForm_invalid_user(self): + register_data = {'username': 'user1&!$', 'email': 'foo@aquaveo.com', 'password1': 'abc123', + 'password2': 'abc123', 'captcha_0': self.hashkey, 'captcha_1': self.response} + register_form = RegisterForm(data=register_data) + err_msg = "This value may contain only letters, numbers and @/./+/-/_ characters." + self.assertEquals(register_form.errors['username'], [err_msg]) + self.assertFalse(register_form.is_valid()) + + def test_RegisterForm_clean_username(self): + register_data = {'username': 'user', 'email': 'foo@aquaveo.com', 'password1': 'abc123', + 'password2': 'abc123', 'captcha_0': self.hashkey, 'captcha_1': self.response} + + register_form = RegisterForm(data=register_data) + + self.assertTrue(register_form.is_valid()) + + ret = register_form.clean_username() + + self.assertEquals('user', ret) + + def test_RegisterForm_clean_username_dup(self): + register_data = {'username': 'user_exist', 'email': 'foo@aquaveo.com', 'password1': 'abc123', + 'password2': 'abc123', 'captcha_0': self.hashkey, 'captcha_1': self.response} + + register_form = RegisterForm(data=register_data) + + # validate form, false because duplicated user + self.assertFalse(register_form.is_valid()) + + # user is duplicated so is_valid removed from cleaned_data, we add it back to test + register_form.cleaned_data['username'] = 'user_exist' + + self.assertRaises(forms.ValidationError, register_form.clean_username) + + def test_RegisterForm_clean_email(self): + register_data = {'username': 'user1', 'email': 'foo@aquaveo.com', 'password1': 'abc123', + 'password2': 'abc123', 'captcha_0': self.hashkey, 'captcha_1': self.response} + + register_form = RegisterForm(data=register_data) + + self.assertTrue(register_form.is_valid()) + + ret = register_form.clean_email() + + self.assertEquals('foo@aquaveo.com', ret) + + def test_RegisterForm_clean_email_dup(self): + register_data = {'username': 'user12', 'email': 'foo_exist@aquaveo.com', 'password1': 'abc123', + 'password2': 'abc123', 'captcha_0': self.hashkey, 'captcha_1': self.response} + + register_form = RegisterForm(data=register_data) + + register_form.is_valid() + + # is_valid is removing duplicated email + self.assertNotIn('email', register_form.cleaned_data) + + # To test raise error, we need to put it back in to test + register_form.cleaned_data['email'] = 'foo_exist@aquaveo.com' + + self.assertRaises(forms.ValidationError, register_form.clean_email) + + @mock.patch('tethys_portal.forms.validate_password') + def test_RegisterForm_clean_password2(self, mock_vp): + register_data = {'username': 'user1', 'email': 'foo@aquaveo.com', 'password1': 'abc123', + 'password2': 'abc123', 'captcha_0': self.hashkey, 'captcha_1': self.response} + + register_form = RegisterForm(data=register_data) + + # Check if form is valid and to generate cleaned_data + self.assertTrue(register_form.is_valid()) + + ret = register_form.clean_password2() + + mock_vp.assert_called_with('abc123') + + self.assertEquals('abc123', ret) + + def test_RegisterForm_clean_password2_diff(self): + register_data = {'username': 'user1', 'email': 'foo@aquaveo.com', 'password1': 'abcd123', + 'password2': 'abc123', 'captcha_0': self.hashkey, 'captcha_1': self.response} + + register_form = RegisterForm(data=register_data) + + # use is_valid to get cleaned_data attributes + self.assertFalse(register_form.is_valid()) + + # is_valid removed cleaned_data password2, need to update + register_form.cleaned_data['password2'] = 'abc123' + + self.assertRaises(forms.ValidationError, register_form.clean_password2) + + def test_RegisterForm_save(self): + register_data = {'username': 'user1', 'email': 'foo@aquaveo.com', 'password1': 'abc123', + 'password2': 'abc123', 'captcha_0': self.hashkey, 'captcha_1': self.response} + + register_form = RegisterForm(data=register_data) + + ret = register_form.save() + + # Also try to get from database after it's saved + ret_database = User.objects.get(username='user1') + + # Check result + self.assertIsInstance(ret, User) + self.assertIsInstance(ret_database, User) + self.assertEqual('user1', ret.username) + self.assertEqual('user1', ret_database.username) + + def test_UserSettingsForm(self): + user_settings_data = {'first_name': 'fname', 'last_name': 'lname', 'email': 'user@aquaveo.com'} + user_settings_form = UserSettingsForm(data=user_settings_data) + self.assertTrue(user_settings_form.is_valid()) + + # UserPasswordChange Form + + def test_UserPasswordChangeForm_valid(self): + user_password_change_data = {'old_password': 'glass_onion', 'new_password1': 'pass2', 'new_password2': 'pass2'} + user_password_change_form = UserPasswordChangeForm(self.user, data=user_password_change_data) + self.assertTrue(user_password_change_form.is_valid()) + + def test_UserPasswordChangeForm_clean_old_password(self): + user_password_change_data = {'old_password': 'glass_onion', 'new_password1': 'pass2', 'new_password2': 'pass2'} + user_password_change_form = UserPasswordChangeForm(self.user, data=user_password_change_data) + + self.assertTrue(user_password_change_form.is_valid()) + + ret = user_password_change_form.clean_old_password() + + self.assertEqual('glass_onion', ret) + + def test_UserPasswordChangeForm_clean_old_password_invalid(self): + user_password_change_data = {'old_password': 'abc123', 'new_password1': 'pass2', 'new_password2': 'pass2'} + user_password_change_form = UserPasswordChangeForm(self.user, data=user_password_change_data) + + # is_valid to get cleaned_data + self.assertFalse(user_password_change_form.is_valid()) + + # is_valid removes old_password, add it back for testing + user_password_change_form.cleaned_data['old_password'] = 'abc123' + + self.assertRaises(forms.ValidationError, user_password_change_form.clean_old_password) + + @mock.patch('tethys_portal.forms.validate_password') + def test_UserPasswordChangeForm_clean_new_password2(self, mock_vp): + user_password_change_data = {'old_password': 'glass_onion', 'new_password1': 'pass2', 'new_password2': 'pass2'} + user_password_change_form = UserPasswordChangeForm(self.user, data=user_password_change_data) + + self.assertTrue(user_password_change_form.is_valid()) + + ret = user_password_change_form.clean_new_password2() + self.assertEqual('pass2', ret) + + mock_vp.assert_called_with('pass2') + + def test_UserPasswordChangeForm_clean_new_password2_diff(self): + user_password_change_data = {'old_password': 'glass_onion', 'new_password1': 'pass1', 'new_password2': 'pass2'} + user_password_change_form = UserPasswordChangeForm(self.user, data=user_password_change_data) + + # run is_valid to get cleaned_data + self.assertFalse(user_password_change_form.is_valid()) + + # is_valid removes new_password2 because it's different from pass1, we update here to run the test + user_password_change_form.cleaned_data['new_password2'] = 'pass2' + + self.assertRaises(forms.ValidationError, user_password_change_form.clean_new_password2) + + def test_UserPasswordChangeForm_save(self): + # password hash before save + ret_old = User.objects.get(username='user_exist') + old_pass = ret_old.password + + # Update new password + user_password_change_data = {'old_password': 'glass_onion', 'new_password1': 'pass2', 'new_password2': 'pass2'} + user_password_change_form = UserPasswordChangeForm(self.user, data=user_password_change_data) + + # run is_valid to get cleaned_data attributes. + self.assertTrue(user_password_change_form.is_valid()) + + user_password_change_form.save() + + # Also try to get from database after it's saved + ret_new = User.objects.get(username='user_exist') + new_pass = ret_new.password + + # Check result + self.assertIsInstance(ret_new, User) + self.assertNotEqual(old_pass, new_pass) diff --git a/tests/unit_tests/test_tethys_portal/test_middleware.py b/tests/unit_tests/test_tethys_portal/test_middleware.py new file mode 100644 index 000000000..b90922bb5 --- /dev/null +++ b/tests/unit_tests/test_tethys_portal/test_middleware.py @@ -0,0 +1,267 @@ +import unittest +import mock + +from tethys_portal.middleware import TethysSocialAuthExceptionMiddleware + + +class TethysPortalMiddlewareTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_portal.middleware.isinstance') + @mock.patch('tethys_portal.middleware.hasattr') + @mock.patch('tethys_portal.middleware.redirect') + def test_process_exception_anonymous_user(self, mock_redirect, mock_hasattr, mock_isinstance): + mock_request = mock.MagicMock() + mock_exception = mock.MagicMock() + mock_hasattr.return_value = True + mock_isinstance.return_value = True + mock_request.user.is_anonymous = True + + obj = TethysSocialAuthExceptionMiddleware() + obj.process_exception(mock_request, mock_exception) + + mock_redirect.assert_called_once_with('accounts:login') + + @mock.patch('tethys_portal.middleware.isinstance') + @mock.patch('tethys_portal.middleware.hasattr') + @mock.patch('tethys_portal.middleware.redirect') + def test_process_exception_user(self, mock_redirect, mock_hasattr, mock_isinstance): + mock_request = mock.MagicMock() + mock_exception = mock.MagicMock() + mock_hasattr.return_value = True + mock_isinstance.return_value = True + mock_request.user.is_anonymous = False + mock_request.user.username = 'foo' + + obj = TethysSocialAuthExceptionMiddleware() + obj.process_exception(mock_request, mock_exception) + + mock_redirect.assert_called_once_with('user:settings', username='foo') + + @mock.patch('tethys_portal.middleware.pretty_output') + @mock.patch('tethys_portal.middleware.messages.success') + @mock.patch('tethys_portal.middleware.isinstance') + @mock.patch('tethys_portal.middleware.hasattr') + @mock.patch('tethys_portal.middleware.redirect') + def test_process_exception_isinstance_google(self, mock_redirect, mock_hasattr, mock_isinstance, mock_success, + mock_pretty_output): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = True + + mock_exception = mock.MagicMock() + mock_exception.backend.name = 'google' + + mock_hasattr.return_value = True + mock_isinstance.side_effect = False, True + + obj = TethysSocialAuthExceptionMiddleware() + + obj.process_exception(mock_request, mock_exception) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('google', po_call_args[0][0][0]) + + call_args = mock_success.call_args_list + + self.assertEquals(mock_request, call_args[0][0][0]) + + self.assertEquals('The Google account you tried to connect to has already been associated with another ' + 'account.', call_args[0][0][1]) + + mock_redirect.assert_called_once_with('accounts:login') + + @mock.patch('tethys_portal.middleware.pretty_output') + @mock.patch('tethys_portal.middleware.messages.success') + @mock.patch('tethys_portal.middleware.isinstance') + @mock.patch('tethys_portal.middleware.hasattr') + @mock.patch('tethys_portal.middleware.redirect') + def test_process_exception_isinstance_linkedin(self, mock_redirect, mock_hasattr, mock_isinstance, + mock_success, mock_pretty_output): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = True + + mock_exception = mock.MagicMock() + mock_exception.backend.name = 'linkedin' + + mock_hasattr.return_value = True + mock_isinstance.side_effect = False, True + + obj = TethysSocialAuthExceptionMiddleware() + + obj.process_exception(mock_request, mock_exception) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('linkedin', po_call_args[0][0][0]) + + call_args = mock_success.call_args_list + + self.assertEquals(mock_request, call_args[0][0][0]) + + self.assertEquals('The LinkedIn account you tried to connect to has already been associated with another ' + 'account.', call_args[0][0][1]) + + mock_redirect.assert_called_once_with('accounts:login') + + @mock.patch('tethys_portal.middleware.pretty_output') + @mock.patch('tethys_portal.middleware.messages.success') + @mock.patch('tethys_portal.middleware.isinstance') + @mock.patch('tethys_portal.middleware.hasattr') + @mock.patch('tethys_portal.middleware.redirect') + def test_process_exception_isinstance_hydroshare(self, mock_redirect, mock_hasattr, mock_isinstance, + mock_success, + mock_pretty_output): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = True + + mock_exception = mock.MagicMock() + mock_exception.backend.name = 'hydroshare' + + mock_hasattr.return_value = True + mock_isinstance.side_effect = False, True + + obj = TethysSocialAuthExceptionMiddleware() + + obj.process_exception(mock_request, mock_exception) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('hydroshare', po_call_args[0][0][0]) + + call_args = mock_success.call_args_list + + self.assertEquals(mock_request, call_args[0][0][0]) + + self.assertEquals('The HydroShare account you tried to connect to has already been associated with ' + 'another account.', call_args[0][0][1]) + + mock_redirect.assert_called_once_with('accounts:login') + + @mock.patch('tethys_portal.middleware.pretty_output') + @mock.patch('tethys_portal.middleware.messages.success') + @mock.patch('tethys_portal.middleware.isinstance') + @mock.patch('tethys_portal.middleware.hasattr') + @mock.patch('tethys_portal.middleware.redirect') + def test_process_exception_isinstance_facebook(self, mock_redirect, mock_hasattr, mock_isinstance, mock_success, + mock_pretty_output): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = True + + mock_exception = mock.MagicMock() + mock_exception.backend.name = 'facebook' + + mock_hasattr.return_value = True + mock_isinstance.side_effect = False, True + + obj = TethysSocialAuthExceptionMiddleware() + + obj.process_exception(mock_request, mock_exception) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('facebook', po_call_args[0][0][0]) + + call_args = mock_success.call_args_list + + self.assertEquals(mock_request, call_args[0][0][0]) + + self.assertEquals('The Facebook account you tried to connect to has already been associated with ' + 'another account.', call_args[0][0][1]) + + mock_redirect.assert_called_once_with('accounts:login') + + @mock.patch('tethys_portal.middleware.pretty_output') + @mock.patch('tethys_portal.middleware.messages.success') + @mock.patch('tethys_portal.middleware.isinstance') + @mock.patch('tethys_portal.middleware.hasattr') + @mock.patch('tethys_portal.middleware.redirect') + def test_process_exception_isinstance_social(self, mock_redirect, mock_hasattr, mock_isinstance, + mock_success, + mock_pretty_output): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = False + mock_request.user.username = 'foo' + + mock_exception = mock.MagicMock() + mock_exception.backend.name = 'social' + + mock_hasattr.return_value = True + mock_isinstance.side_effect = False, True + + obj = TethysSocialAuthExceptionMiddleware() + + obj.process_exception(mock_request, mock_exception) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEqual(1, len(po_call_args)) + self.assertIn('social', po_call_args[0][0][0]) + + call_args = mock_success.call_args_list + + self.assertEquals(mock_request, call_args[0][0][0]) + + self.assertEquals('The social account you tried to connect to has already been associated with ' + 'another account.', call_args[0][0][1]) + + mock_redirect.assert_called_once_with('user:settings', username='foo') + + @mock.patch('tethys_portal.middleware.messages.success') + @mock.patch('tethys_portal.middleware.isinstance') + @mock.patch('tethys_portal.middleware.hasattr') + @mock.patch('tethys_portal.middleware.redirect') + def test_process_exception_isinstance_exception_with_anonymous_user(self, mock_redirect, mock_hasattr, + mock_isinstance, mock_success): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = False + mock_request.user.username = 'foo' + + mock_exception = mock.MagicMock() + mock_exception.backend.name = 'social' + + mock_hasattr.return_value = True + mock_isinstance.side_effect = False, False, True + + obj = TethysSocialAuthExceptionMiddleware() + + obj.process_exception(mock_request, mock_exception) + + call_args = mock_success.call_args_list + + self.assertEquals(mock_request, call_args[0][0][0]) + + self.assertEquals('Unable to disconnect from this social account.', call_args[0][0][1]) + + mock_redirect.assert_called_once_with('user:settings', username='foo') + + @mock.patch('tethys_portal.middleware.messages.success') + @mock.patch('tethys_portal.middleware.isinstance') + @mock.patch('tethys_portal.middleware.hasattr') + @mock.patch('tethys_portal.middleware.redirect') + def test_process_exception_isinstance_exception_user(self, mock_redirect, mock_hasattr, mock_isinstance, + mock_success): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = True + + mock_exception = mock.MagicMock() + mock_exception.backend.name = 'social' + + mock_hasattr.return_value = True + mock_isinstance.side_effect = False, False, True + + obj = TethysSocialAuthExceptionMiddleware() + + obj.process_exception(mock_request, mock_exception) + + call_args = mock_success.call_args_list + + self.assertEquals(mock_request, call_args[0][0][0]) + + self.assertEquals('Unable to disconnect from this social account.', call_args[0][0][1]) + + mock_redirect.assert_called_once_with('accounts:login') diff --git a/tests/unit_tests/test_tethys_portal/test_urls.py b/tests/unit_tests/test_tethys_portal/test_urls.py new file mode 100644 index 000000000..feacf223d --- /dev/null +++ b/tests/unit_tests/test_tethys_portal/test_urls.py @@ -0,0 +1,125 @@ +from django.urls import reverse, resolve +from tethys_sdk.testing import TethysTestCase + + +class TestUrls(TethysTestCase): + def set_up(self): + pass + + def tear_down(self): + pass + + def test_account_urls_account_login(self): + url = reverse('accounts:login') + resolver = resolve(url) + self.assertEqual('/accounts/login/', url) + self.assertEqual('login_view', resolver.func.__name__) + self.assertEqual('tethys_portal.views.accounts', resolver.func.__module__) + + def test_account_urls_accounts_logout(self): + url = reverse('accounts:logout') + resolver = resolve(url) + self.assertEqual('/accounts/logout/', url) + self.assertEqual('logout_view', resolver.func.__name__) + self.assertEqual('tethys_portal.views.accounts', resolver.func.__module__) + + def test_account_urls_accounts_register(self): + url = reverse('accounts:register') + resolver = resolve(url) + self.assertEqual('/accounts/register/', url) + self.assertEqual('register', resolver.func.__name__) + self.assertEqual('tethys_portal.views.accounts', resolver.func.__module__) + + def test_account_urls_accounts_password_reset(self): + url = reverse('accounts:password_reset') + resolver = resolve(url) + self.assertEqual('/accounts/password/reset/', url) + self.assertEqual('password_reset', resolver.func.__name__) + self.assertEqual('django.contrib.auth.views', resolver.func.__module__) + + def test_account_urls_accounts_password_confirm(self): + url = reverse('accounts:password_confirm', kwargs={'uidb64': 'f00Bar', 'token': 'tok'}) + resolver = resolve(url) + self.assertEqual('/accounts/password/reset/f00Bar-tok/', url) + self.assertEqual('password_reset_confirm', resolver.func.__name__) + self.assertEqual('django.contrib.auth.views', resolver.func.__module__) + + def test_user_urls_profile(self): + url = reverse('user:profile', kwargs={'username': 'foo'}) + resolver = resolve(url) + + self.assertEqual('/user/foo/', url) + self.assertEqual('profile', resolver.func.__name__) + self.assertEqual('tethys_portal.views.user', resolver.func.__module__) + + def test_user_urls_settings(self): + url = reverse('user:settings', kwargs={'username': 'foo'}) + resolver = resolve(url) + self.assertEqual('/user/foo/settings/', url) + self.assertEqual('settings', resolver.func.__name__) + self.assertEqual('tethys_portal.views.user', resolver.func.__module__) + + def test_user_urls_change_password(self): + url = reverse('user:change_password', kwargs={'username': 'foo'}) + resolver = resolve(url) + self.assertEqual('/user/foo/change-password/', url) + self.assertEqual('change_password', resolver.func.__name__) + self.assertEqual('tethys_portal.views.user', resolver.func.__module__) + + def test_user_urls_disconnect(self): + url = reverse('user:change_password', kwargs={'username': 'foo'}) + resolver = resolve(url) + self.assertEqual('/user/foo/change-password/', url) + self.assertEqual('change_password', resolver.func.__name__) + self.assertEqual('tethys_portal.views.user', resolver.func.__module__) + + def test_user_urls_delete(self): + url = reverse('user:delete', kwargs={'username': 'foo'}) + resolver = resolve(url) + self.assertEqual('/user/foo/delete-account/', url) + self.assertEqual('delete_account', resolver.func.__name__) + self.assertEqual('tethys_portal.views.user', resolver.func.__module__) + + def test_developer_urls_developer_home(self): + url = reverse('developer_home') + resolver = resolve(url) + self.assertEqual('/developer/', url) + self.assertEqual('home', resolver.func.__name__) + self.assertEqual('tethys_portal.views.developer', resolver.func.__module__) + + def test_developer_urls_gizmos(self): + url = reverse('gizmos:showcase') + resolver = resolve(url) + self.assertEqual('/developer/gizmos/', url) + self.assertEqual('index', resolver.func.__name__) + self.assertEqual('tethys_gizmos.views.gizmo_showcase', resolver.func.__module__) + self.assertEqual('gizmos', resolver.namespaces[0]) + + def test_developer_urls_services(self): + url = reverse('services:wps_home') + resolver = resolve(url) + self.assertEqual('/developer/services/wps/', url) + self.assertEqual('wps_home', resolver.func.__name__) + self.assertEqual('tethys_services.views', resolver.func.__module__) + self.assertEqual('services', resolver.namespaces[0]) + + def test_urlpatterns_handoff_capabilities(self): + url = reverse('handoff_capabilities', kwargs={'app_name': 'foo'}) + resolver = resolve(url) + self.assertEqual('/handoff/foo/', url) + self.assertEqual('handoff_capabilities', resolver.func.__name__) + self.assertEqual('tethys_apps.views', resolver.func.__module__) + + def test_urlpatterns_handoff(self): + url = reverse('handoff', kwargs={'app_name': 'foo', 'handler_name': 'Bar'}) + resolver = resolve(url) + self.assertEqual('/handoff/foo/Bar/', url) + self.assertEqual('handoff', resolver.func.__name__) + self.assertEqual('tethys_apps.views', resolver.func.__module__) + + def test_urlpatterns_update_job_status(self): + url = reverse('update_job_status', kwargs={'job_id': 'JI001'}) + resolver = resolve(url) + self.assertEqual('/update-job-status/JI001/', url) + self.assertEqual('update_job_status', resolver.func.__name__) + self.assertEqual('tethys_apps.views', resolver.func.__module__) diff --git a/tests/unit_tests/test_tethys_portal/test_views/__init__.py b/tests/unit_tests/test_tethys_portal/test_views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py b/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py new file mode 100644 index 000000000..fb1c5fb51 --- /dev/null +++ b/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py @@ -0,0 +1,565 @@ +import unittest +import mock +from tethys_portal.views.accounts import login_view, register, logout_view, reset_confirm, reset + + +class TethysPortalViewsAccountsTest(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_portal.views.accounts.redirect') + def test_login_view_not_anonymous_user(self, mock_redirect): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = False + mock_request.user.username = 'sam' + login_view(mock_request) + mock_redirect.assert_called_once_with('user:profile', username='sam') + + @mock.patch('tethys_portal.views.accounts.login') + @mock.patch('tethys_portal.views.accounts.authenticate') + @mock.patch('tethys_portal.views.accounts.LoginForm') + @mock.patch('tethys_portal.views.accounts.redirect') + def test_login_view_post_request(self, mock_redirect, mock_login_form, mock_authenticate, mock_login): + mock_request = mock.MagicMock() + mock_request.method = 'POST' + mock_request.POST = 'login-submit' + mock_request.user.is_anonymous = True + mock_request.user.username = 'sam' + mock_request.GET = '' + + mock_form = mock.MagicMock() + mock_login_form.return_value = mock_form + + # mock validate the form + mock_form.is_valid.return_value = True + + mock_username = mock.MagicMock() + mock_password = mock.MagicMock() + mock_form.cleaned_data('username').return_value = mock_username + mock_form.cleaned_data('password').return_value = mock_password + + # mock authenticate + mock_user = mock.MagicMock() + mock_authenticate.return_value = mock_user + + # mock the password has been verified for the user + mock_user.is_active = True + + # call the login function with mock args + login_view(mock_request) + + mock_login_form.assert_called_with(mock_request.POST) + + # mock authenticate call + mock_authenticate.asset_called_with(username=mock_username, password=mock_password) + + # mock the user is valid, active, and authenticated, so login in the user + mock_login.assert_called_with(mock_request, mock_user) + + # mock redirect after logged in using next parameter or default to user profile + mock_redirect.assert_called_once_with('app_library') + + @mock.patch('tethys_portal.views.accounts.login') + @mock.patch('tethys_portal.views.accounts.authenticate') + @mock.patch('tethys_portal.views.accounts.LoginForm') + @mock.patch('tethys_portal.views.accounts.redirect') + def test_login_view_get_method_next(self, mock_redirect, mock_login_form, mock_authenticate, mock_login): + mock_request = mock.MagicMock() + mock_request.method = 'POST' + mock_request.POST = 'login-submit' + mock_request.user.is_anonymous = True + mock_request.user.username = 'sam' + mock_request.GET = {'next': 'foo'} + + mock_form = mock.MagicMock() + mock_login_form.return_value = mock_form + + # mock validate the form + mock_form.is_valid.return_value = True + + mock_username = mock.MagicMock() + mock_password = mock.MagicMock() + mock_form.cleaned_data('username').return_value = mock_username + mock_form.cleaned_data('password').return_value = mock_password + + # mock authenticate + mock_user = mock.MagicMock() + mock_authenticate.return_value = mock_user + + # mock the password has been verified for the user + mock_user.is_active = True + + # call the login function with mock args + login_view(mock_request) + + mock_login_form.assert_called_with(mock_request.POST) + + # mock authenticate call + mock_authenticate.asset_called_with(username=mock_username, password=mock_password) + + # mock the user is valid, active, and authenticated, so login in the user + mock_login.assert_called_with(mock_request, mock_user) + + # mock redirect after logged in using next parameter or default to user profile + mock_redirect.assert_called_once_with(mock_request.GET['next']) + + @mock.patch('tethys_portal.views.accounts.render') + @mock.patch('tethys_portal.views.accounts.messages') + @mock.patch('tethys_portal.views.accounts.login') + @mock.patch('tethys_portal.views.accounts.authenticate') + @mock.patch('tethys_portal.views.accounts.LoginForm') + @mock.patch('tethys_portal.views.accounts.redirect') + def test_login_view_get_method_not_active_user(self, mock_redirect, mock_login_form, mock_authenticate, mock_login, + mock_messages, mock_render): + mock_request = mock.MagicMock() + mock_request.method = 'POST' + mock_request.POST = 'login-submit' + mock_request.user.is_anonymous = True + mock_request.user.username = 'sam' + mock_request.GET = {'next': 'foo'} + + mock_form = mock.MagicMock() + mock_login_form.return_value = mock_form + + # mock validate the form + mock_form.is_valid.return_value = True + + mock_username = mock.MagicMock() + mock_password = mock.MagicMock() + mock_form.cleaned_data('username').return_value = mock_username + mock_form.cleaned_data('password').return_value = mock_password + + # mock authenticate + mock_user = mock.MagicMock() + mock_authenticate.return_value = mock_user + + # mock the password has been verified for the user + mock_user.is_active = False + + # call the login function with mock args + login_view(mock_request) + + mock_login_form.assert_called_with(mock_request.POST) + + # mock authenticate call + mock_authenticate.asset_called_with(username=mock_username, password=mock_password) + + # mock the user is valid, active, and authenticated, so login in the user + mock_login.assert_not_called() + + # mock redirect after logged in using next parameter or default to user profile + mock_redirect.assert_not_called() + + mock_messages.error.assert_called_once_with(mock_request, "Sorry, but your account has been disabled. " + "Please contact the site " + "administrator for more details.") + + context = {'form': mock_login_form(), + 'signup_enabled': False} + + mock_render.assert_called_once_with(mock_request, 'tethys_portal/accounts/login.html', context) + + @mock.patch('tethys_portal.views.accounts.render') + @mock.patch('tethys_portal.views.accounts.messages') + @mock.patch('tethys_portal.views.accounts.login') + @mock.patch('tethys_portal.views.accounts.authenticate') + @mock.patch('tethys_portal.views.accounts.LoginForm') + @mock.patch('tethys_portal.views.accounts.redirect') + def test_login_view_get_method_user_none(self, mock_redirect, mock_login_form, mock_authenticate, mock_login, + mock_messages, mock_render): + mock_request = mock.MagicMock() + mock_request.method = 'POST' + mock_request.POST = 'login-submit' + mock_request.user.is_anonymous = True + mock_request.user.username = 'sam' + mock_request.GET = {'next': 'foo'} + + mock_form = mock.MagicMock() + mock_login_form.return_value = mock_form + + # mock validate the form + mock_form.is_valid.return_value = True + + mock_username = mock.MagicMock() + mock_password = mock.MagicMock() + mock_form.cleaned_data('username').return_value = mock_username + mock_form.cleaned_data('password').return_value = mock_password + + # mock authenticate + mock_user = mock.MagicMock() + mock_authenticate.return_value = None + + # mock the password has been verified for the user + mock_user.is_active = False + + # call the login function with mock args + login_view(mock_request) + + mock_login_form.assert_called_with(mock_request.POST) + + # mock authenticate call + mock_authenticate.asset_called_with(username=mock_username, password=mock_password) + + # mock the user is valid, active, and authenticated, so login in the user + mock_login.assert_not_called() + + # mock redirect after logged in using next parameter or default to user profile + mock_redirect.assert_not_called() + + mock_messages.warning.assert_called_once_with(mock_request, "Whoops! We were not able to log you in. " + "Please check your username and " + "password and try again.") + + context = {'form': mock_login_form(), + 'signup_enabled': False} + + mock_render.assert_called_once_with(mock_request, 'tethys_portal/accounts/login.html', context) + + @mock.patch('tethys_portal.views.accounts.render') + @mock.patch('tethys_portal.views.accounts.login') + @mock.patch('tethys_portal.views.accounts.LoginForm') + @mock.patch('tethys_portal.views.accounts.redirect') + def test_login_view_wrong_method(self, mock_redirect, mock_login_form, mock_login, mock_render): + mock_request = mock.MagicMock() + mock_request.method = 'foo' + + mock_form = mock.MagicMock() + mock_login_form.return_value = mock_form + + # call the login function with mock args + login_view(mock_request) + + mock_login_form.assert_called_with() + + # mock the user is valid, active, and authenticated, so login in the user + mock_login.assert_not_called() + + # mock redirect after logged in using next parameter or default to user profile + mock_redirect.assert_not_called() + + context = {'form': mock_login_form(), + 'signup_enabled': False} + + mock_render.assert_called_once_with(mock_request, 'tethys_portal/accounts/login.html', context) + + @mock.patch('tethys_portal.views.accounts.redirect') + def test_register_not_anonymous_user(self, mock_redirect): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = False + mock_request.user.username = 'sam' + register(mock_request) + mock_redirect.assert_called_once_with('user:profile', username='sam') + + @mock.patch('tethys_portal.views.accounts.settings') + @mock.patch('tethys_portal.views.accounts.redirect') + def test_register_not_enable_open_signup(self, mock_redirect, mock_settings): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = True + mock_request.user.username = 'sam' + mock_settings.ENABLE_OPEN_SIGNUP = False + register(mock_request) + mock_redirect.assert_called_once_with('accounts:login') + + @mock.patch('tethys_portal.views.accounts.settings') + @mock.patch('tethys_portal.views.accounts.login') + @mock.patch('tethys_portal.views.accounts.authenticate') + @mock.patch('tethys_portal.views.accounts.RegisterForm') + @mock.patch('tethys_portal.views.accounts.redirect') + def test_register_post_request(self, mock_redirect, mock_register_form, mock_authenticate, mock_login, + mock_settings): + mock_request = mock.MagicMock() + mock_request.method = 'POST' + mock_request.POST = 'register-submit' + mock_request.user.is_anonymous = True + mock_request.user.username = 'sam' + mock_request.GET = '' + + mock_settings.ENABLE_OPEN_SIGNUP = True + + mock_form = mock.MagicMock() + mock_register_form.return_value = mock_form + + # mock validate the form + mock_form.is_valid.return_value = True + + mock_username = mock.MagicMock() + mock_email = mock.MagicMock() + mock_password = mock.MagicMock() + mock_form.clean_username.return_value = mock_username + mock_form.clean_email.return_value = mock_email + mock_form.clean_password2.return_value = mock_password + + # mock authenticate + mock_user = mock.MagicMock() + mock_authenticate.return_value = mock_user + + # mock the password has been verified for the user + mock_user.is_active = True + + # call the login function with mock args + register(mock_request) + + mock_form.save.assert_called_once() + + mock_register_form.assert_called_with(mock_request.POST) + + # mock authenticate call + mock_authenticate.asset_called_with(username=mock_username, password=mock_password) + + # mock the user is valid, active, and authenticated, so login in the user + mock_login.assert_called_with(mock_request, mock_user) + + # mock redirect after logged in using next parameter or default to user profile + mock_redirect.assert_called_once_with('user:profile', username=mock_user.username) + + @mock.patch('tethys_portal.views.accounts.settings') + @mock.patch('tethys_portal.views.accounts.login') + @mock.patch('tethys_portal.views.accounts.authenticate') + @mock.patch('tethys_portal.views.accounts.RegisterForm') + @mock.patch('tethys_portal.views.accounts.redirect') + def test_register_post_request_next(self, mock_redirect, mock_register_form, mock_authenticate, mock_login, + mock_settings): + mock_request = mock.MagicMock() + mock_request.method = 'POST' + mock_request.POST = 'register-submit' + mock_request.user.is_anonymous = True + mock_request.user.username = 'sam' + mock_request.GET = {'next': 'foo'} + + mock_settings.ENABLE_OPEN_SIGNUP = True + + mock_form = mock.MagicMock() + mock_register_form.return_value = mock_form + + # mock validate the form + mock_form.is_valid.return_value = True + + mock_username = mock.MagicMock() + mock_email = mock.MagicMock() + mock_password = mock.MagicMock() + mock_form.clean_username.return_value = mock_username + mock_form.clean_email.return_value = mock_email + mock_form.clean_password2.return_value = mock_password + + # mock authenticate + mock_user = mock.MagicMock() + mock_authenticate.return_value = mock_user + + # mock the password has been verified for the user + mock_user.is_active = True + + # call the login function with mock args + register(mock_request) + + mock_form.save.assert_called_once() + + mock_register_form.assert_called_with(mock_request.POST) + + # mock authenticate call + mock_authenticate.asset_called_with(username=mock_username, password=mock_password) + + # mock the user is valid, active, and authenticated, so login in the user + mock_login.assert_called_with(mock_request, mock_user) + + # mock redirect after logged in using next parameter or default to user profile + mock_redirect.assert_called_once_with(mock_request.GET['next']) + + @mock.patch('tethys_portal.views.accounts.messages') + @mock.patch('tethys_portal.views.accounts.settings') + @mock.patch('tethys_portal.views.accounts.login') + @mock.patch('tethys_portal.views.accounts.authenticate') + @mock.patch('tethys_portal.views.accounts.RegisterForm') + @mock.patch('tethys_portal.views.accounts.render') + def test_register_post_request_not_active_user(self, mock_render, mock_register_form, mock_authenticate, + mock_login, mock_settings, mock_messages): + mock_request = mock.MagicMock() + mock_request.method = 'POST' + mock_request.POST = 'register-submit' + mock_request.user.is_anonymous = True + mock_request.user.username = 'sam' + mock_request.GET = '' + + mock_settings.ENABLE_OPEN_SIGNUP = True + + mock_form = mock.MagicMock() + mock_register_form.return_value = mock_form + + # mock validate the form + mock_form.is_valid.return_value = True + + mock_username = mock.MagicMock() + mock_email = mock.MagicMock() + mock_password = mock.MagicMock() + mock_form.clean_username.return_value = mock_username + mock_form.clean_email.return_value = mock_email + mock_form.clean_password2.return_value = mock_password + + # mock authenticate + mock_user = mock.MagicMock() + mock_authenticate.return_value = mock_user + + # mock the password has been verified for the user + mock_user.is_active = False + + # call the login function with mock args + register(mock_request) + + mock_form.save.assert_called_once() + + mock_register_form.assert_called_with(mock_request.POST) + + # mock authenticate call + mock_authenticate.asset_called_with(username=mock_username, password=mock_password) + + # mock the user is valid, active, and authenticated, so login in the user + mock_login.assert_not_called() + + mock_messages.error.assert_called_once_with(mock_request, "Sorry, but your account has been disabled. " + "Please contact the site " + "administrator for more details.") + + context = {'form': mock_form} + + # mock redirect after logged in using next parameter or default to user profile + mock_render.assert_called_once_with(mock_request, 'tethys_portal/accounts/register.html', context) + + @mock.patch('tethys_portal.views.accounts.messages') + @mock.patch('tethys_portal.views.accounts.settings') + @mock.patch('tethys_portal.views.accounts.login') + @mock.patch('tethys_portal.views.accounts.authenticate') + @mock.patch('tethys_portal.views.accounts.RegisterForm') + @mock.patch('tethys_portal.views.accounts.render') + def test_register_post_request_user_none(self, mock_render, mock_register_form, mock_authenticate, + mock_login, mock_settings, mock_messages): + mock_request = mock.MagicMock() + mock_request.method = 'POST' + mock_request.POST = 'register-submit' + mock_request.user.is_anonymous = True + mock_request.user.username = 'sam' + mock_request.GET = '' + + mock_settings.ENABLE_OPEN_SIGNUP = True + + mock_form = mock.MagicMock() + mock_register_form.return_value = mock_form + + # mock validate the form + mock_form.is_valid.return_value = True + + mock_username = mock.MagicMock() + mock_email = mock.MagicMock() + mock_password = mock.MagicMock() + mock_form.clean_username.return_value = mock_username + mock_form.clean_email.return_value = mock_email + mock_form.clean_password2.return_value = mock_password + + # mock authenticate + mock_user = mock.MagicMock() + mock_authenticate.return_value = None + + # mock the password has been verified for the user + mock_user.is_active = False + + # call the login function with mock args + register(mock_request) + + mock_form.save.assert_called_once() + + mock_register_form.assert_called_with(mock_request.POST) + + # mock authenticate call + mock_authenticate.asset_called_with(username=mock_username, password=mock_password) + + # mock the user is valid, active, and authenticated, so login in the user + mock_login.assert_not_called() + + mock_messages.warning.assert_called_once_with(mock_request, "Whoops! We were not able to log you in. " + "Please check your username and " + "password and try again.") + + context = {'form': mock_form} + + # mock redirect after logged in using next parameter or default to user profile + mock_render.assert_called_once_with(mock_request, 'tethys_portal/accounts/register.html', context) + + @mock.patch('tethys_portal.views.accounts.settings') + @mock.patch('tethys_portal.views.accounts.RegisterForm') + @mock.patch('tethys_portal.views.accounts.render') + def test_register_bad_request(self, mock_render, mock_register_form, mock_settings): + mock_request = mock.MagicMock() + mock_request.method = 'FOO' + + mock_settings.ENABLE_OPEN_SIGNUP = True + + mock_form = mock.MagicMock() + + mock_register_form.return_value = mock_form + + # mock validate the form + mock_form.is_valid.return_value = True + + # call the login function with mock args + register(mock_request) + + mock_form.save.assert_not_called() + + mock_register_form.assert_called_with() + + context = {'form': mock_form} + + # mock redirect after logged in using next parameter or default to user profile + mock_render.assert_called_once_with(mock_request, 'tethys_portal/accounts/register.html', context) + + @mock.patch('tethys_portal.views.accounts.messages') + @mock.patch('tethys_portal.views.accounts.logout') + @mock.patch('tethys_portal.views.accounts.redirect') + def test_logout_view(self, mock_redirect, mock_logout, mock_messages): + mock_request = mock.MagicMock() + mock_request.user.is_anonymous = False + mock_request.user.first_name = 'foo' + mock_request.user.username = 'bar' + + mock_redirect.return_value = 'home' + + ret = logout_view(mock_request) + + self.assertEquals('home', ret) + + mock_logout.assert_called_once_with(mock_request) + + mock_messages.success.assert_called_once_with(mock_request, 'Goodbye, {0}. Come back soon!'. + format(mock_request.user.first_name)) + + mock_redirect.assert_called_once_with('home') + + @mock.patch('tethys_portal.views.accounts.reverse') + @mock.patch('tethys_portal.views.accounts.password_reset_confirm') + def test_reset_confirm(self, mock_prc, mock_reverse): + mock_request = mock.MagicMock() + mock_reverse.return_value = 'accounts:login' + mock_prc.return_value = True + ret = reset_confirm(mock_request) + self.assertTrue(ret) + mock_prc.assert_called_once_with(mock_request, + template_name='tethys_portal/accounts/password_reset/reset_confirm.html', + uidb64=None, + token=None, + post_reset_redirect='accounts:login') + + @mock.patch('tethys_portal.views.accounts.reverse') + @mock.patch('tethys_portal.views.accounts.password_reset') + def test_reset(self, mock_pr, mock_reverse): + mock_request = mock.MagicMock() + mock_reverse.return_value = 'accounts:login' + mock_pr.return_value = True + ret = reset(mock_request) + self.assertTrue(ret) + mock_pr.assert_called_once_with(mock_request, + template_name='tethys_portal/accounts/password_reset/reset_request.html', + email_template_name='tethys_portal/accounts/password_reset/reset_email.html', + subject_template_name='tethys_portal/accounts/password_reset/reset_subject.txt', + post_reset_redirect='accounts:login') diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_developer.py b/tests/unit_tests/test_tethys_portal/test_views/test_developer.py new file mode 100644 index 000000000..d2db59e2a --- /dev/null +++ b/tests/unit_tests/test_tethys_portal/test_views/test_developer.py @@ -0,0 +1,26 @@ +import unittest +import mock + +from tethys_portal.views.developer import is_staff, home + + +class TethysPortalDeveloperTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_is_staff(self): + mock_user = mock.MagicMock() + mock_user.is_staff = 'foo' + self.assertEquals('foo', is_staff(mock_user)) + + @mock.patch('tethys_portal.views.developer.render') + def test_home(self, mock_render): + mock_request = mock.MagicMock() + context = {} + mock_render.return_value = 'foo' + self.assertEquals('foo', home(mock_request)) + mock_render.assert_called_once_with(mock_request, 'tethys_portal/developer/home.html', context) diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_error.py b/tests/unit_tests/test_tethys_portal/test_views/test_error.py new file mode 100644 index 000000000..a39faf69c --- /dev/null +++ b/tests/unit_tests/test_tethys_portal/test_views/test_error.py @@ -0,0 +1,62 @@ +import unittest +import mock +from tethys_portal.views.error import handler_400, handler_403, handler_404, handler_500 + + +class TethysPortalViewsErrorTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_portal.views.error.render') + def test_handler_400(self, mock_render): + mock_request = mock.MagicMock() + mock_render.return_value = '400' + context = {'error_code': '400', + 'error_title': 'Bad Request', + 'error_message': "Sorry, but we can't process your request. Try something different.", + 'error_image': '/static/tethys_portal/images/error_500.png'} + + self.assertEquals('400', handler_400(mock_request)) + mock_render.assert_called_once_with(mock_request, 'tethys_portal/error.html', context, status=400) + + @mock.patch('tethys_portal.views.error.render') + def test_handler_403(self, mock_render): + mock_request = mock.MagicMock() + mock_render.return_value = '403' + context = {'error_code': '403', + 'error_title': 'Forbidden', + 'error_message': "We apologize, but this operation is not permitted.", + 'error_image': '/static/tethys_portal/images/error_403.png'} + + self.assertEquals('403', handler_403(mock_request)) + mock_render.assert_called_once_with(mock_request, 'tethys_portal/error.html', context, status=403) + + @mock.patch('tethys_portal.views.error.render') + def test_handler_404(self, mock_render): + mock_request = mock.MagicMock() + mock_render.return_value = '404' + context = {'error_code': '404', + 'error_title': 'Page Not Found', + 'error_message': "We are unable to find the page you requested. Please, check the address and " + "try again.", + 'error_image': '/static/tethys_portal/images/error_404.png'} + + self.assertEquals('404', handler_404(mock_request)) + mock_render.assert_called_once_with(mock_request, 'tethys_portal/error.html', context, status=404) + + @mock.patch('tethys_portal.views.error.render') + def test_handler_500(self, mock_render): + mock_request = mock.MagicMock() + mock_render.return_value = '500' + context = {'error_code': '500', + 'error_title': 'Internal Server Error', + 'error_message': "We're sorry, but we seem to have a problem. " + "Please, come back later and try again.", + 'error_image': '/static/tethys_portal/images/error_500.png'} + + self.assertEquals('500', handler_500(mock_request)) + mock_render.assert_called_once_with(mock_request, 'tethys_portal/error.html', context, status=500) diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_home.py b/tests/unit_tests/test_tethys_portal/test_views/test_home.py new file mode 100644 index 000000000..375c10c15 --- /dev/null +++ b/tests/unit_tests/test_tethys_portal/test_views/test_home.py @@ -0,0 +1,42 @@ +import unittest +import mock + +from tethys_portal.views.home import home + + +class TethysPortalHomeTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_portal.views.home.hasattr') + @mock.patch('tethys_portal.views.home.render') + @mock.patch('tethys_portal.views.home.redirect') + @mock.patch('tethys_portal.views.home.settings') + def test_home(self, mock_settings, mock_redirect, mock_render, mock_hasattr): + mock_request = mock.MagicMock() + mock_hasattr.return_value = True + mock_settings.BYPASS_TETHYS_HOME_PAGE = True + mock_redirect.return_value = 'foo' + mock_render.return_value = 'bar' + self.assertEquals('foo', home(mock_request)) + mock_render.assert_not_called() + mock_redirect.assert_called_once_with('app_library') + + @mock.patch('tethys_portal.views.home.hasattr') + @mock.patch('tethys_portal.views.home.render') + @mock.patch('tethys_portal.views.home.redirect') + @mock.patch('tethys_portal.views.home.settings') + def test_home_with_no_attribute(self, mock_settings, mock_redirect, mock_render, mock_hasattr): + mock_request = mock.MagicMock() + mock_hasattr.return_value = False + mock_settings.ENABLE_OPEN_SIGNUP = True + mock_redirect.return_value = 'foo' + mock_render.return_value = 'bar' + self.assertEquals('bar', home(mock_request)) + mock_redirect.assert_not_called() + mock_render.assert_called_once_with(mock_request, 'tethys_portal/home.html', + {"ENABLE_OPEN_SIGNUP": mock_settings.ENABLE_OPEN_SIGNUP, }) diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_receivers.py b/tests/unit_tests/test_tethys_portal/test_views/test_receivers.py new file mode 100644 index 000000000..c9b4f36b6 --- /dev/null +++ b/tests/unit_tests/test_tethys_portal/test_views/test_receivers.py @@ -0,0 +1,30 @@ +import unittest +import mock +from tethys_portal.views.receivers import create_auth_token + + +class TethysPortalReceiversTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_portal.views.receivers.Token') + def test_create_auth_token(self, mock_token): + expected_sender = 'foo' + expected_created = True + mock_instance = mock.MagicMock() + + create_auth_token(expected_sender, instance=mock_instance, created=expected_created) + mock_token.objects.create.assert_called_with(user=mock_instance) + + @mock.patch('tethys_portal.views.receivers.Token') + def test_create_auth_token_not_created(self, mock_token): + expected_sender = 'foo' + expected_created = False + mock_instance = mock.MagicMock() + + create_auth_token(expected_sender, instance=mock_instance, created=expected_created) + mock_token.objects.create.assert_not_called() diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_user.py b/tests/unit_tests/test_tethys_portal/test_views/test_user.py new file mode 100644 index 000000000..9722e6e01 --- /dev/null +++ b/tests/unit_tests/test_tethys_portal/test_views/test_user.py @@ -0,0 +1,286 @@ +import unittest +import mock +from tethys_portal.views.user import profile, settings, change_password, social_disconnect, delete_account + + +class TethysPortalUserTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_portal.views.user.render') + @mock.patch('tethys_portal.views.user.Token.objects.get_or_create') + @mock.patch('tethys_portal.views.user.User.objects.get') + def test_profile(self, mock_get_user, mock_token_get_create, mock_render): + mock_request = mock.MagicMock() + username = 'foo' + + mock_context_user = mock.MagicMock() + mock_get_user.return_value = mock_context_user + + mock_user_token = mock.MagicMock() + mock_token_created = mock.MagicMock() + mock_token_get_create.return_value = mock_user_token, mock_token_created + + expected_context = { + 'context_user': mock_context_user, + 'user_token': mock_user_token.key + } + + profile(mock_request, username) + + mock_render.assert_called_once_with(mock_request, 'tethys_portal/user/profile.html', expected_context) + + mock_get_user.assert_called_once_with(username='foo') + + mock_token_get_create.assert_called_once_with(user=mock_context_user) + + @mock.patch('tethys_portal.views.user.messages.warning') + @mock.patch('tethys_portal.views.user.redirect') + def test_settings(self, mock_redirect, mock_message_warn): + mock_request = mock.MagicMock() + username = 'foo' + mock_user = mock.MagicMock() + mock_user.username = 'sam' + mock_request.user = mock_user + + settings(mock_request, username) + + mock_message_warn.assert_called_once_with(mock_request, "You are not allowed to change other users' settings.") + mock_redirect.assert_called_once_with('user:profile', username='sam') + + @mock.patch('tethys_portal.views.user.UserSettingsForm') + @mock.patch('tethys_portal.views.user.redirect') + def test_settings_request_post(self, mock_redirect, mock_usf): + username = 'foo' + + mock_first_name = mock.MagicMock() + mock_last_name = mock.MagicMock() + mock_email = mock.MagicMock() + + mock_user = mock.MagicMock() + mock_user.username = 'foo' + mock_user.first_name = mock_first_name + mock_user.last_name = mock_last_name + mock_user.email = mock_email + + mock_request = mock.MagicMock() + mock_request.user = mock_user + mock_request.method = 'POST' + mock_request.POST = 'user-settings-submit' + + mock_form = mock.MagicMock() + mock_form.is_valid.return_value = True + mock_usf.return_value = mock_form + + settings(mock_request, username) + + mock_user.save.assert_called() + + mock_usf.assert_called_once_with(mock_request.POST) + + mock_redirect.assert_called_once_with('user:profile', username='foo') + + @mock.patch('tethys_portal.views.user.Token.objects.get_or_create') + @mock.patch('tethys_portal.views.user.UserSettingsForm') + @mock.patch('tethys_portal.views.user.render') + def test_settings_request_get(self, mock_render, mock_usf, mock_token_get_create): + username = 'foo' + + mock_request_user = mock.MagicMock() + mock_request_user.username = 'foo' + + mock_request = mock.MagicMock() + mock_request.user = mock_request_user + mock_request.method = 'GET' + + mock_form = mock.MagicMock() + mock_usf.return_value = mock_form + + mock_user_token = mock.MagicMock() + mock_token_created = mock.MagicMock() + mock_token_get_create.return_value = mock_user_token, mock_token_created + + expected_context = {'form': mock_form, + 'context_user': mock_request.user, + 'user_token': mock_user_token.key} + + settings(mock_request, username) + + mock_usf.assert_called_once_with(instance=mock_request_user) + + mock_token_get_create.assert_called_once_with(user=mock_request_user) + + mock_render.assert_called_once_with(mock_request, 'tethys_portal/user/settings.html', expected_context) + + @mock.patch('tethys_portal.views.user.messages.warning') + @mock.patch('tethys_portal.views.user.redirect') + def test_change_password(self, mock_redirect, mock_message_warn): + mock_request = mock.MagicMock() + username = 'foo' + mock_user = mock.MagicMock() + mock_user.username = 'sam' + mock_request.user = mock_user + + change_password(mock_request, username) + + mock_message_warn.assert_called_once_with(mock_request, "You are not allowed to change other users' settings.") + mock_redirect.assert_called_once_with('user:profile', username='sam') + + @mock.patch('tethys_portal.views.user.UserPasswordChangeForm') + @mock.patch('tethys_portal.views.user.redirect') + def test_change_password_post(self, mock_redirect, mock_upf): + username = 'foo' + + mock_user = mock.MagicMock() + mock_user.username = 'foo' + + mock_request = mock.MagicMock() + mock_request.user = mock_user + + mock_request.method = 'POST' + mock_request.POST = 'change-password-submit' + + mock_form = mock.MagicMock() + mock_form.is_valid.return_value = True + mock_upf.return_value = mock_form + + change_password(mock_request, username) + + mock_redirect.assert_called_once_with('user:settings', username='foo') + + mock_form.clean_old_password.assert_called() + + mock_form.clean_new_password2.assert_called() + + mock_form.save.assert_called() + + mock_upf.assert_called_once_with(user=mock_request.user, data=mock_request.POST) + + @mock.patch('tethys_portal.views.user.UserPasswordChangeForm') + @mock.patch('tethys_portal.views.user.render') + def test_change_password_get(self, mock_render, mock_upf): + username = 'foo' + + mock_request_user = mock.MagicMock() + mock_request_user.username = 'foo' + + mock_request = mock.MagicMock() + mock_request.user = mock_request_user + mock_request.method = 'GET' + + mock_form = mock.MagicMock() + mock_upf.return_value = mock_form + + expected_context = {'form': mock_form} + + change_password(mock_request, username) + + mock_upf.assert_called_once_with(user=mock_request_user) + + mock_render.assert_called_once_with(mock_request, 'tethys_portal/user/change_password.html', expected_context) + + @mock.patch('tethys_portal.views.user.messages.warning') + @mock.patch('tethys_portal.views.user.redirect') + def test_social_disconnect_invalid_user(self, mock_redirect, mock_message_warn): + username = 'foo' + + mock_request_user = mock.MagicMock() + mock_request_user.username = 'sam' + + mock_request = mock.MagicMock() + mock_request.user = mock_request_user + + mock_provider = mock.MagicMock() + + mock_association_id = mock.MagicMock() + + social_disconnect(mock_request, username, mock_provider, mock_association_id) + + mock_message_warn.assert_called_once_with(mock_request, "You are not allowed to change other users' settings.") + + mock_redirect.assert_called_once_with('user:profile', username='sam') + + @mock.patch('tethys_portal.views.user.render') + def test_social_disconnect_valid_user(self, mock_render): + username = 'foo' + + mock_request_user = mock.MagicMock() + mock_request_user.username = 'foo' + + mock_request = mock.MagicMock() + mock_request.user = mock_request_user + + mock_provider = mock.MagicMock() + + mock_association_id = mock.MagicMock() + + expected_context = {'provider': mock_provider, + 'association_id': mock_association_id} + + social_disconnect(mock_request, username, mock_provider, mock_association_id) + + mock_render.assert_called_once_with(mock_request, 'tethys_portal/user/disconnect.html', expected_context) + + @mock.patch('tethys_portal.views.user.messages.warning') + @mock.patch('tethys_portal.views.user.redirect') + def test_delete_account(self, mock_redirect, mock_message_warn): + username = 'foo' + + mock_request_user = mock.MagicMock() + mock_request_user.username = 'sam' + + mock_request = mock.MagicMock() + mock_request.user = mock_request_user + + delete_account(mock_request, username) + + mock_message_warn.assert_called_once_with(mock_request, "You are not allowed to change other users' settings.") + + mock_redirect.assert_called_once_with('user:profile', username='sam') + + @mock.patch('tethys_portal.views.user.messages.success') + @mock.patch('tethys_portal.views.user.logout') + @mock.patch('tethys_portal.views.user.redirect') + def test_delete_account_post(self, mock_redirect, mock_logout, mock_messages_success): + username = 'foo' + + mock_user = mock.MagicMock() + mock_user.username = 'foo' + + mock_request = mock.MagicMock() + mock_request.user = mock_user + + mock_request.method = 'POST' + mock_request.POST = 'delete-account-submit' + + delete_account(mock_request, username) + + mock_request.user.delete.assert_called() + + mock_logout.assert_called_once_with(mock_request) + + mock_messages_success.assert_called_once_with(mock_request, 'Your account has been successfully deleted.') + + mock_redirect.assert_called_once_with('home') + + @mock.patch('tethys_portal.views.user.render') + def test_delete_account_not_post(self, mock_render): + username = 'foo' + + mock_user = mock.MagicMock() + mock_user.username = 'foo' + + mock_request = mock.MagicMock() + mock_request.user = mock_user + + mock_request.method = 'GET' + + delete_account(mock_request, username) + + expected_context = {} + + mock_render.assert_called_once_with(mock_request, 'tethys_portal/user/delete.html', expected_context) diff --git a/tests/unit_tests/test_tethys_services/test_admin.py b/tests/unit_tests/test_tethys_services/test_admin.py new file mode 100644 index 000000000..a5ad0e626 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_admin.py @@ -0,0 +1,105 @@ +import unittest +import mock + +from django.utils.translation import ugettext_lazy as _ +from tethys_services.models import DatasetService, SpatialDatasetService, WebProcessingService, PersistentStoreService +from tethys_services.admin import DatasetServiceForm, SpatialDatasetServiceForm, WebProcessingServiceForm,\ + PersistentStoreServiceForm, DatasetServiceAdmin, SpatialDatasetServiceAdmin, WebProcessingServiceAdmin,\ + PersistentStoreServiceAdmin + + +class TestTethysServicesAdmin(unittest.TestCase): + + def setUp(self): + self.expected_labels = { + 'public_endpoint': _('Public Endpoint') + } + + def tearDown(self): + pass + + def test_DatasetServiceForm(self): + mock_args = mock.MagicMock() + expected_fields = ('name', 'engine', 'endpoint', 'public_endpoint', 'apikey', 'username', 'password') + + ret = DatasetServiceForm(mock_args) + self.assertEquals(DatasetService, ret.Meta.model) + self.assertEquals(expected_fields, ret.Meta.fields) + self.assertTrue('password' in ret.Meta.widgets) + self.assertEquals(self.expected_labels, ret.Meta.labels) + + def test_SpatialDatasetServiceForm(self): + mock_args = mock.MagicMock() + expected_fields = ('name', 'engine', 'endpoint', 'public_endpoint', 'apikey', 'username', 'password') + + ret = SpatialDatasetServiceForm(mock_args) + self.assertEquals(SpatialDatasetService, ret.Meta.model) + self.assertEquals(expected_fields, ret.Meta.fields) + self.assertTrue('password' in ret.Meta.widgets) + self.assertEquals(self.expected_labels, ret.Meta.labels) + + def test_WebProcessingServiceForm(self): + mock_args = mock.MagicMock() + expected_fields = ('name', 'endpoint', 'public_endpoint', 'username', 'password') + + ret = WebProcessingServiceForm(mock_args) + self.assertEquals(WebProcessingService, ret.Meta.model) + self.assertEquals(expected_fields, ret.Meta.fields) + self.assertTrue('password' in ret.Meta.widgets) + self.assertEquals(self.expected_labels, ret.Meta.labels) + + def test_PersistentStoreServiceForm(self): + mock_args = mock.MagicMock() + expected_fields = ('name', 'engine', 'host', 'port', 'username', 'password') + + ret = PersistentStoreServiceForm(mock_args) + self.assertEquals(PersistentStoreService, ret.Meta.model) + self.assertEquals(expected_fields, ret.Meta.fields) + self.assertTrue('password' in ret.Meta.widgets) + + def test_DatasetServiceAdmin(self): + mock_args = mock.MagicMock() + expected_fields = ('name', 'engine', 'endpoint', 'public_endpoint', 'apikey', 'username', 'password') + + ret = DatasetServiceAdmin(mock_args, mock_args) + self.assertEquals(DatasetServiceForm, ret.form) + self.assertEquals(expected_fields, ret.fields) + + def test_SpatialDatasetServiceAdmin(self): + mock_args = mock.MagicMock() + expected_fields = ('name', 'engine', 'endpoint', 'public_endpoint', 'apikey', 'username', 'password') + + ret = SpatialDatasetServiceAdmin(mock_args, mock_args) + self.assertEquals(SpatialDatasetServiceForm, ret.form) + self.assertEquals(expected_fields, ret.fields) + + def test_WebProcessingServiceAdmin(self): + mock_args = mock.MagicMock() + expected_fields = ('name', 'endpoint', 'public_endpoint', 'username', 'password') + + ret = WebProcessingServiceAdmin(mock_args, mock_args) + self.assertEquals(WebProcessingServiceForm, ret.form) + self.assertEquals(expected_fields, ret.fields) + + def test_PersistentStoreServiceAdmin(self): + mock_args = mock.MagicMock() + expected_fields = ('name', 'engine', 'host', 'port', 'username', 'password') + + ret = PersistentStoreServiceAdmin(mock_args, mock_args) + self.assertEquals(PersistentStoreServiceForm, ret.form) + self.assertEquals(expected_fields, ret.fields) + + def test_admin_site_register(self): + from django.contrib import admin + registry = admin.site._registry + self.assertIn(DatasetService, registry) + self.assertIsInstance(registry[DatasetService], DatasetServiceAdmin) + + self.assertIn(SpatialDatasetService, registry) + self.assertIsInstance(registry[SpatialDatasetService], SpatialDatasetServiceAdmin) + + self.assertIn(WebProcessingService, registry) + self.assertIsInstance(registry[WebProcessingService], WebProcessingServiceAdmin) + + self.assertIn(PersistentStoreService, registry) + self.assertIsInstance(registry[PersistentStoreService], PersistentStoreServiceAdmin) diff --git a/tests/unit_tests/test_tethys_services/test_apps.py b/tests/unit_tests/test_tethys_services/test_apps.py new file mode 100644 index 000000000..dc3c979d8 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_apps.py @@ -0,0 +1,14 @@ +import unittest +from tethys_services.apps import TethysServicesConfig + + +class TestApps(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_TethysServiceConfig(self): + self.assertEqual('tethys_services', TethysServicesConfig.name) + self.assertEqual("Tethys Services", TethysServicesConfig.verbose_name) diff --git a/tests/unit_tests/test_tethys_services/test_backends/__init__.py b/tests/unit_tests/test_tethys_services/test_backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_services/test_backends/test_hs_restclient_helper.py b/tests/unit_tests/test_tethys_services/test_backends/test_hs_restclient_helper.py new file mode 100644 index 000000000..831d9f8c1 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_backends/test_hs_restclient_helper.py @@ -0,0 +1,182 @@ +import unittest +import mock +import time +from tethys_services.backends.hs_restclient_helper import HSClientInitException +import tethys_services.backends.hs_restclient_helper as hs_client_init_exception + + +class HsRestClientHelperTest(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_init(self): + exc = HSClientInitException('foo') + self.assertEquals('foo', exc.value) + self.assertEquals("'foo'", str(exc)) + + @mock.patch('tethys_services.backends.hs_restclient_helper.logger') + def test_get_get_oauth_hs_main_exception(self, mock_logger): + mock_request = mock.MagicMock() + mock_request.user.social_auth.all.side_effect = Exception('foo') + + self.assertRaises(HSClientInitException, hs_client_init_exception.get_oauth_hs, mock_request) + mock_logger.exception.assert_called_once_with('Failed to initialize hs object: foo') + + @mock.patch('tethys_services.backends.hs_restclient_helper.hs_r') + @mock.patch('tethys_services.backends.hs_restclient_helper.refresh_user_token') + @mock.patch('tethys_services.backends.hs_restclient_helper.logger') + def test_get_get_oauth_hs_one_hydroshare(self, mock_logger, mock_refresh_user_token, mock_hs_r): + mock_social_auth_obj = mock.MagicMock() + mock_backend_instance = mock.MagicMock() + mock_backend_instance.name = 'hydroshare' + mock_backend_instance.auth_server_hostname = 'foo' + mock_data1 = { + 'id': 'id', + 'access_token': 'my_access_token', + 'token_type': 'my_token_type', + 'expires_in': 'my_expires_in', + 'expires_at': 'my_expires_at', + 'refresh_token': 'my_refresh_token', + 'scope': 'my_scope' + } + + mock_request = mock.MagicMock() + mock_request.user.social_auth.all.return_value = [mock_social_auth_obj] + mock_social_auth_obj.get_backend_instance.return_value = mock_backend_instance + mock_social_auth_obj.extra_data.return_value = mock_data1 + mock_refresh_user_token.return_value = True + + hs_client_init_exception.get_oauth_hs(mock_request) + + mock_logger.debug.assert_any_call('Found oauth backend: hydroshare') + mock_refresh_user_token.assert_called_once_with(mock_social_auth_obj) + mock_hs_r.HydroShareAuthOAuth2.assert_called_once_with('', '', token=mock_social_auth_obj.extra_data) + mock_hs_r.HydroShare.assert_called_once_with(auth=mock_hs_r.HydroShareAuthOAuth2(), + hostname=mock_backend_instance.auth_server_hostname) + mock_logger.debug.assert_called_with('hs object initialized: {0} @ {1}'. + format(mock_social_auth_obj.extra_data['id'], + mock_backend_instance.auth_server_hostname)) + + @mock.patch('tethys_services.backends.hs_restclient_helper.hs_r') + @mock.patch('tethys_services.backends.hs_restclient_helper.refresh_user_token') + @mock.patch('tethys_services.backends.hs_restclient_helper.logger') + def test_get_get_oauth_two_hydroshare_exception(self, mock_logger, mock_refresh_user_token, mock_hs_r): + mock_social_auth_obj = mock.MagicMock() + mock_backend_instance = mock.MagicMock() + mock_backend_instance.name = 'hydroshare' + mock_backend_instance.auth_server_hostname = 'foo' + mock_data1 = { + 'id': 'id', + 'access_token': 'my_access_token', + 'token_type': 'my_token_type', + 'expires_in': 'my_expires_in', + 'expires_at': 'my_expires_at', + 'refresh_token': 'my_refresh_token', + 'scope': 'my_scope' + } + + mock_request = mock.MagicMock() + mock_request.user.social_auth.all.return_value = [mock_social_auth_obj, mock_social_auth_obj] + mock_social_auth_obj.get_backend_instance.return_value = mock_backend_instance + mock_social_auth_obj.extra_data.return_value = mock_data1 + mock_refresh_user_token.return_value = True + + self.assertRaises(HSClientInitException, hs_client_init_exception.get_oauth_hs, mock_request) + + mock_logger.debug.assert_any_call('Found oauth backend: hydroshare') + mock_refresh_user_token.assert_called_once_with(mock_social_auth_obj) + mock_hs_r.HydroShareAuthOAuth2.assert_called_once_with('', '', token=mock_social_auth_obj.extra_data) + mock_hs_r.HydroShare.assert_called_once_with(auth=mock_hs_r.HydroShareAuthOAuth2(), + hostname=mock_backend_instance.auth_server_hostname) + mock_logger.debug.assert_any_call('hs object initialized: {0} @ {1}'. + format(mock_social_auth_obj.extra_data['id'], + mock_backend_instance.auth_server_hostname)) + mock_logger.debug.assert_called_with('Found oauth backend: hydroshare') + mock_logger.exception.assert_called_once_with('Failed to initialize hs object: Found another hydroshare oauth ' + 'instance: {0} @ {1}'. + format(mock_social_auth_obj.extra_data['id'], + mock_backend_instance.auth_server_hostname)) + + @mock.patch('tethys_services.backends.hs_restclient_helper.logger') + def test_get_get_oauth_no_hydroshare_exception(self, mock_logger): + mock_request = mock.MagicMock() + mock_request.user.social_auth.all.return_value = [] + + self.assertRaises(HSClientInitException, hs_client_init_exception.get_oauth_hs, mock_request) + + mock_logger.exception.assert_called_once_with('Failed to initialize hs object: Not logged in through ' + 'HydroShare') + mock_request.user.social_auth.all.assert_called_once() + + @mock.patch('tethys_services.backends.hs_restclient_helper.logger') + @mock.patch('tethys_services.backends.hs_restclient_helper.load_strategy') + def test__send_refresh_request(self, mock_load_st, mock_log): + # mock token data + mock_data1 = { + 'access_token': 'my_access_token', + 'token_type': 'my_token_type', + 'expires_in': 'my_expires_in', + 'expires_at': 'my_expires_at', + 'refresh_token': 'my_refresh_token', + 'scope': 'my_scope' + } + # mock user social data as mock token data + mock_user_social = mock.MagicMock() + mock_user_social.refresh.return_value = True + mock_user_social.extra_data.return_value = mock_data1 + mock_user_social.set_extra_data.return_value = True + mock_user_social.save.return_value = True + + # mock the load_strategy() call + mock_load_st.return_value = mock.MagicMock() + + # call the method to test + hs_client_init_exception._send_refresh_request(mock_user_social) + + # check mock user_social is called with mock_load_st + mock_user_social.refresh_token.assert_called_with(mock_load_st()) + mock_user_social.set_extra_data.assert_called_once_with(extra_data=mock_user_social.extra_data) + mock_user_social.save.assert_called_once() + mock_log.debug.assert_called() + + @mock.patch('tethys_services.backends.hs_restclient_helper._send_refresh_request') + def test_refresh_user_token(self, mock_refresh_request): + # mock user social data as mock token data + mock_user_social = mock.MagicMock() + mock_user_social.extra_data.get.return_value = int(time.time()) + + # call the method to test + hs_client_init_exception.refresh_user_token(mock_user_social) + mock_user_social.extra_data.get.assert_called_once_with('expires_at') + mock_refresh_request.assert_called_once_with(mock_user_social) + + @mock.patch('tethys_services.backends.hs_restclient_helper._send_refresh_request') + def test_refresh_user_token_exception_1(self, mock_refresh_request): + # mock user social data as mock token data + mock_user_social = mock.MagicMock() + mock_user_social.extra_data.get.side_effect = Exception + + # call the method to test + hs_client_init_exception.refresh_user_token(mock_user_social) + + mock_user_social.extra_data.get.assert_called_once_with('expires_at') + mock_refresh_request.assert_called_once_with(mock_user_social) + + @mock.patch('tethys_services.backends.hs_restclient_helper.logger') + @mock.patch('tethys_services.backends.hs_restclient_helper.time.time', side_effect=Exception('foo')) + @mock.patch('tethys_services.backends.hs_restclient_helper._send_refresh_request') + def test_refresh_user_token_exception_2(self, mock_refresh_request, mock_time, mock_log): + # mock user social data as mock token data + mock_user_social = mock.MagicMock() + mock_user_social.extra_data.get.return_value = 5 + + # call the method to test + self.assertRaises(Exception, hs_client_init_exception.refresh_user_token, mock_user_social) + + mock_user_social.extra_data.get.assert_called_once_with('expires_at') + mock_refresh_request.assert_not_called() + mock_time.assert_called_once() + mock_log.error.assert_called_once_with('Failed to refresh token: foo') diff --git a/tests/unit_tests/test_tethys_services/test_backends/test_hydroshare.py b/tests/unit_tests/test_tethys_services/test_backends/test_hydroshare.py new file mode 100644 index 000000000..1f97f69af --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_backends/test_hydroshare.py @@ -0,0 +1,118 @@ +import unittest +import mock +from tethys_services.backends.hydroshare import HydroShareOAuth2 + + +class HydroShareBackendTest(unittest.TestCase): + + def setUp(self): + self.auth_server_hostname = "www.hydroshare.org" + self.http_scheme = "https" + self.auth_server_full_url = "{0}://{1}".format(self.http_scheme, self.auth_server_hostname) + self.name = 'hydroshare' + self.user_data_url = '{0}/hsapi/userInfo/'.format(self.auth_server_full_url) + + def tearDown(self): + pass + + def test_HydroShareOAuth2(self): + hydro_share_auth2_obj = HydroShareOAuth2() + + expected_auth_server_full_url = "{0}://{1}".format(self.http_scheme, self.auth_server_hostname) + self.assertEqual(expected_auth_server_full_url, hydro_share_auth2_obj.auth_server_full_url) + + expected_authorization_url = '{0}/o/authorize/'.format(self.auth_server_full_url) + self.assertEqual(expected_authorization_url, hydro_share_auth2_obj.AUTHORIZATION_URL) + + expected_access_token_url = '{0}/o/token/'.format(self.auth_server_full_url) + self.assertEqual(expected_access_token_url, hydro_share_auth2_obj.ACCESS_TOKEN_URL) + + # user data endpoint + expected_user_data_url = '{0}/hsapi/userInfo/'.format(self.auth_server_full_url) + self.assertEqual(expected_user_data_url, hydro_share_auth2_obj.USER_DATA_URL) + + def test_extra_data(self): + mock_response = dict( + email='foo@gmail.com', + username='user1', + access_token='token1', + token_type='type1', + expires_in='500', + expires_at='10000000', + refresh_token='234234', + scope='scope' + ) + + hydro_share_auth2_obj = HydroShareOAuth2() + + hydro_share_auth2_obj.set_expires_in_to = 100 + + ret = hydro_share_auth2_obj.extra_data('user1', '0001-009', mock_response) + + self.assertEquals('foo@gmail.com', ret['email']) + self.assertEquals('token1', ret['access_token']) + self.assertEquals('type1', ret['token_type']) + self.assertEquals(100, ret['expires_in']) + self.assertEquals('234234', ret['refresh_token']) + self.assertEquals('scope', ret['scope']) + + def test_get_user_details(self): + hydro_share_auth2_obj = HydroShareOAuth2() + mock_response = mock.MagicMock(username='name', email='email') + mock_response.get('username').return_value = 'name' + mock_response.get('email').return_value = 'email' + ret = hydro_share_auth2_obj.get_user_details(mock_response) + self.assertIn('username', ret) + self.assertIn('email', ret) + + @mock.patch('tethys_services.backends.hydroshare.HydroShareOAuth2.get_json') + def test_user_data(self, mock_get_json): + # mock the jason response + mock_json_rval = mock.MagicMock() + mock_get_json.return_value = mock_json_rval + access_token = 'token1' + + hydro_share_auth2_obj = HydroShareOAuth2() + ret = hydro_share_auth2_obj.user_data(access_token) + + self.assertEquals(mock_json_rval, ret) + mock_get_json.assert_called_once_with(self.user_data_url, params={'access_token': 'token1'}) + + @mock.patch('tethys_services.backends.hydroshare.HydroShareOAuth2.get_json') + def test_user_data_value_error(self, mock_get_json): + # mock the jason response + mock_get_json.side_effect = ValueError + access_token = 'token1' + hydro_share_auth2_obj = HydroShareOAuth2() + ret = hydro_share_auth2_obj.user_data(access_token) + + self.assertEquals(None, ret) + mock_get_json.assert_called_once_with(self.user_data_url, params={'access_token': 'token1'}) + + @mock.patch('tethys_services.backends.hydroshare.BaseOAuth2.refresh_token') + def test_refresh_token(self, mock_request): + mock_response = dict( + email='foo@gmail.com', + username='user1', + access_token='token1', + token_type='type1', + expires_in=500, + expires_at=10000000, + refresh_token='234234', + scope='scope' + ) + mock_request.return_value = mock_response + + hydro_share_auth2_obj = HydroShareOAuth2() + + hydro_share_auth2_obj.set_expires_in_to = 100 + + ret = hydro_share_auth2_obj.refresh_token('token1') + + self.assertEquals('foo@gmail.com', ret['email']) + self.assertEquals('token1', ret['access_token']) + self.assertEquals('type1', ret['token_type']) + self.assertEquals(100, ret['expires_in']) + self.assertEquals('234234', ret['refresh_token']) + self.assertEquals('scope', ret['scope']) + self.assertIsNotNone(mock_response['expires_in']) diff --git a/tests/unit_tests/test_tethys_services/test_backends/test_hydroshare_beta.py b/tests/unit_tests/test_tethys_services/test_backends/test_hydroshare_beta.py new file mode 100644 index 000000000..9a9e772a9 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_backends/test_hydroshare_beta.py @@ -0,0 +1,27 @@ +import unittest +import mock +from tethys_services.backends.hydroshare_beta import HydroShareBetaOAuth2 + + +class TestHydroShareBeta(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_services.backends.hydroshare_beta.HydroShareOAuth2') + def test_HydroShareBetaOAuth2(self, mock_hydro_share_auth2): + hydro_share_beta_obj = HydroShareBetaOAuth2(mock_hydro_share_auth2) + + expected_auth_server_full_url = 'https://beta.hydroshare.org' + self.assertEqual(expected_auth_server_full_url, hydro_share_beta_obj.auth_server_full_url) + + expected_authorization_url = "https://beta.hydroshare.org/o/authorize/" + self.assertEqual(expected_authorization_url, hydro_share_beta_obj.AUTHORIZATION_URL) + + expected_access_toekn_url = "https://beta.hydroshare.org/o/token/" + self.assertEqual(expected_access_toekn_url, hydro_share_beta_obj.ACCESS_TOKEN_URL) + + expected_user_info = "https://beta.hydroshare.org/hsapi/userInfo/" + self.assertEqual(expected_user_info, hydro_share_beta_obj.USER_DATA_URL) diff --git a/tests/unit_tests/test_tethys_services/test_backends/test_hydroshare_playground.py b/tests/unit_tests/test_tethys_services/test_backends/test_hydroshare_playground.py new file mode 100644 index 000000000..0ba767601 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_backends/test_hydroshare_playground.py @@ -0,0 +1,27 @@ +import unittest +import mock +from tethys_services.backends.hydroshare_playground import HydroSharePlaygroundOAuth2 + + +class TestHydroSharePlayground(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_services.backends.hydroshare_beta.HydroShareOAuth2') + def test_HydroSharePlaygroundOAuth2(self, mock_hydro_share_auth2): + hydro_share_beta_obj = HydroSharePlaygroundOAuth2(mock_hydro_share_auth2) + + expected_auth_server_full_url = 'https://playground.hydroshare.org' + self.assertEqual(expected_auth_server_full_url, hydro_share_beta_obj.auth_server_full_url) + + expected_authorization_url = "https://playground.hydroshare.org/o/authorize/" + self.assertEqual(expected_authorization_url, hydro_share_beta_obj.AUTHORIZATION_URL) + + expected_access_toekn_url = "https://playground.hydroshare.org/o/token/" + self.assertEqual(expected_access_toekn_url, hydro_share_beta_obj.ACCESS_TOKEN_URL) + + expected_user_info = "https://playground.hydroshare.org/hsapi/userInfo/" + self.assertEqual(expected_user_info, hydro_share_beta_obj.USER_DATA_URL) diff --git a/tests/unit_tests/test_tethys_services/test_base.py b/tests/unit_tests/test_tethys_services/test_base.py new file mode 100644 index 000000000..6d6c92ab1 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_base.py @@ -0,0 +1,175 @@ +import unittest +import mock + +from tethys_services.base import DatasetService, SpatialDatasetService, WpsService + + +class TethysServicesBaseTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + # Data Services + + @mock.patch('tethys_services.base.pretty_output') + def test_DatasetService_init_valid_engine(self, mock_pretty_output): + expected_name = 'DataServices' + expected_type = 'ckan' + expected_endpoint = 'tethys_dataset_services.engines.CkanDatasetEngine' + ret = DatasetService(name=expected_name, type=expected_type, endpoint=expected_endpoint) + self.assertEquals(expected_name, ret.name) + self.assertEquals(expected_type, ret.type) + self.assertEquals(expected_endpoint, ret.endpoint) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertIn('DEPRECATION WARNING', po_call_args[0][0][0]) + + @mock.patch('tethys_services.base.pretty_output') + @mock.patch('tethys_services.base.len') + @mock.patch('tethys_services.base.list') + def test_DatasetService_init_with_more_than_two(self, mock_list, mock_len, mock_pretty_output): + mock_len.return_value = 3 + mock_list.return_value = ['ckan', 'hydroshare', 'geoserver'] + expected_name = 'DataServices' + expected_type = 'foo' + expected_endpoint = 'tethys_dataset_services.engines.CkanDatasetEngine' + + self.assertRaises(ValueError, DatasetService, name=expected_name, type=expected_type, + endpoint=expected_endpoint) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals(0, len(po_call_args)) + + @mock.patch('tethys_services.base.pretty_output') + def test_DatasetService_init_with_two(self, mock_pretty_output): + expected_name = 'DataServices' + expected_type = 'foo' + expected_endpoint = 'tethys_dataset_services.engines.CkanDatasetEngine' + self.assertRaises(ValueError, DatasetService, name=expected_name, type=expected_type, + endpoint=expected_endpoint) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals(0, len(po_call_args)) + + @mock.patch('tethys_services.base.pretty_output') + @mock.patch('tethys_services.base.len') + @mock.patch('tethys_services.base.list') + def test_DatasetService_init_with_less_than_two(self, mock_list, mock_len, mock_pretty_output): + mock_len.return_value = 1 + mock_list.return_value = ['ckan'] + expected_name = 'DataServices' + expected_type = 'foo' + expected_endpoint = 'tethys_dataset_services.engines.CkanDatasetEngine' + self.assertRaises(ValueError, DatasetService, name=expected_name, type=expected_type, + endpoint=expected_endpoint) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals(0, len(po_call_args)) + + @mock.patch('tethys_services.base.pretty_output') + def test_DatasetService_repr(self, mock_pretty_output): + expected_name = 'DataServices' + expected_type = 'ckan' + expected_endpoint = 'tethys_dataset_services.engines.CkanDatasetEngine' + ret = DatasetService(name=expected_name, type=expected_type, endpoint=expected_endpoint) + self.assertEquals('', + ret.__repr__()) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals(1, len(po_call_args)) + self.assertIn('DEPRECATION WARNING', po_call_args[0][0][0]) + + # Spatial Data Services + + @mock.patch('tethys_services.base.pretty_output') + def test_SpatialDatasetService_init_with_valid_spatial_engine(self, mock_pretty_output): + expected_name = 'SpatialDataServices' + expected_type = 'geoserver' + expected_endpoint = 'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine' + ret = SpatialDatasetService(name=expected_name, type=expected_type, endpoint=expected_endpoint) + self.assertEquals(expected_name, ret.name) + self.assertEquals(expected_type, ret.type) + self.assertEquals(expected_endpoint, ret.endpoint) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertIn('DEPRECATION WARNING', po_call_args[0][0][0]) + + @mock.patch('tethys_services.base.pretty_output') + @mock.patch('tethys_services.base.len') + @mock.patch('tethys_services.base.list') + def test_SpatialDatasetService_init_with_invalid_spatial_engine_more_than_two(self, mock_list, + mock_len, + mock_pretty_output): + mock_len.return_value = 3 + mock_list.return_value = ['ckan', 'hydroshare', 'geoserver'] + expected_name = 'SpatialDataServices' + expected_type = 'foo' + expected_endpoint = 'end-point' + self.assertRaises(ValueError, SpatialDatasetService, name=expected_name, type=expected_type, + endpoint=expected_endpoint) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals(0, len(po_call_args)) + + @mock.patch('tethys_services.base.pretty_output') + @mock.patch('tethys_services.base.len') + @mock.patch('tethys_services.base.list') + def test_SpatialDatasetService_init_with_valid_spatial_engine_equals_two(self, mock_list, + mock_len, + mock_pretty_output): + mock_len.return_value = 2 + mock_list.return_value = ['hydroshare', 'geoserver'] + expected_name = 'SpatialDataServices' + expected_type = 'foo' + expected_endpoint = 'end-point' + self.assertRaises(ValueError, SpatialDatasetService, name=expected_name, type=expected_type, + endpoint=expected_endpoint) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals(0, len(po_call_args)) + + @mock.patch('tethys_services.base.pretty_output') + @mock.patch('tethys_services.base.len') + @mock.patch('tethys_services.base.list') + def test_SpatialDatasetService_init_with_valid_spatial_engine_less_than_two(self, mock_list, + mock_len, + mock_pretty_output): + mock_len.return_value = 1 + mock_list.return_value = ['geoserver'] + expected_name = 'SpatialDataServices' + expected_type = 'foo' + expected_endpoint = 'end-point' + self.assertRaises(ValueError, SpatialDatasetService, name=expected_name, type=expected_type, + endpoint=expected_endpoint) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals(0, len(po_call_args)) + + @mock.patch('tethys_services.base.pretty_output') + def test_SpatialDatasetService_repr(self, mock_pretty_output): + expected_name = 'SpatialDataServices' + expected_type = 'geoserver' + expected_endpoint = 'tethys_dataset_services.engines.GeoServerSpatialDatasetEngine' + ret = SpatialDatasetService(name=expected_name, type=expected_type, endpoint=expected_endpoint) + self.assertEquals('', ret.__repr__()) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals(1, len(po_call_args)) + self.assertIn('DEPRECATION WARNING', po_call_args[0][0][0]) + + # WpsService + + @mock.patch('tethys_services.base.pretty_output') + def test_WpsService_init(self, mock_pretty_output): + expected_name = 'foo' + expected_endpoint = 'end_point' + ret = WpsService(name=expected_name, endpoint=expected_endpoint) + self.assertEquals(expected_name, ret.name) + self.assertEquals(expected_endpoint, ret.endpoint) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals(1, len(po_call_args)) + self.assertIn('DEPRECATION WARNING', po_call_args[0][0][0]) + + @mock.patch('tethys_services.base.pretty_output') + def test_WpsService_repr(self, mock_pretty_output): + expected_name = 'foo' + expected_endpoint = 'end_point' + self.assertEquals('', + WpsService(name=expected_name, endpoint=expected_endpoint).__repr__()) + po_call_args = mock_pretty_output().__enter__().write.call_args_list + self.assertEquals(1, len(po_call_args)) + self.assertIn('DEPRECATION WARNING', po_call_args[0][0][0]) diff --git a/tests/unit_tests/test_tethys_services/test_models/__init__.py b/tests/unit_tests/test_tethys_services/test_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_services/test_models/test_DatasetService.py b/tests/unit_tests/test_tethys_services/test_models/test_DatasetService.py new file mode 100644 index 000000000..4306d94eb --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_models/test_DatasetService.py @@ -0,0 +1,62 @@ +from tethys_sdk.testing import TethysTestCase +import tethys_services.models as service_model +from django.core.exceptions import ObjectDoesNotExist +from social_core.exceptions import AuthException +import mock + + +class DatasetServiceTests(TethysTestCase): + def set_up(self): + pass + + def tear_down(self): + pass + + def test_unicode(self): + ds = service_model.DatasetService( + name='test_ds', + ) + + self.assertEqual('test_ds', str(ds)) + + @mock.patch('tethys_services.models.HydroShareDatasetEngine') + def test_get_engine_hydroshare(self, mock_hsde): + request = mock.MagicMock() + ds = service_model.DatasetService( + name='test_ds', + engine='tethys_dataset_services.engines.HydroShareDatasetEngine', + endpoint='http://localhost/api/3/action/', + apikey='test_api', + username='foo', + password='password' + + ) + ds.save() + ds.get_engine(request=request) + mock_hsde.assert_called_with(apikey='test_api', endpoint='http://localhost/api/3/action/', + password='password', username='foo') + + @mock.patch('tethys_services.models.HydroShareDatasetEngine') + def test_get_engine_hydroshare_error(self, _): + user = mock.MagicMock() + user.social_auth.get.side_effect = ObjectDoesNotExist + request = mock.MagicMock(user=user) + ds = service_model.DatasetService( + name='test_ds', + engine='tethys_dataset_services.engines.HydroShareDatasetEngine', + ) + self.assertRaises(AuthException, ds.get_engine, request=request) + + @mock.patch('tethys_services.models.CkanDatasetEngine') + def test_get_engine_ckan(self, mock_ckan): + ds = service_model.DatasetService( + name='test_ds', + apikey='test_api', + endpoint='http://localhost/api/3/action/', + username='foo', + password='password' + ) + ds.save() + ds.get_engine() + mock_ckan.assert_called_with(apikey='test_api', endpoint='http://localhost/api/3/action/', + password='password', username='foo') diff --git a/tests/unit_tests/test_tethys_services/test_models/test_PersistentStoreService.py b/tests/unit_tests/test_tethys_services/test_models/test_PersistentStoreService.py new file mode 100644 index 000000000..7ad155f08 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_models/test_PersistentStoreService.py @@ -0,0 +1,50 @@ +from tethys_sdk.testing import TethysTestCase +import tethys_services.models as service_model +import mock + + +class PersistentStoreServiceTests(TethysTestCase): + def set_up(self): + pass + + def tear_down(self): + pass + + def test_unicode(self): + pss = service_model.PersistentStoreService( + name='test_pss', + username='foo', + password='pass' + ) + self.assertEqual('test_pss', str(pss)) + + @mock.patch('sqlalchemy.engine.url.URL') + def test_get_url(self, mock_url): + pss = service_model.PersistentStoreService( + name='test_pss', + username='foo', + password='pass' + ) + + # Execute + pss.get_url() + + # Check if called correctly + mock_url.assert_called_with(database=None, drivername='postgresql', host='localhost', + password='pass', port=5435, username='foo') + + @mock.patch('tethys_services.models.PersistentStoreService.get_url') + @mock.patch('sqlalchemy.create_engine') + def test_get_engine(self, mock_ce, mock_url): + pss = service_model.PersistentStoreService( + name='test_pss', + username='foo', + password='pass' + ) + + mock_url.return_value = 'test_url' + # Execute + pss.get_engine() + + # Check if called correctly + mock_ce.assert_called_with('test_url') diff --git a/tests/unit_tests/test_tethys_services/test_models/test_SpatialDatasetService.py b/tests/unit_tests/test_tethys_services/test_models/test_SpatialDatasetService.py new file mode 100644 index 000000000..c020b0f43 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_models/test_SpatialDatasetService.py @@ -0,0 +1,33 @@ +from tethys_sdk.testing import TethysTestCase +import tethys_services.models as service_model +import mock + + +class SpatialDatasetServiceTests(TethysTestCase): + def set_up(self): + pass + + def tear_down(self): + pass + + def test_unicode(self): + sds = service_model.SpatialDatasetService( + name='test_sds', + ) + self.assertEqual('test_sds', sds.__unicode__()) + + @mock.patch('tethys_services.models.GeoServerSpatialDatasetEngine') + def test_get_engine_geo_server(self, mock_sds): + sds = service_model.SpatialDatasetService( + name='test_sds', + endpoint='http://localhost/geoserver/rest/', + public_endpoint='http://publichost/geoserver/rest/', + username='foo', + password='password' + ) + sds.save() + ret = sds.get_engine() + + # Check result + mock_sds.assert_called_with(endpoint='http://localhost/geoserver/rest/', password='password', username='foo') + self.assertEqual('http://publichost/geoserver/rest/', ret.public_endpoint) diff --git a/tests/unit_tests/test_tethys_services/test_models/test_WebProcessingService.py b/tests/unit_tests/test_tethys_services/test_models/test_WebProcessingService.py new file mode 100644 index 000000000..8ec4ad6ee --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_models/test_WebProcessingService.py @@ -0,0 +1,120 @@ +from tethys_sdk.testing import TethysTestCase +import tethys_services.models as service_model +import mock + +from tethys_services.models import HTTPError, URLError + + +class WebProcessingServiceTests(TethysTestCase): + def set_up(self): + pass + + def tear_down(self): + pass + + def test_unicode(self): + wps = service_model.WebProcessingService( + name='test_sds', + ) + self.assertEqual('test_sds', str(wps)) + + def test_activate(self): + wps = service_model.WebProcessingService( + name='test_sds', + endpoint='http://localhost/geoserver/rest/', + public_endpoint='http://publichost/geoserver/rest/', + username='foo', + password='password' + ) + wps.save() + # Check result + mock_wps = mock.MagicMock() + mock_wps.getcapabilities.return_value = 'test' + + ret = wps.activate(mock_wps) + + mock_wps.getcapabilities.assert_called() + self.assertEqual(mock_wps, ret) + + def test_activate_http_error_404(self): + wps = service_model.WebProcessingService( + name='test_sds', + endpoint='http://localhost/geoserver/rest/', + public_endpoint='http://publichost/geoserver/rest/', + username='foo', + password='password' + ) + + # Check result + mock_wps = mock.MagicMock() + mock_wps.getcapabilities.side_effect = HTTPError(url='test_url', code=404, msg='test_message', + hdrs='test_header', fp=None) + + self.assertRaises(HTTPError, wps.activate, mock_wps) + + def test_activate_http_error_not_404(self): + wps = service_model.WebProcessingService( + name='test_sds', + endpoint='http://localhost/geoserver/rest/', + public_endpoint='http://publichost/geoserver/rest/', + username='foo', + password='password' + ) + + # Check result + mock_wps = mock.MagicMock() + mock_wps.getcapabilities.side_effect = HTTPError(url='test_url', code=500, msg='test_message', + hdrs='test_header', fp=None) + + self.assertRaises(HTTPError, wps.activate, mock_wps) + + def test_activate_url_error(self): + wps = service_model.WebProcessingService( + name='test_sds', + endpoint='http://localhost/geoserver/rest/', + public_endpoint='http://publichost/geoserver/rest/', + username='foo', + password='password' + ) + + # Check result + mock_wps = mock.MagicMock() + mock_wps.getcapabilities.side_effect = URLError(reason='test_url') + + ret = wps.activate(mock_wps) + + self.assertIsNone(ret) + + def test_activate_error(self): + wps = service_model.WebProcessingService( + name='test_sds', + endpoint='http://localhost/geoserver/rest/', + public_endpoint='http://publichost/geoserver/rest/', + username='foo', + password='password' + ) + + # Check result + mock_wps = mock.MagicMock() + mock_wps.getcapabilities.side_effect = Exception + + self.assertRaises(Exception, wps.activate, mock_wps) + + @mock.patch('tethys_services.models.WebProcessingService.activate') + @mock.patch('tethys_services.models.WPS') + def test_get_engine(self, mock_wps, mock_activate): + wps = service_model.WebProcessingService( + name='test_sds', + endpoint='http://localhost/geoserver/rest/', + public_endpoint='http://publichost/geoserver/rest/', + username='foo', + password='password' + ) + wps.save() + # Execute + wps.get_engine() + + # Check called + mock_wps.assert_called_with('http://localhost/geoserver/rest/', password='password', + skip_caps=True, username='foo', verbose=False) + mock_activate.assert_called_with(wps=mock_wps()) diff --git a/tests/unit_tests/test_tethys_services/test_models/test_helper_functions.py b/tests/unit_tests/test_tethys_services/test_models/test_helper_functions.py new file mode 100644 index 000000000..85debeb79 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_models/test_helper_functions.py @@ -0,0 +1,40 @@ +from tethys_sdk.testing import TethysTestCase +import tethys_services.models as service_model +from django.core.exceptions import ValidationError + + +class HelperFunctionTests(TethysTestCase): + def set_up(self): + pass + + def tear_down(self): + pass + + def test_validate_url_valid(self): + test_url = 'http://' + raised = False + try: + service_model.validate_url(test_url) + except ValidationError: + raised = True + self.assertFalse(raised) + + def test_validate_url(self): + test_url = 'test_url' + self.assertRaises(ValidationError, service_model.validate_url, test_url) + + def test_validate_dataset_service_endpoint(self): + test_url = 'http://test_url' + self.assertRaises(ValidationError, service_model.validate_dataset_service_endpoint, test_url) + + def test_validate_spatial_dataset_service_endpoint(self): + test_url = 'http://test_url' + self.assertRaises(ValidationError, service_model.validate_spatial_dataset_service_endpoint, test_url) + + def test_validate_wps_service_endpoint(self): + test_url = 'http://test_url' + self.assertRaises(ValidationError, service_model.validate_wps_service_endpoint, test_url) + + def test_validate_persistent_store_port(self): + test_url = '800' + self.assertRaises(ValidationError, service_model.validate_persistent_store_port, test_url) diff --git a/tests/unit_tests/test_tethys_services/test_templatetags/__init__.py b/tests/unit_tests/test_tethys_services/test_templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_services/test_templatetags/test_tethys_services.py b/tests/unit_tests/test_tethys_services/test_templatetags/test_tethys_services.py new file mode 100644 index 000000000..b53eacd08 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_templatetags/test_tethys_services.py @@ -0,0 +1,25 @@ +import unittest +import mock + +from owslib.wps import ComplexData +from tethys_services.templatetags.tethys_services import is_complex_data + + +class TethysServicesIsComplexDataTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_is_complex_data_false(self): + mock_args = mock.MagicMock() + + self.assertFalse(is_complex_data(mock_args)) + + def test_is_compex_data_true(self): + mock_args = mock.MagicMock() + mock_args = ComplexData() + + self.assertTrue(is_complex_data(mock_args)) diff --git a/tests/unit_tests/test_tethys_services/test_urls.py b/tests/unit_tests/test_tethys_services/test_urls.py new file mode 100644 index 000000000..6197882d0 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_urls.py @@ -0,0 +1,39 @@ +from django.urls import reverse, resolve +from tethys_sdk.testing import TethysTestCase + + +class TestTethysServicesUrls(TethysTestCase): + + def set_up(self): + pass + + def tear_down(self): + pass + + def test_service_urls_wps_services(self): + url = reverse('services:wps_service', kwargs={'service': 'foo'}) + resolver = resolve(url) + self.assertEqual('/developer/services/wps/foo/', url) + self.assertEqual('wps_service', resolver.func.__name__) + self.assertEqual('tethys_services.views', resolver.func.__module__) + + def test_service_urls_wps_process(self): + url = reverse('services:wps_process', kwargs={'service': 'foo', 'identifier': 'bar'}) + resolver = resolve(url) + self.assertEqual('/developer/services/wps/foo/process/bar/', url) + self.assertEqual('wps_process', resolver.func.__name__) + self.assertEqual('tethys_services.views', resolver.func.__module__) + + def test_urlpatterns_datasethome(self): + url = reverse('services:datasets_home') + resolver = resolve(url) + self.assertEqual('/developer/services/datasets/', url) + self.assertEqual('datasets_home', resolver.func.__name__) + self.assertEqual('tethys_services.views', resolver.func.__module__) + + def test_urlpatterns_wpshome(self): + url = reverse('services:wps_home') + resolver = resolve(url) + self.assertEqual('/developer/services/wps/', url) + self.assertEqual('wps_home', resolver.func.__name__) + self.assertEqual('tethys_services.views', resolver.func.__module__) diff --git a/tests/unit_tests/test_tethys_services/test_utilities.py b/tests/unit_tests/test_tethys_services/test_utilities.py new file mode 100644 index 000000000..9c0f39227 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_utilities.py @@ -0,0 +1,595 @@ +import unittest +import mock + +from django.core.exceptions import ObjectDoesNotExist +from social_core.exceptions import AuthAlreadyAssociated, AuthException + +from tethys_dataset_services.engines import HydroShareDatasetEngine +from tethys_services.utilities import ensure_oauth2, initialize_engine_object, list_dataset_engines, \ + get_dataset_engine, list_spatial_dataset_engines, get_spatial_dataset_engine, abstract_is_link, activate_wps, \ + list_wps_service_engines, get_wps_service_engine +try: + from urllib2 import HTTPError, URLError +except ImportError: + from urllib.request import HTTPError, URLError + + +@ensure_oauth2('hydroshare') +def enforced_controller(request, *args, **kwargs): + return True + + +class TestUtilites(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_services.utilities.reverse') + @mock.patch('tethys_services.utilities.redirect') + def test_ensure_oauth2(self, mock_redirect, mock_reverse): + + mock_user = mock.MagicMock() + + mock_request = mock.MagicMock(user=mock_user, path='path') + + mock_redirect_url = mock.MagicMock() + + mock_reverse.return_value = mock_redirect_url + + enforced_controller(mock_request) + + mock_reverse.assert_called_once_with('social:begin', args=['hydroshare']) + + mock_redirect.assert_called_once() + + mock_user.social_auth.get.assert_called_once_with(provider='hydroshare') + + @mock.patch('tethys_services.utilities.reverse') + @mock.patch('tethys_services.utilities.redirect') + def test_ensure_oauth2_ObjectDoesNotExist(self, mock_redirect, mock_reverse): + from django.core.exceptions import ObjectDoesNotExist + + mock_user = mock.MagicMock() + + mock_request = mock.MagicMock(user=mock_user, path='path') + + mock_redirect_url = mock.MagicMock() + + mock_reverse.return_value = mock_redirect_url + + mock_user.social_auth.get.side_effect = ObjectDoesNotExist + + ret = enforced_controller(mock_request) + + mock_reverse.assert_called_once_with('social:begin', args=['hydroshare']) + + mock_redirect.assert_called_once() + + self.assertEquals(mock_redirect(), ret) + + @mock.patch('tethys_services.utilities.reverse') + @mock.patch('tethys_services.utilities.redirect') + def test_ensure_oauth2_AttributeError(self, mock_redirect, mock_reverse): + mock_user = mock.MagicMock() + + mock_request = mock.MagicMock(user=mock_user, path='path') + + mock_redirect_url = mock.MagicMock() + + mock_reverse.return_value = mock_redirect_url + + mock_user.social_auth.get.side_effect = AttributeError + + ret = enforced_controller(mock_request) + + mock_reverse.assert_called_once_with('social:begin', args=['hydroshare']) + + mock_redirect.assert_called_once() + + self.assertEquals(mock_redirect(), ret) + + @mock.patch('tethys_services.utilities.reverse') + @mock.patch('tethys_services.utilities.redirect') + def test_ensure_oauth2_AuthAlreadyAssociated(self, mock_redirect, mock_reverse): + from social_core.exceptions import AuthAlreadyAssociated + + mock_user = mock.MagicMock() + + mock_request = mock.MagicMock(user=mock_user, path='path') + + mock_redirect_url = mock.MagicMock() + + mock_reverse.return_value = mock_redirect_url + + mock_user.social_auth.get.side_effect = AuthAlreadyAssociated(mock.MagicMock(), mock.MagicMock()) + + self.assertRaises(AuthAlreadyAssociated, enforced_controller, mock_request) + + mock_reverse.assert_called_once_with('social:begin', args=['hydroshare']) + + mock_redirect.assert_called_once() + + @mock.patch('tethys_services.utilities.reverse') + @mock.patch('tethys_services.utilities.redirect') + def test_ensure_oauth2_Exception(self, mock_redirect, mock_reverse): + mock_user = mock.MagicMock() + + mock_request = mock.MagicMock(user=mock_user, path='path') + + mock_redirect_url = mock.MagicMock() + + mock_reverse.return_value = mock_redirect_url + + mock_user.social_auth.get.side_effect = Exception + + self.assertRaises(Exception, enforced_controller, mock_request) + + mock_reverse.assert_called_once_with('social:begin', args=['hydroshare']) + + mock_redirect.assert_called_once() + + def test_initialize_engine_object(self): + input_engine = 'tethys_dataset_services.engines.HydroShareDatasetEngine' + input_end_point = 'http://localhost/api/3/action' + + mock_user = mock.MagicMock() + mock_request = mock.MagicMock(user=mock_user, path='path') + + mock_social = mock.MagicMock() + + mock_user.social_auth.get.return_value = mock_social + + mock_api_key = mock.MagicMock() + + mock_social.extra_data['access_token'].return_value = mock_api_key + + ret = initialize_engine_object(engine=input_engine, endpoint=input_end_point, request=mock_request) + + mock_user.social_auth.get.assert_called_once_with(provider='hydroshare') + + self.assertEquals('http://localhost/api/3/action', ret.endpoint) + self.assertIsInstance(ret, HydroShareDatasetEngine) + + def test_initialize_engine_object_ObjectDoesNotExist(self): + input_engine = 'tethys_dataset_services.engines.HydroShareDatasetEngine' + input_end_point = 'http://localhost/api/3/action' + + mock_user = mock.MagicMock() + mock_request = mock.MagicMock(user=mock_user, path='path') + + mock_social = mock.MagicMock() + + mock_user.social_auth.get.side_effect = [ObjectDoesNotExist, mock_social] + + mock_social.extra_data['access_token'].return_value = None + + self.assertRaises(AuthException, initialize_engine_object, engine=input_engine, endpoint=input_end_point, + request=mock_request) + + mock_user.social_auth.get.assert_called_once_with(provider='hydroshare') + + def test_initialize_engine_object_AttributeError(self): + input_engine = 'tethys_dataset_services.engines.HydroShareDatasetEngine' + input_end_point = 'http://localhost/api/3/action' + + mock_user = mock.MagicMock() + mock_request = mock.MagicMock(user=mock_user, path='path') + + mock_social = mock.MagicMock() + + mock_user.social_auth.get.side_effect = [AttributeError, mock_social] + + self.assertRaises(AttributeError, initialize_engine_object, engine=input_engine, endpoint=input_end_point, + request=mock_request) + + mock_user.social_auth.get.assert_called_once_with(provider='hydroshare') + + def test_initialize_engine_object_AuthAlreadyAssociated(self): + input_engine = 'tethys_dataset_services.engines.HydroShareDatasetEngine' + input_end_point = 'http://localhost/api/3/action' + + mock_user = mock.MagicMock() + mock_request = mock.MagicMock(user=mock_user, path='path') + + mock_social = mock.MagicMock() + + mock_user.social_auth.get.side_effect = [AuthAlreadyAssociated(mock.MagicMock(), mock.MagicMock()), mock_social] + + self.assertRaises(AuthAlreadyAssociated, initialize_engine_object, engine=input_engine, + endpoint=input_end_point, request=mock_request) + + mock_user.social_auth.get.assert_called_once_with(provider='hydroshare') + + def test_initialize_engine_object_Exception(self): + input_engine = 'tethys_dataset_services.engines.HydroShareDatasetEngine' + input_end_point = 'http://localhost/api/3/action' + + mock_user = mock.MagicMock() + mock_request = mock.MagicMock(user=mock_user, path='path') + + mock_social = mock.MagicMock() + + mock_user.social_auth.get.side_effect = [Exception, mock_social] + + self.assertRaises(Exception, initialize_engine_object, engine=input_engine, endpoint=input_end_point, + request=mock_request) + + mock_user.social_auth.get.assert_called_once_with(provider='hydroshare') + + @mock.patch('tethys_services.utilities.DsModel.objects') + @mock.patch('tethys_services.utilities.initialize_engine_object') + def test_list_dataset_engines(self, mock_initialize_engine_object, mock_dsmodel): + + mock_engine = mock.MagicMock() + mock_endpoint = mock.MagicMock() + mock_api_key = mock.MagicMock() + mock_user_name = mock.MagicMock() + mock_password = mock.MagicMock() + mock_request = mock.MagicMock() + mock_public_endpoint = mock.MagicMock() + mock_site_dataset_service1 = mock.MagicMock(engine=mock_engine, + endpoint=mock_endpoint.endpoint, + apikey=mock_api_key, + username=mock_user_name, + password=mock_password, + request=mock_request, + public_endpoint=mock_public_endpoint) + + mock_site_dataset_services = [mock_site_dataset_service1] + + mock_dsmodel.all.return_value = mock_site_dataset_services + + mock_init_return = mock.MagicMock() + mock_init_return.public_endpoint = mock_site_dataset_service1.public_endpoint + + mock_initialize_engine_object.return_value = mock_init_return + + ret = list_dataset_engines() + + mock_initialize_engine_object.assert_called_with(apikey=mock_api_key, + endpoint=mock_endpoint.endpoint, + engine=mock_engine.encode('utf-8'), + password=mock_password, + request=None, + username=mock_user_name, + ) + + mock_dsmodel.all.assert_called_once() + + self.assertEquals(mock_init_return, ret[0]) + + @mock.patch('tethys_services.utilities.issubclass') + @mock.patch('tethys_services.utilities.initialize_engine_object') + def test_get_dataset_engine_app_dataset(self, mock_initialize_engine_object, mock_subclass): + from tethys_apps.base.app_base import TethysAppBase + + mock_name = 'foo' + mock_app_class = mock.MagicMock() + mock_subclass.return_value = True + mock_app_dataset_services = mock.MagicMock() + mock_app_dataset_services.name = 'foo' + + mock_app_class().dataset_services.return_value = [mock_app_dataset_services] + + mock_initialize_engine_object.return_value = True + + ret = get_dataset_engine(mock_name, mock_app_class) + + mock_subclass.assert_called_once_with(mock_app_class, TethysAppBase) + + mock_initialize_engine_object.assert_called_with(engine=mock_app_dataset_services.engine, + endpoint=mock_app_dataset_services.endpoint, + apikey=mock_app_dataset_services.apikey, + username=mock_app_dataset_services.username, + password=mock_app_dataset_services.password, + request=None) + + self.assertTrue(ret) + + @mock.patch('tethys_services.utilities.issubclass') + @mock.patch('tethys_services.utilities.initialize_engine_object') + @mock.patch('tethys_services.utilities.DsModel.objects.all') + def test_get_dataset_engine_dataset_services(self, mock_ds_model_object_all, mock_initialize_engine_object, + mock_subclass): + mock_name = 'foo' + + mock_subclass.return_value = False + + mock_init_return = mock.MagicMock() + + mock_initialize_engine_object.return_value = mock_init_return + + mock_site_dataset_services = mock.MagicMock() + + mock_site_dataset_services.name = 'foo' + + mock_ds_model_object_all.return_value = [mock_site_dataset_services] + + mock_init_return.public_endpoint = mock_site_dataset_services.public_endpoint + + ret = get_dataset_engine(mock_name, app_class=None) + + mock_initialize_engine_object.assert_called_with(engine=mock_site_dataset_services.engine.encode('utf-8'), + endpoint=mock_site_dataset_services.endpoint, + apikey=mock_site_dataset_services.apikey, + username=mock_site_dataset_services.username, + password=mock_site_dataset_services.password, + request=None) + + self.assertEquals(mock_init_return, ret) + + @mock.patch('tethys_services.utilities.initialize_engine_object') + @mock.patch('tethys_services.utilities.DsModel.objects.all') + def test_get_dataset_engine_name_error(self, mock_ds_model_object_all, mock_initialize_engine_object): + mock_name = 'foo' + + mock_site_dataset_services = mock.MagicMock() + + mock_site_dataset_services.name = 'foo' + + mock_ds_model_object_all.return_value = None + + self.assertRaises(NameError, get_dataset_engine, mock_name, app_class=None) + + mock_initialize_engine_object.assert_not_called() + + @mock.patch('tethys_services.utilities.initialize_engine_object') + @mock.patch('tethys_services.utilities.SdsModel') + def test_list_spatial_dataset_engines(self, mock_sds_model, mock_initialize): + mock_service1 = mock.MagicMock() + mock_sds_model.objects.all.return_value = [mock_service1] + mock_ret = mock.MagicMock() + mock_ret.public_endpoint = mock_service1.public_endpoint + mock_initialize.return_value = mock_ret + + ret = list_spatial_dataset_engines() + + self.assertEquals(mock_ret, ret[0]) + mock_sds_model.objects.all.assert_called_once() + mock_initialize.assert_called_once_with(engine=mock_service1.engine.encode('utf-8'), + endpoint=mock_service1.endpoint, + apikey=mock_service1.apikey, + username=mock_service1.username, + password=mock_service1.password) + + @mock.patch('tethys_services.utilities.initialize_engine_object') + @mock.patch('tethys_services.utilities.issubclass') + def test_get_spatial_dataset_engine_with_app(self, mock_issubclass, mock_initialize_engine_object): + from tethys_apps.base.app_base import TethysAppBase + + name = 'foo' + mock_app_class = mock.MagicMock() + mock_app_sds = mock.MagicMock() + mock_app_sds.name = 'foo' + mock_app_class().spatial_dataset_services.return_value = [mock_app_sds] + mock_issubclass.return_value = True + mock_initialize_engine_object.return_value = True + + ret = get_spatial_dataset_engine(name=name, app_class=mock_app_class) + + self.assertTrue(ret) + mock_issubclass.assert_called_once_with(mock_app_class, TethysAppBase) + mock_initialize_engine_object.assert_called_once_with(engine=mock_app_sds.engine, + endpoint=mock_app_sds.endpoint, + apikey=mock_app_sds.apikey, + username=mock_app_sds.username, + password=mock_app_sds.password) + + @mock.patch('tethys_services.utilities.initialize_engine_object') + @mock.patch('tethys_services.utilities.SdsModel') + def test_get_spatial_dataset_engine_with_site(self, mock_sds_model, mock_initialize_engine_object): + name = 'foo' + mock_site_sds = mock.MagicMock() + mock_site_sds.name = 'foo' + mock_sds_model.objects.all.return_value = [mock_site_sds] + mock_sdo = mock.MagicMock() + mock_sdo.public_endpoint = mock_site_sds.public_endpoint + mock_initialize_engine_object.return_value = mock_sdo + + ret = get_spatial_dataset_engine(name=name, app_class=None) + + self.assertEquals(mock_sdo, ret) + mock_initialize_engine_object.assert_called_once_with(engine=mock_site_sds.engine.encode('utf-8'), + endpoint=mock_site_sds.endpoint, + apikey=mock_site_sds.apikey, + username=mock_site_sds.username, + password=mock_site_sds.password) + + @mock.patch('tethys_services.utilities.SdsModel') + def test_get_spatial_dataset_engine_with_name_error(self, mock_sds_model): + name = 'foo' + mock_sds_model.objects.all.return_value = None + + self.assertRaises(NameError, get_spatial_dataset_engine, name=name, app_class=None) + + def test_abstract_is_link(self): + mock_process = mock.MagicMock() + mock_process.abstract = 'http://foo' + + ret = abstract_is_link(mock_process) + + self.assertTrue(ret) + + def test_abstract_is_link_false(self): + mock_process = mock.MagicMock() + mock_process.abstract = 'foo_bar' + + ret = abstract_is_link(mock_process) + + self.assertFalse(ret) + + def test_abstract_is_link_attribute_error(self): + ret = abstract_is_link(process=None) + + self.assertFalse(ret) + + def test_activate_wps(self): + mock_wps = mock.MagicMock() + mock_endpoint = mock.MagicMock() + mock_name = mock.MagicMock() + + ret = activate_wps(mock_wps, mock_endpoint, mock_name) + + mock_wps.getcapabilities.assert_called_once() + self.assertEqual(mock_wps, ret) + + def test_activate_wps_HTTPError_with_error_code_404(self): + mock_wps = mock.MagicMock() + mock_endpoint = mock.MagicMock() + mock_name = mock.MagicMock() + + mock_wps.getcapabilities.side_effect = HTTPError(url='test_url', code=404, msg='test_message', + hdrs='test_header', fp=None) + + self.assertRaises(HTTPError, activate_wps, mock_wps, mock_endpoint, mock_name) + + def test_activate_wps_HTTPError(self): + mock_wps = mock.MagicMock() + mock_endpoint = mock.MagicMock() + mock_name = mock.MagicMock() + + mock_wps.getcapabilities.side_effect = HTTPError(url='test_url', code=500, msg='test_message', + hdrs='test_header', fp=None) + + self.assertRaises(HTTPError, activate_wps, mock_wps, mock_endpoint, mock_name) + + def test_activate_wps_URLError(self): + mock_wps = mock.MagicMock() + mock_endpoint = mock.MagicMock() + mock_name = mock.MagicMock() + + mock_wps.getcapabilities.side_effect = URLError(reason='') + + self.assertIsNone(activate_wps(mock_wps, mock_endpoint, mock_name)) + + @mock.patch('tethys_services.utilities.activate_wps') + @mock.patch('tethys_services.utilities.WebProcessingService') + @mock.patch('tethys_services.utilities.issubclass') + def test_get_wps_service_engine_with_app(self, mock_issubclass, mock_wps_obj, mock_activate_wps): + from tethys_apps.base.app_base import TethysAppBase + + name = 'foo' + + mock_app_ws = mock.MagicMock() + mock_app_ws.name = 'foo' + + mock_app_class = mock.MagicMock() + mock_app_class().wps_services.return_value = [mock_app_ws] + + mock_issubclass.return_value = True + + mock_wps_obj.return_value = True + + ret = get_wps_service_engine(name=name, app_class=mock_app_class) + + self.assertTrue(ret) + + mock_issubclass.assert_called_once_with(mock_app_class, TethysAppBase) + + mock_wps_obj.assert_called_once_with(mock_app_ws.endpoint, + username=mock_app_ws.username, + password=mock_app_ws.password, + verbose=False, + skip_caps=True + ) + + mock_activate_wps.call_once_with(wps=True, endpoint=mock_app_ws.endpoint, name=mock_app_ws.name) + + @mock.patch('tethys_services.utilities.activate_wps') + @mock.patch('tethys_services.utilities.WebProcessingService') + @mock.patch('tethys_services.utilities.WpsModel') + def test_get_wps_service_engine_with_site(self, mock_wps_model, mock_wps, mock_activate_wps): + name = 'foo' + mock_site_ws = mock.MagicMock() + mock_site_ws.name = 'foo' + + mock_wps_model.objects.all.return_value = [mock_site_ws] + + mock_sdo = mock.MagicMock() + mock_sdo.public_endpoint = mock_site_ws.public_endpoint + + mock_wps.return_value = mock_sdo + + get_wps_service_engine(name=name, app_class=None) + + mock_wps.assert_called_once_with(mock_site_ws.endpoint, + username=mock_site_ws.username, + password=mock_site_ws.password, + verbose=False, + skip_caps=True) + + mock_activate_wps.call_once_with(wps=mock_sdo, endpoint=mock_site_ws.endpoint, name=mock_site_ws.name) + + @mock.patch('tethys_services.utilities.WpsModel') + def test_get_wps_service_engine_with_name_error(self, mock_wps_model): + name = 'foo' + mock_wps_model.objects.all.return_value = None + self.assertRaises(NameError, get_wps_service_engine, name=name, app_class=None) + + @mock.patch('tethys_services.utilities.activate_wps') + @mock.patch('tethys_services.utilities.WebProcessingService') + @mock.patch('tethys_services.utilities.issubclass') + def test_list_wps_service_engines_apps(self, mock_issubclass, mock_wps, mock_activate_wps): + from tethys_apps.base.app_base import TethysAppBase + + mock_app_ws = mock.MagicMock() + + mock_app_ws.name = 'foo' + + mock_app_class = mock.MagicMock() + mock_app_class().wps_services.return_value = [mock_app_ws] + + mock_issubclass.return_value = True + + mock_wps.return_value = True + + mock_activated_wps = mock.MagicMock() + + mock_activate_wps.return_value = mock_activated_wps + + ret = list_wps_service_engines(app_class=mock_app_class) + + mock_issubclass.assert_called_once_with(mock_app_class, TethysAppBase) + + mock_wps.assert_called_once_with(mock_app_ws.endpoint, + username=mock_app_ws.username, + password=mock_app_ws.password, + verbose=False, + skip_caps=True) + + mock_issubclass.assert_called_once_with(mock_app_class, TethysAppBase) + + self.assertEquals(mock_activate_wps(), ret[0]) + + @mock.patch('tethys_services.utilities.activate_wps') + @mock.patch('tethys_services.utilities.WebProcessingService') + @mock.patch('tethys_services.utilities.WpsModel') + def test_list_wps_service_engine_with_site(self, mock_wps_model, mock_wps, mock_activate_wps): + mock_site_ws = mock.MagicMock() + mock_site_ws.name = 'foo' + + mock_wps_model.objects.all.return_value = [mock_site_ws] + + mock_sdo = mock.MagicMock() + mock_sdo.public_endpoint = mock_site_ws.public_endpoint + + mock_wps.return_value = mock_sdo + + mock_activated_wps = mock.MagicMock() + + mock_activate_wps.return_value = mock_activated_wps + + ret = list_wps_service_engines(app_class=None) + + mock_wps.assert_called_once_with(mock_site_ws.endpoint, + username=mock_site_ws.username, + password=mock_site_ws.password, + verbose=False, + skip_caps=True) + + mock_activate_wps.call_once_with(wps=mock_sdo, endpoint=mock_site_ws.endpoint, name=mock_site_ws.name) + + self.assertEquals(mock_activate_wps(), ret[0]) diff --git a/tests/unit_tests/test_tethys_services/test_views.py b/tests/unit_tests/test_tethys_services/test_views.py new file mode 100644 index 000000000..55b138c12 --- /dev/null +++ b/tests/unit_tests/test_tethys_services/test_views.py @@ -0,0 +1,68 @@ +import unittest +import mock + +from tethys_services.views import datasets_home, wps_home, wps_service, wps_process + + +class TethysServicesViewsTests(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + @mock.patch('tethys_services.views.render') + def test_datasets_home(self, mock_render): + mock_request = mock.MagicMock() + mock_render.return_value = 'datasets_home' + context = {} + + self.assertEquals('datasets_home', datasets_home(mock_request)) + mock_render.assert_called_once_with(mock_request, 'tethys_services/tethys_datasets/home.html', context) + + @mock.patch('tethys_services.views.list_wps_service_engines') + @mock.patch('tethys_services.views.render') + def test_wps_home(self, mock_render, mock_list_wps_service_engines): + mock_request = mock.MagicMock() + mock_render.return_value = 'wps_home' + mock_wps = mock.MagicMock() + mock_list_wps_service_engines.return_value = mock_wps + context = {'wps_services': mock_wps} + + self.assertEquals('wps_home', wps_home(mock_request)) + mock_render.assert_called_once_with(mock_request, 'tethys_services/tethys_wps/home.html', context) + + @mock.patch('tethys_services.views.get_wps_service_engine') + @mock.patch('tethys_services.views.render') + def test_wps_service(self, mock_render, mock_get_wps_service_engine): + mock_request = mock.MagicMock() + mock_service = mock.MagicMock() + mock_render.return_value = 'wps_service' + mock_wps = mock.MagicMock() + mock_get_wps_service_engine.return_value = mock_wps + context = {'wps': mock_wps, + 'service': mock_service} + + self.assertEquals('wps_service', wps_service(mock_request, mock_service)) + mock_render.assert_called_once_with(mock_request, 'tethys_services/tethys_wps/service.html', context) + mock_get_wps_service_engine.assert_called_once_with(mock_service) + + @mock.patch('tethys_services.views.abstract_is_link') + @mock.patch('tethys_services.views.get_wps_service_engine') + @mock.patch('tethys_services.views.render') + def test_wps_process(self, mock_render, mock_get_wps_service_engine, mock_abstract_is_link): + mock_request = mock.MagicMock() + mock_service = mock.MagicMock() + mock_identifier = mock.MagicMock() + mock_wps_process = mock.MagicMock() + mock_render.return_value = 'wps_process' + mock_wps = mock.MagicMock() + mock_wps.describeprocess.return_value = mock_wps_process + mock_get_wps_service_engine.return_value = mock_wps + + self.assertEquals('wps_process', wps_process(mock_request, mock_service, mock_identifier)) + mock_render.assert_called_once() + mock_get_wps_service_engine.assert_called_once_with(mock_service) + mock_abstract_is_link.assert_called_once_with(mock_wps_process) + mock_wps.describeprocess.assert_called_once_with(mock_identifier) diff --git a/tethys_apps/__init__.py b/tethys_apps/__init__.py index 4da7df4b0..b43c27210 100644 --- a/tethys_apps/__init__.py +++ b/tethys_apps/__init__.py @@ -10,7 +10,3 @@ # Load the custom app config default_app_config = 'tethys_apps.apps.TethysAppsConfig' - - - - diff --git a/tethys_apps/admin.py b/tethys_apps/admin.py index 59f86ba6e..f026a3f72 100644 --- a/tethys_apps/admin.py +++ b/tethys_apps/admin.py @@ -53,7 +53,7 @@ class WebProcessingServiceSettingInline(TethysAppSettingInline): model = WebProcessingServiceSetting -#TODO: Figure out how to initialize persistent stores with button in admin +# TODO: Figure out how to initialize persistent stores with button in admin # Consider: https://medium.com/@hakibenita/how-to-add-custom-action-buttons-to-django-admin-8d266f5b0d41 class PersistentStoreConnectionSettingInline(TethysAppSettingInline): readonly_fields = ('name', 'description', 'required') @@ -88,7 +88,6 @@ def has_add_permission(self, request): return False - class TethysExtensionAdmin(GuardedModelAdmin): readonly_fields = ('package', 'name', 'description') fields = ('package', 'name', 'description', 'enabled') @@ -99,5 +98,6 @@ def has_delete_permission(self, request, obj=None): def has_add_permission(self, request): return False + admin.site.register(TethysApp, TethysAppAdmin) -admin.site.register(TethysExtension, TethysExtensionAdmin) \ No newline at end of file +admin.site.register(TethysExtension, TethysExtensionAdmin) diff --git a/tethys_apps/app_installation.py b/tethys_apps/app_installation.py index f2cd6eb68..0f2a11cfc 100644 --- a/tethys_apps/app_installation.py +++ b/tethys_apps/app_installation.py @@ -12,8 +12,8 @@ import subprocess from setuptools.command.develop import develop from setuptools.command.install import install -from sys import platform as _platform import ctypes +from tethys_apps.cli.cli_colors import pretty_output, FG_BLACK def find_resource_files(directory): @@ -40,16 +40,17 @@ def _run_install(self): destination_dir = os.path.join(tethysapp_dir, self.app_package) # Notify user - print('Copying App Package: {0} to {1}'.format(self.app_package_dir, destination_dir)) + with pretty_output(FG_BLACK) as p: + p.write('Copying App Package: {0} to {1}'.format(self.app_package_dir, destination_dir)) # Copy files try: shutil.copytree(self.app_package_dir, destination_dir) - except: + except Exception: try: shutil.rmtree(destination_dir) - except: + except Exception: os.remove(destination_dir) shutil.copytree(self.app_package_dir, destination_dir) @@ -71,27 +72,31 @@ def _run_develop(self): destination_dir = os.path.join(tethysapp_dir, self.app_package) # Notify user - print('Creating Symbolic Link to App Package: {0} to {1}'.format(self.app_package_dir, destination_dir)) + with pretty_output(FG_BLACK) as p: + p.write('Creating Symbolic Link to App Package: {0} to {1}'.format(self.app_package_dir, destination_dir)) # Create symbolic link try: - os_symlink = getattr(os,"symlink",None) + os_symlink = getattr(os, "symlink", None) if callable(os_symlink): os.symlink(self.app_package_dir, destination_dir) else: - def symlink_ms(source,dest): + def symlink_ms(source, dest): csl = ctypes.windll.kernel32.CreateSymbolicLinkW - csl.argtypes = (ctypes.c_wchar_p,ctypes.c_wchar_p,ctypes.c_uint32) + csl.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32) csl.restype = ctypes.c_ubyte flags = 1 if os.path.isdir(source) else 0 - if csl(dest,source.replace('/','\\'),flags) == 0: - raise ctypes.WinError() - os.symlink = symlink_ms(self.app_package_dir, destination_dir) + if csl(dest, source.replace('/', '\\'), flags) == 0: + raise ctypes.WinError() + + os.symlink = symlink_ms + symlink_ms(self.app_package_dir, destination_dir) except Exception as e: - print(e) + with pretty_output(FG_BLACK) as p: + p.write(e) try: shutil.rmtree(destination_dir) - except Exception as e: + except Exception: os.remove(destination_dir) os.symlink(self.app_package_dir, destination_dir) @@ -127,4 +132,4 @@ def custom_develop_command(app_package, app_package_dir, dependencies): 'dependencies': dependencies, 'run': _run_develop} - return type('CustomDevelopCommand', (develop, object), properties) \ No newline at end of file + return type('CustomDevelopCommand', (develop, object), properties) diff --git a/tethys_apps/apps.py b/tethys_apps/apps.py index 1ac8d940c..97a1dfdf5 100644 --- a/tethys_apps/apps.py +++ b/tethys_apps/apps.py @@ -23,5 +23,3 @@ def ready(self): # Perform App Harvesting harvester = SingletonHarvester() harvester.harvest() - - diff --git a/tethys_apps/base/__init__.py b/tethys_apps/base/__init__.py index 1248d58f2..e7c01a70d 100644 --- a/tethys_apps/base/__init__.py +++ b/tethys_apps/base/__init__.py @@ -8,8 +8,8 @@ ******************************************************************************** """ # DO NOT ERASE -from tethys_apps.base.app_base import TethysAppBase, TethysExtensionBase -from tethys_apps.base.controller import app_controller_maker -from tethys_apps.base.url_map import url_map_maker -from tethys_apps.base.workspace import TethysWorkspace -from tethys_apps.base.permissions import Permission, PermissionGroup, has_permission +from tethys_apps.base.app_base import TethysAppBase, TethysExtensionBase # noqa: F401 +from tethys_apps.base.controller import app_controller_maker # noqa: F401 +from tethys_apps.base.url_map import url_map_maker # noqa: F401 +from tethys_apps.base.workspace import TethysWorkspace # noqa: F401 +from tethys_apps.base.permissions import Permission, PermissionGroup, has_permission # noqa: F401 diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index c0a9289cb..d50eefa86 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -71,7 +71,7 @@ def url_maps(self): ) return url_maps - """ + """ # noqa: E501 return [] @property @@ -79,7 +79,6 @@ def url_patterns(self): """ Generate the url pattern lists for app and namespace them accordingly. """ - if self._url_patterns is None: is_extension = isinstance(self, TethysExtensionBase) @@ -87,8 +86,6 @@ def url_patterns(self): if hasattr(self, 'url_maps'): url_maps = self.url_maps() - else: - url_maps = [] for url_map in url_maps: namespace = self.namespace @@ -105,18 +102,18 @@ def url_patterns(self): function_name = controller_parts[-1] try: module = __import__(module_name, fromlist=[function_name]) - except ImportError: + except Exception as e: error_msg = 'The following error occurred while trying to import the controller function ' \ '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) tethys_log.error(error_msg) - raise + raise e try: controller_function = getattr(module, function_name) except AttributeError as e: error_msg = 'The following error occurred while trying to access the controller function ' \ '"{0}":\n {1}'.format(url_map.controller, traceback.format_exc(2)) tethys_log.error(error_msg) - raise + raise e else: controller_function = url_map.controller django_url = url(url_map.url, controller_function, name=url_map.name) @@ -139,6 +136,7 @@ def remove_from_db(self): """ raise NotImplementedError + class TethysExtensionBase(TethysBase): """ Base class used to define the extension class for Tethys extensions. @@ -185,7 +183,7 @@ def url_maps(self): ) return url_maps - """ + """ # noqa: E501 return [] def sync_with_tethys_db(self): @@ -194,7 +192,6 @@ def sync_with_tethys_db(self): """ from django.conf import settings from tethys_apps.models import TethysExtension - try: # Query to see if installed extension is in the database db_extensions = TethysExtension.objects. \ @@ -363,7 +360,7 @@ def persistent_store_settings(self): ) return ps_settings - """ + """ # noqa: E501 return None def dataset_service_settings(self): @@ -705,7 +702,7 @@ def job_templates(cls): ) return job_templates - """ + """ # noqa: E501 return None @classmethod @@ -812,8 +809,8 @@ def a_controller(request): """ # Find the path to the app project directory - ## Hint: cls is a child class of this class. - ## Credits: http://stackoverflow.com/questions/4006102/is-possible-to-know-the-_path-of-the-file-of-a-subclass-in-python + # Hint: cls is a child class of this class. + # Credits: http://stackoverflow.com/questions/4006102/ is-possible-to-know-the-_path-of-the-file-of-a-subclass-in-python # noqa: E501 project_directory = os.path.dirname(sys.modules[cls.__module__].__file__) workspace_directory = os.path.join(project_directory, 'workspaces', 'app_workspace') return TethysWorkspace(workspace_directory) @@ -847,8 +844,6 @@ def get_custom_setting(cls, name): except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist('CustomTethysAppSetting', name, cls.name) - - @classmethod def get_dataset_service(cls, name, as_public_endpoint=False, as_endpoint=False, as_engine=False): @@ -919,8 +914,12 @@ def get_spatial_dataset_service(cls, name, as_public_endpoint=False, as_endpoint try: spatial_dataset_service_setting = spatial_dataset_service_settings.get(name=name) - return spatial_dataset_service_setting.get_value(as_public_endpoint=as_public_endpoint, as_endpoint=as_endpoint, - as_wms=as_wms, as_wfs=as_wfs, as_engine=as_engine) + return spatial_dataset_service_setting.get_value( + as_public_endpoint=as_public_endpoint, + as_endpoint=as_endpoint, + as_wms=as_wms, as_wfs=as_wfs, + as_engine=as_engine + ) except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist('SpatialDatasetServiceSetting', name, cls.name) @@ -1037,7 +1036,7 @@ def get_persistent_store_database(cls, name, as_url=False, as_sessionmaker=False except ObjectDoesNotExist: raise TethysAppSettingDoesNotExist('PersistentStoreDatabaseSetting', verified_name, cls.name) except TethysAppSettingNotAssigned: - cls._log_tethys_app_setting_not_assigned_error('PersistentStoreConnectionSetting', verified_name) + cls._log_tethys_app_setting_not_assigned_error('PersistentStoreDatabaseSetting', verified_name) @classmethod def create_persistent_store(cls, db_name, connection_name, spatial=False, initializer='', refresh=False, @@ -1068,7 +1067,7 @@ def create_persistent_store(cls, db_name, connection_name, spatial=False, initia if result: engine = app.get_persistent_store_engine('example_db') - """ + """ # noqa: E501 # Get named persistent store service connection from tethys_apps.models import TethysApp from tethys_apps.models import PersistentStoreDatabaseSetting @@ -1093,9 +1092,11 @@ def create_persistent_store(cls, db_name, connection_name, spatial=False, initia except ObjectDoesNotExist: if connection_name is None: raise TethysAppSettingDoesNotExist( - 'PersistentStoreDatabaseSetting named "{0}" does not exist.'.format(db_name)) - else:raise TethysAppSettingDoesNotExist( - 'PersistentStoreConnectionSetting ',connection_name, cls.name) + 'PersistentStoreDatabaseSetting named "{0}" does not exist.'.format(db_name), + connection_name, cls.name) + else: + raise TethysAppSettingDoesNotExist( + 'PersistentStoreConnectionSetting ', connection_name, cls.name) ps_service = ps_setting.persistent_store_service @@ -1317,6 +1318,17 @@ def sync_with_tethys_db(self): db_app.icon = self.icon db_app.root_url = self.root_url db_app.color = self.color + + # custom settings + db_app.add_settings(self.custom_settings()) + # dataset services settings + db_app.add_settings(self.dataset_service_settings()) + # spatial dataset services settings + db_app.add_settings(self.spatial_dataset_service_settings()) + # wps settings + db_app.add_settings(self.web_processing_service_settings()) + # persistent store settings + db_app.add_settings(self.persistent_store_settings()) db_app.save() if hasattr(settings, 'DEBUG') and settings.DEBUG: @@ -1327,18 +1339,6 @@ def sync_with_tethys_db(self): db_app.feedback_emails = self.feedback_emails db_app.save() - # custom settings - db_app.add_settings(self.custom_settings()) - # dataset services settings - db_app.add_settings(self.dataset_service_settings()) - # spatial dataset services settings - db_app.add_settings(self.spatial_dataset_service_settings()) - # wps settings - db_app.add_settings(self.web_processing_service_settings()) - # persistent store settings - db_app.add_settings(self.persistent_store_settings()) - db_app.save() - # More than one instance of the app in db... (what to do here?) elif len(db_apps) >= 2: pass @@ -1372,5 +1372,6 @@ def _log_tethys_app_setting_not_assigned_error(cls, setting_type, setting_name): tethys_log.warn('Tethys app setting is not assigned.\nTraceback (most recent call last):\n{0} ' 'TethysAppSettingNotAssigned: {1} named "{2}" has not been assigned. ' 'Please visit the setting page for the app {3} and assign all required settings.' - .format(traceback.format_stack(limit=3)[0], setting_type, setting_name, cls.name.encode('utf-8')) + .format(traceback.format_stack(limit=3)[0], setting_type, setting_name, + cls.name.encode('utf-8')) ) diff --git a/tethys_apps/base/controller.py b/tethys_apps/base/controller.py index 44b91f391..fcfad377d 100644 --- a/tethys_apps/base/controller.py +++ b/tethys_apps/base/controller.py @@ -22,8 +22,8 @@ def app_controller_maker(root_url): class TethysController(View): @classmethod - def as_controller(cls, *args, **kwargs): + def as_controller(cls, **kwargs): """ Thin veneer around the as_view method to make interface more consistent with Tethys terminology. """ - return cls.as_view(*args, **kwargs) + return cls.as_view(**kwargs) diff --git a/tethys_apps/base/function_extractor.py b/tethys_apps/base/function_extractor.py index 3dd0abe84..06784c2a6 100644 --- a/tethys_apps/base/function_extractor.py +++ b/tethys_apps/base/function_extractor.py @@ -6,7 +6,7 @@ class TethysFunctionExtractor(object): Base class for PersistentStore and HandoffHandler that returns a function handle from a string path to the function. Attributes: - path (str): The path to a function in the form "app_name.module_name.function_name". + path (str): The path to a function in the form "app_name.module_name.function_name" or the function object. """ PATH_PREFIX = 'tethys_apps.tethysapp' @@ -17,28 +17,13 @@ def __init__(self, path, prefix=PATH_PREFIX, throw=False): self._valid = None self._function = None + # Handle function object case if not isinstance(path, basestring): - if path.callable(): - self.valid = True - self.function = path - - @property - def valid(self): - """ - True if function is valid otherwise False. - """ - if self._valid is None: - self.function - return self._valid - - @property - def function(self): - """ - The function pointed to by the path_str attribute. + if callable(path): + self._valid = True + self._function = path - Returns: - A handle to a Python function or None if function is not valid. - """ + def _extract_function(self): if not self._function and self._valid is None: try: # Split into parts and extract function name @@ -59,4 +44,23 @@ def function(self): self._function = getattr(module, function_name) self._valid = True - return self._function \ No newline at end of file + @property + def valid(self): + """ + True if function is valid otherwise False. + """ + if self._valid is None: + self._extract_function() + return self._valid + + @property + def function(self): + """ + The function pointed to by the path_str attribute. + + Returns: + A handle to a Python function or None if function is not valid. + """ + if self._function is None: + self._extract_function() + return self._function diff --git a/tethys_apps/base/handoff.py b/tethys_apps/base/handoff.py index 0ef8a2254..3fb48ddd9 100644 --- a/tethys_apps/base/handoff.py +++ b/tethys_apps/base/handoff.py @@ -7,6 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ +from __future__ import print_function import inspect import json from django.shortcuts import redirect @@ -52,7 +53,7 @@ def get_capabilities(self, app_name=None, external_only=False, jsonify=False): Returns: A list of valid HandoffHandler objects (or a JSON string if jsonify=True) representing the capabilities of app_name, or None if no app with app_name is found. - """ + """ # noqa: E501 manager = self._get_handoff_manager_for_app(app_name) if manager: @@ -62,7 +63,7 @@ def get_capabilities(self, app_name=None, external_only=False, jsonify=False): handlers = [handler for handler in handlers if not handler.internal] if jsonify: - handlers = json.dumps([handler.__json__() for handler in handlers]) + handlers = json.dumps([handler.__dict__ for handler in handlers]) return handlers @@ -76,7 +77,7 @@ def get_handler(self, handler_name, app_name=None): Returns: A HandoffHandler object where the name attribute is equal to handler_name or None if no HandoffHandler with that name is found or no app with app_name is found. - """ + """ # noqa: E501 manager = self._get_handoff_manager_for_app(app_name) if manager: @@ -96,7 +97,7 @@ def handoff(self, request, handler_name, app_name=None, external_only=True, **kw Returns: HttpResponse object. - """ + """ # noqa: E501 error = {"message": "", "code": 400, @@ -113,10 +114,11 @@ def handoff(self, request, handler_name, app_name=None, external_only=True, **kw urlish = handler(request, **kwargs) return redirect(urlish) except TypeError as e: - error['message'] = "HTTP 400 Bad Request: {0}. ".format(e.message) + error['message'] = "HTTP 400 Bad Request: {0}. ".format(str(e)) return HttpResponseBadRequest(json.dumps(error), content_type='application/javascript') - error['message'] = "HTTP 400 Bad Request: No handoff handler '{0}' for app '{1}' found.".format(manager.app.name, handler_name) + error['message'] = "HTTP 400 Bad Request: No handoff handler '{0}' for app '{1}' found.".\ + format(manager.app.name, handler_name) return HttpResponseBadRequest(json.dumps(error), content_type='application/javascript') def _get_handoff_manager_for_app(self, app_name): @@ -128,13 +130,12 @@ def _get_handoff_manager_for_app(self, app_name): Returns: A HandoffManager object for the app with the name app_name or None if no app with that name is found. - """ - + """ # noqa: E501 if not app_name: return self # Get the app - harvester = tethys_apps.app_harvester.SingletonAppHarvester() + harvester = tethys_apps.harvester.SingletonAppHarvester() apps = harvester.apps for app in apps: @@ -156,10 +157,12 @@ def _get_valid_handlers(self): else: handler_str = handler.handler if ':' in handler_str: - print('DEPRECATION WARNING: The handler attribute of a HandoffHandler should now be in the form: "my_first_app.controllers.my_handler". The form "handoff:my_handler" is now deprecated.') + print('DEPRECATION WARNING: The handler attribute of a HandoffHandler should now be in the ' + 'form: "my_first_app.controllers.my_handler". The form "handoff:my_handler" ' + 'is now deprecated.') # Split into module name and function name - module_path, function_name = handler_str.split(':') + module_path, function_name = handler_str.split(':') # Pre-process handler path full_module_path = '.'.join(('tethys_apps.tethysapp', self.app.package, module_path)) @@ -185,7 +188,7 @@ class HandoffHandler(TethysFunctionExtractor): name(str): Name of the handoff handler. handler(str): Path to the handler function for the handoff interaction. Use dot-notation (e.g.: "foo.bar.function"). internal(bool, optional): Specifies that the handler is only for internal (i.e. within the same Tethys server) purposes. - """ + """ # noqa: E501 def __init__(self, name, handler, internal=False): """ @@ -209,7 +212,7 @@ def __repr__(self): """ return ''.format(self.name, self.handler) - def __json__(self): + def __dict__(self): """ JSON representation """ @@ -233,4 +236,4 @@ def json_arguments(self): if 'request' in args: index = args.index('request') args.pop(index) - return args \ No newline at end of file + return args diff --git a/tethys_apps/base/permissions.py b/tethys_apps/base/permissions.py index d9b2373e9..bf9925888 100644 --- a/tethys_apps/base/permissions.py +++ b/tethys_apps/base/permissions.py @@ -4,7 +4,7 @@ * Author: nswain * Created On: May 09, 2016 * Copyright: (c) Aquaveo 2016 -* License: +* License: ******************************************************************************** """ @@ -40,7 +40,7 @@ def __init__(self, name, description): def _repr(self): return ''.format(self.name, self.description) - def __unicode__(self): + def __str__(self): return self._repr() def __repr__(self): @@ -88,7 +88,7 @@ def __init__(self, name, permissions=[]): def _repr(self): return ''.format(self.name) - def __unicode__(self): + def __str__(self): return self._repr() def __repr__(self): @@ -120,7 +120,7 @@ def my_controller(request): if can_create_projects: ... - """ + """ # noqa: E501 from tethys_apps.utilities import get_active_app app = get_active_app(request) diff --git a/tethys_apps/base/testing/testing.py b/tethys_apps/base/testing/testing.py index 9c98cb7be..7a75a67db 100644 --- a/tethys_apps/base/testing/testing.py +++ b/tethys_apps/base/testing/testing.py @@ -1,3 +1,5 @@ +import logging + from django.test import Client from django.test import TestCase @@ -17,10 +19,12 @@ def setUp(self): from tethys_apps.harvester import SingletonHarvester harvester = SingletonHarvester() harvester.harvest() + logging.disable(logging.CRITICAL) self.set_up() def tearDown(self): self.tear_down() + logging.disable(logging.NOTSET) def set_up(self): """ diff --git a/tethys_apps/base/url_map.py b/tethys_apps/base/url_map.py index 2959b2617..6d6cdd18b 100644 --- a/tethys_apps/base/url_map.py +++ b/tethys_apps/base/url_map.py @@ -28,9 +28,10 @@ def __init__(self, name, url, controller, regex=None): url (str): Url pattern to map to the controller. controller (str): Dot-notation path to the controller. regex (str or iterable, optional): Custom regex pattern(s) for url variables. If a string is provided, it will be applied to all variables. If a list or tuple is provided, they will be applied in variable order. - """ + """ # noqa: E501 # Validate - if regex and (not isinstance(regex, basestring) and not isinstance(regex, tuple) and not isinstance(regex, list)): + if regex and (not isinstance(regex, basestring) and not isinstance(regex, tuple) + and not isinstance(regex, list)): raise ValueError('Value for "regex" must be either a string, list, or tuple.') self.name = name @@ -60,7 +61,7 @@ def django_url_preprocessor(url, root_url, custom_regex=None): e.g.: '/example/resource/{variable_name}/' - r'^/example/resource/?P[1-9A-Za-z\-]+/$' + r'^/example/resource/(?P[0-9A-Za-z\-]+)//$' """ # Split the url into parts @@ -87,13 +88,9 @@ def django_url_preprocessor(url, root_url, custom_regex=None): elif (isinstance(custom_regex, list) or isinstance(custom_regex, tuple)) and len(custom_regex) > 0: try: expression = custom_regex[url_variable_count] - except IndexError: expression = custom_regex[0] - except: - raise - else: expression = DEFAULT_EXPRESSION diff --git a/tethys_apps/base/workspace.py b/tethys_apps/base/workspace.py index adbb6ec14..de8f3a579 100644 --- a/tethys_apps/base/workspace.py +++ b/tethys_apps/base/workspace.py @@ -70,7 +70,8 @@ def files(self, full_path=False): """ if full_path: - files = [os.path.join(self._path, f) for f in os.listdir(self._path) if os.path.isfile(os.path.join(self._path, f))] + files = [os.path.join(self._path, f) for f in os.listdir(self._path) if + os.path.isfile(os.path.join(self._path, f))] else: files = [f for f in os.listdir(self._path) if os.path.isfile(os.path.join(self._path, f))] return files @@ -97,7 +98,8 @@ def directories(self, full_path=False): """ if full_path: - directories = [os.path.join(self._path, d) for d in os.listdir(self._path) if os.path.isdir(os.path.join(self._path, d))] + directories = [os.path.join(self._path, d) for d in os.listdir(self._path) if + os.path.isdir(os.path.join(self._path, d))] else: directories = [d for d in os.listdir(self._path) if os.path.isdir(os.path.join(self._path, d))] return directories @@ -163,11 +165,10 @@ def remove(self, item): **Note:** Though you can specify relative paths, the ``remove()`` method will not allow you to back into other directories using "../" or similar notation. Futhermore, absolute paths given must contain the path of the workspace to be valid. - """ + """ # noqa: E501 # Sanitize to prevent backing into other directories or entering the home directory - full_path = item.replace('../', '').replace('./', '').\ - replace('..\\', '').replace('.\\', '').\ - replace('~/', '').replace('~\\', '') + full_path = item.replace('../', '').replace('./', '').replace('..\\', '').\ + replace('.\\', '').replace('~/', '').replace('~\\', '') if self._path not in full_path: full_path = os.path.join(self._path, full_path) @@ -175,4 +176,4 @@ def remove(self, item): if os.path.isdir(full_path): shutil.rmtree(full_path) elif os.path.isfile(full_path): - os.remove(full_path) \ No newline at end of file + os.remove(full_path) diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index 41b76fb7d..d2260a189 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -9,204 +9,31 @@ """ # Commandline interface for Tethys import argparse -import os -import subprocess -import webbrowser - -from builtins import input +from tethys_apps.cli.docker_commands import docker_command +from tethys_apps.cli.list_command import list_command as lc from tethys_apps.cli.scaffold_commands import scaffold_command -from tethys_apps.terminal_colors import TerminalColors -from .docker_commands import * -from .gen_commands import GEN_SETTINGS_OPTION, GEN_APACHE_OPTION, generate_command -from .manage_commands import (manage_command, get_manage_path, run_process, - MANAGE_START, MANAGE_SYNCDB, - MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_SYNC, - MANAGE_COLLECT, MANAGE_CREATESUPERUSER, TETHYS_SRC_DIRECTORY) -from .services_commands import (SERVICES_CREATE, SERVICES_CREATE_PERSISTENT, SERVICES_CREATE_SPATIAL, SERVICES_LINK, - services_create_persistent_command, services_create_spatial_command, - services_list_command, services_remove_persistent_command, - services_remove_spatial_command) -from .link_commands import link_command -from .app_settings_commands import (app_settings_list_command, app_settings_create_ps_database_command, - app_settings_remove_command) -from .scheduler_commands import scheduler_create_command, schedulers_list_command, schedulers_remove_command -from .gen_commands import VALID_GEN_OBJECTS, generate_command -from tethys_apps.helpers import get_installed_tethys_apps, get_installed_tethys_extensions +from tethys_apps.cli.syncstores_command import syncstores_command as syc +from tethys_apps.cli.test_command import test_command as tstc +from tethys_apps.cli.uninstall_command import uninstall_command as uc +from tethys_apps.cli.docker_commands import N52WPS_INPUT, GEOSERVER_INPUT, POSTGIS_INPUT +from tethys_apps.cli.manage_commands import (manage_command, MANAGE_START, MANAGE_SYNCDB, + MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_SYNC, + MANAGE_COLLECT, MANAGE_CREATESUPERUSER) +from tethys_apps.cli.services_commands import (services_create_persistent_command, services_create_spatial_command, + services_list_command, services_remove_persistent_command, + services_remove_spatial_command) +from tethys_apps.cli.link_commands import link_command +from tethys_apps.cli.app_settings_commands import (app_settings_list_command, app_settings_create_ps_database_command, + app_settings_remove_command) +from tethys_apps.cli.scheduler_commands import (scheduler_create_command, schedulers_list_command, + schedulers_remove_command) +from tethys_apps.cli.gen_commands import VALID_GEN_OBJECTS, generate_command # Module level variables PREFIX = 'tethysapp' -def uninstall_command(args): - """ - Uninstall an app command. - """ - # Get the path to manage.py - manage_path = get_manage_path(args) - item_name = args.app_or_extension - process = ['python', manage_path, 'tethys_app_uninstall', item_name] - if args.is_extension: - process.append('-e') - try: - subprocess.call(process) - except KeyboardInterrupt: - pass - - -def list_command(args): - """ - List installed apps. - """ - installed_apps = get_installed_tethys_apps() - installed_extensions = get_installed_tethys_extensions() - - if installed_apps: - print('Apps:') - for item in installed_apps: - print(' {}'.format(item)) - - if installed_extensions: - print('Extensions:') - for item in installed_extensions: - print(' {}'.format(item)) - - -def docker_command(args): - """ - Docker management commands. - """ - if args.command == 'init': - docker_init(containers=args.containers, defaults=args.defaults) - - elif args.command == 'start': - docker_start(containers=args.containers) - - elif args.command == 'stop': - docker_stop(containers=args.containers, boot2docker=args.boot2docker) - - elif args.command == 'status': - docker_status() - - elif args.command == 'update': - docker_update(containers=args.containers, defaults=args.defaults) - - elif args.command == 'remove': - docker_remove(containers=args.containers) - - elif args.command == 'ip': - docker_ip() - - elif args.command == 'restart': - docker_restart(containers=args.containers) - - -def syncstores_command(args): - """ - Sync persistent stores. - """ - # Get the path to manage.py - manage_path = get_manage_path(args) - - # This command is a wrapper for a custom Django manage.py method called syncstores. - # See tethys_apps.mangement.commands.syncstores - process = ['python', manage_path, 'syncstores'] - - if args.refresh: - valid_inputs = ('y', 'n', 'yes', 'no') - no_inputs = ('n', 'no') - proceed = input('{1}WARNING:{2} You have specified the database refresh option. This will drop all of the ' - 'databases for the following apps: {0}. This could result in significant data loss and ' - 'cannot be undone. Do you wish to continue? (y/n): '.format(', '.join(args.app), - TerminalColors.WARNING, - TerminalColors.ENDC)).lower() - - while proceed not in valid_inputs: - proceed = input('Invalid option. Do you wish to continue? (y/n): ').lower() - - if proceed not in no_inputs: - process.extend(['-r']) - else: - print('Operation cancelled by user.') - exit(0) - - if args.firsttime: - process.extend(['-f']) - - if args.database: - process.extend(['-d', args.database]) - - if args.app: - process.extend(args.app) - - try: - subprocess.call(process) - except KeyboardInterrupt: - pass - - -def test_command(args): - args.manage = False - # Get the path to manage.py - manage_path = get_manage_path(args) - tests_path = os.path.join(TETHYS_SRC_DIRECTORY, 'tests') - - # Define the process to be run - primary_process = ['python', manage_path, 'test'] - - # Tag to later check if tests are being run on a specific app or extension - app_package_tag = 'tethys_apps.tethysapp.' - extension_package_tag = 'tethysext.' - - if args.coverage or args.coverage_html: - os.environ['TETHYS_TEST_DIR'] = tests_path - if args.file and app_package_tag in args.file: - app_package_parts = args.file.split(app_package_tag) - app_name = app_package_parts[1].split('.')[0] - core_app_package = '{}{}'.format(app_package_tag, app_name) - app_package = 'tethysapp.{}'.format(app_name) - config_opt = '--source={},{}'.format(core_app_package, app_package) - elif args.file and extension_package_tag in args.file: - extension_package_parts = args.file.split(extension_package_tag) - extension_name = extension_package_parts[1].split('.')[0] - core_extension_package = '{}{}'.format(extension_package_tag, extension_name) - extension_package = 'tethysext.{}'.format(extension_name) - config_opt = '--source={},{}'.format(core_extension_package, extension_package) - else: - config_opt = '--rcfile={0}'.format(os.path.join(tests_path, 'coverage.cfg')) - primary_process = ['coverage', 'run', config_opt, manage_path, 'test'] - - if args.file: - primary_process.append(args.file) - elif args.unit: - primary_process.append(os.path.join(tests_path, 'unit_tests')) - elif args.gui: - primary_process.append(os.path.join(tests_path, 'gui_tests')) - - # print(primary_process) - run_process(primary_process) - if args.coverage: - if args.file and (app_package_tag in args.file or extension_package_tag in args.file): - run_process(['coverage', 'report']) - else: - run_process(['coverage', 'report', config_opt]) - if args.coverage_html: - report_dirname = 'coverage_html_report' - index_fname = 'index.html' - - if args.file and (app_package_tag in args.file or extension_package_tag in args.file): - run_process(['coverage', 'html', '--directory={0}'.format(os.path.join(tests_path, report_dirname))]) - else: - run_process(['coverage', 'html', config_opt]) - - try: - status = run_process(['open', os.path.join(tests_path, report_dirname, index_fname)]) - if status != 0: - raise Exception - except: - webbrowser.open_new_tab(os.path.join(tests_path, report_dirname, index_fname)) - - def tethys_command(): """ Tethys commandline interface function. @@ -233,7 +60,15 @@ def tethys_command(): gen_parser.add_argument('type', help='The type of object to generate.', choices=VALID_GEN_OBJECTS) gen_parser.add_argument('-d', '--directory', help='Destination directory for the generated object.') gen_parser.add_argument('--allowed-host', dest='allowed_host', - help='Hostname or IP address to add to allowed hosts in the settings file.') + help='Single hostname or IP address to add to allowed hosts in the settings file. ' + 'e.g.: 127.0.0.1') + gen_parser.add_argument('--allowed-hosts', dest='allowed_hosts', + help='A list of hostnames or IP addresses to add to allowed hosts in the settings file. ' + 'e.g.: "[\'127.0.0.1\', \'localhost\']"') + gen_parser.add_argument('--client-max-body-size', dest='client_max_body_size', + help='Populates the client_max_body_size parameter for nginx config. Defaults to "75M".') + gen_parser.add_argument('--uwsgi-processes', dest='uwsgi_processes', + help='The maximum number of uwsgi worker processes. Defaults to 10.') gen_parser.add_argument('--db-username', dest='db_username', help='Username for the Tethys Database server to be set in the settings file.') gen_parser.add_argument('--db-password', dest='db_password', @@ -244,8 +79,9 @@ def tethys_command(): help='Generate a new settings file for a production server.') gen_parser.add_argument('--overwrite', dest='overwrite', action='store_true', help='Overwrite existing file without prompting.') - gen_parser.set_defaults(func=generate_command, allowed_host=None, db_username='tethys_default', - db_password='pass', db_port=5436, production=False, overwrite=False) + gen_parser.set_defaults(func=generate_command, allowed_host=None, allowed_hosts=None, client_max_body_size='75M', + uwsgi_processes=10, db_username='tethys_default', db_password='pass', db_port=5436, + production=False, overwrite=False) # Setup start server command manage_parser = subparsers.add_parser('manage', help='Management commands for Tethys Platform.') @@ -253,8 +89,10 @@ def tethys_command(): choices=[MANAGE_START, MANAGE_SYNCDB, MANAGE_COLLECTSTATIC, MANAGE_COLLECTWORKSPACES, MANAGE_COLLECT, MANAGE_CREATESUPERUSER, MANAGE_SYNC]) manage_parser.add_argument('-m', '--manage', help='Absolute path to manage.py for Tethys Platform installation.') - manage_parser.add_argument('-p', '--port', type=str, help='Host and/or port on which to bind the development server.') - manage_parser.add_argument('--noinput', action='store_true', help='Pass the --noinput argument to the manage.py command.') + manage_parser.add_argument('-p', '--port', type=str, + help='Host and/or port on which to bind the development server.') + manage_parser.add_argument('--noinput', action='store_true', + help='Pass the --noinput argument to the manage.py command.') manage_parser.add_argument('-f', '--force', required=False, action='store_true', help='Used only with {} to force the overwrite the app directory into its collect-to ' 'location.') @@ -400,7 +238,8 @@ def tethys_command(): '":" ' '(i.e. "persistent_connection:super_conn")') link_parser.add_argument('setting', help='Setting of an app with which to link the specified service.' - 'Of the form "::' + 'Of the form ":' + ':' '" (i.e. "epanet:database:epanet_2")') link_parser.set_defaults(func=link_command) @@ -415,18 +254,18 @@ def tethys_command(): 'If both flags are set then -u takes precedence.', action='store_true') test_parser.add_argument('-f', '--file', type=str, help='File to run tests in. Overrides -g and -u.') - test_parser.set_defaults(func=test_command) + test_parser.set_defaults(func=tstc) # Setup uninstall command uninstall_parser = subparsers.add_parser('uninstall', help='Uninstall an app.') uninstall_parser.add_argument('app_or_extension', help='Name of the app or extension to uninstall.') uninstall_parser.add_argument('-e', '--extension', dest='is_extension', default=False, action='store_true', help='Flag to denote an extension is being uninstalled') - uninstall_parser.set_defaults(func=uninstall_command) + uninstall_parser.set_defaults(func=uc) # Setup list command list_parser = subparsers.add_parser('list', help='List installed apps and extensions.') - list_parser.set_defaults(func=list_command) + list_parser.set_defaults(func=lc) # Sync stores command syncstores_parser = subparsers.add_parser('syncstores', help='Management command for App Persistent Stores.') @@ -444,8 +283,9 @@ def tethys_command(): action='store_true', dest='firsttime') syncstores_parser.add_argument('-d', '--database', help='Name of database to sync.') - syncstores_parser.add_argument('-m', '--manage', help='Absolute path to manage.py for Tethys Platform installation.') - syncstores_parser.set_defaults(func=syncstores_command, refresh=False, firstime=False) + syncstores_parser.add_argument('-m', '--manage', help='Absolute path to manage.py for ' + 'Tethys Platform installation.') + syncstores_parser.set_defaults(func=syc, refresh=False, firstime=False) # Setup the docker commands docker_parser = subparsers.add_parser('docker', help="Management commands for the Tethys Docker containers.") @@ -468,4 +308,4 @@ def tethys_command(): # Parse the args and call the default function args = parser.parse_args() - args.func(args) \ No newline at end of file + args.func(args) diff --git a/tethys_apps/cli/app_settings_commands.py b/tethys_apps/cli/app_settings_commands.py index 19955391b..82f0c5de8 100644 --- a/tethys_apps/cli/app_settings_commands.py +++ b/tethys_apps/cli/app_settings_commands.py @@ -1,6 +1,6 @@ from django.core.exceptions import ObjectDoesNotExist -from .cli_colors import * +from tethys_apps.cli.cli_colors import pretty_output, BOLD, FG_RED def app_settings_list_command(args): @@ -29,7 +29,7 @@ def app_settings_list_command(args): linked_settings = [] for setting in app_settings: - if hasattr(setting, 'spatial_dataset_service') and setting.dataset_service \ + if hasattr(setting, 'spatial_dataset_service') and setting.spatial_dataset_service \ or hasattr(setting, 'persistent_store_service') and setting.persistent_store_service: linked_settings.append(setting) else: @@ -39,7 +39,8 @@ def app_settings_list_command(args): p.write("\nUnlinked Settings:") if len(unlinked_settings) == 0: - print('None') + with pretty_output() as p: + p.write('None') else: is_first_row = True for setting in unlinked_settings: @@ -47,13 +48,16 @@ def app_settings_list_command(args): with pretty_output(BOLD) as p: p.write('{0: <10}{1: <40}{2: <15}'.format('ID', 'Name', 'Type')) is_first_row = False - print('{0: <10}{1: <40}{2: <15}'.format(setting.pk, setting.name, setting_type_dict[type(setting)])) + with pretty_output() as p: + p.write('{0: <10}{1: <40}{2: <15}'.format(setting.pk, setting.name, + setting_type_dict[type(setting)])) with pretty_output(BOLD) as p: p.write("\nLinked Settings:") if len(linked_settings) == 0: - print('None') + with pretty_output() as p: + p.write('None') else: is_first_row = True for setting in linked_settings: @@ -69,13 +73,13 @@ def app_settings_list_command(args): with pretty_output(FG_RED) as p: p.write('The app you specified ("{0}") does not exist. Command aborted.'.format(app_package)) except Exception as e: - print(e) with pretty_output(FG_RED) as p: + p.write(e) p.write('Something went wrong. Please try again.') def app_settings_create_ps_database_command(args): - from ..utilities import create_ps_database_setting + from tethys_apps.utilities import create_ps_database_setting app_package = args.app setting_name = args.name setting_description = args.description @@ -85,8 +89,8 @@ def app_settings_create_ps_database_command(args): spatial = args.spatial dynamic = args.dynamic - success = create_ps_database_setting(app_package, setting_name, setting_description or '', required, initializer or '', - initialized, spatial, dynamic) + success = create_ps_database_setting(app_package, setting_name, setting_description or '', + required, initializer or '', initialized, spatial, dynamic) if not success: exit(1) @@ -95,7 +99,7 @@ def app_settings_create_ps_database_command(args): def app_settings_remove_command(args): - from ..utilities import remove_ps_database_setting + from tethys_apps.utilities import remove_ps_database_setting app_package = args.app setting_name = args.name force = args.force diff --git a/tethys_apps/cli/cli_colors.py b/tethys_apps/cli/cli_colors.py index 0ee82bcdb..9acbe5978 100644 --- a/tethys_apps/cli/cli_colors.py +++ b/tethys_apps/cli/cli_colors.py @@ -60,6 +60,13 @@ BG_CYAN = '\033[46m' BG_WHITE = '\033[47m' +# TerminalColors colors +TC_BLUE = '\033[94m' +TC_GREEN = '\033[92m' +TC_WARNING = '\033[93m' +TC_FAIL = '\033[91m' +TC_ENDC = '\033[0m' + class pretty_output: ''' @@ -78,24 +85,3 @@ def __exit__(self, type, value, traceback): def write(self, msg): style = ''.join(self.attributes) print('{}{}{}'.format(style, msg.replace(END, ALL_OFF + style), ALL_OFF)) - - -if __name__ == '__main__': - - with pretty_output(FG_RED) as out: - out.write('This is a test in RED') - - with pretty_output(FG_BLUE) as out: - out.write('This is a test in BLUE') - - with pretty_output(BOLD, FG_GREEN) as out: - out.write('This is a bold text in green') - - with pretty_output(BOLD, BG_GREEN) as out: - out.write('This is a text with green background') - - with pretty_output(FG_GREEN) as out: - out.write('This is a green text with ' + BOLD + 'bold' + END + ' text included') - - with pretty_output() as out: - out.write(BOLD + 'Use this' + END + ' even with ' + BOLD + FG_RED + 'no parameters' + END + ' in the with statement') \ No newline at end of file diff --git a/tethys_apps/cli/docker_commands.py b/tethys_apps/cli/docker_commands.py index f4dbd2c13..c67dc38fd 100644 --- a/tethys_apps/cli/docker_commands.py +++ b/tethys_apps/cli/docker_commands.py @@ -9,7 +9,7 @@ """ try: import curses -except: +except Exception: pass # curses not available on Windows from builtins import input import platform @@ -22,6 +22,7 @@ from docker.utils import kwargs_from_env, compare_version, create_host_config from docker.client import Client as DockerClient from docker.constants import DEFAULT_DOCKER_API_VERSION as MAX_CLIENT_DOCKER_API_VERSION +from tethys_apps.cli.cli_colors import pretty_output, FG_WHITE __all__ = ['docker_init', 'docker_start', @@ -112,7 +113,7 @@ def validate_numeric_cli_input(value, default=None, max=None): continue if max is not None: - if float(value) > max: + if float(value) > float(max): if default is not None: value = input('Maximum allowed value is {0} [{1}]: '.format(max, default)) else: @@ -151,7 +152,8 @@ def validate_directory_cli_input(value, default=None): try: os.makedirs(value) except OSError as e: - print('{0}: {1}'.format(repr(e), value)) + with pretty_output(FG_WHITE) as p: + p.write('{0}: {1}'.format(repr(e), value)) prompt = 'Please provide a valid directory' prompt = add_default_to_prompt(prompt, default) prompt = close_prompt(prompt) @@ -192,12 +194,14 @@ def get_docker_client(): # Start the boot2docker VM if it is not already running if boot2docker_info['State'] != "running": - print('Starting Boot2Docker VM:') + with pretty_output(FG_WHITE) as p: + p.write('Starting Boot2Docker VM:') # Start up the Docker VM process = ['boot2docker', 'start'] subprocess.call(process) - if ('DOCKER_HOST' not in os.environ) or ('DOCKER_CERT_PATH' not in os.environ) or ('DOCKER_TLS_VERIFY' not in os.environ): + if ('DOCKER_HOST' not in os.environ) or ('DOCKER_CERT_PATH' not in os.environ) or \ + ('DOCKER_TLS_VERIFY' not in os.environ): # Get environmental variable values process = ['boot2docker', 'shellinit'] p = subprocess.Popen(process, stdout=PIPE) @@ -227,7 +231,8 @@ def get_docker_client(): # Then test to see if the Docker is running a later version than the minimum # See: https://github.com/docker/docker-py/issues/439 version_client = DockerClient(**client_kwargs) - client_kwargs['version'] = get_api_version(MAX_CLIENT_DOCKER_API_VERSION, version_client.version()['ApiVersion']) + client_kwargs['version'] = get_api_version(MAX_CLIENT_DOCKER_API_VERSION, + version_client.version()['ApiVersion']) # Create Real Docker client docker_client = DockerClient(**client_kwargs) @@ -249,9 +254,6 @@ def get_docker_client(): return docker_client - except: - raise - def stop_boot2docker(): """ @@ -260,13 +262,11 @@ def stop_boot2docker(): try: process = ['boot2docker', 'stop'] subprocess.call(process) - print('Boot2Docker VM Stopped') + with pretty_output(FG_WHITE) as p: + p.write('Boot2Docker VM Stopped') except OSError: pass - except: - raise - def get_images_to_install(docker_client, containers=ALL_DOCKER_INPUTS): """ @@ -341,8 +341,12 @@ def log_pull_stream(stream): current_status = json_line['status'] if 'status' in json_line else '' current_progress = json_line['progress'] if 'progress' in json_line else '' - print("{id}{status} {progress}".format(id=current_id, status=current_status, - progress=current_progress)) + with pretty_output(FG_WHITE) as p: + p.write("{id}{status} {progress}".format( + id=current_id, + status=current_status, + progress=current_progress + )) else: TERMINAL_STATUSES = ['Already exists', 'Download complete', 'Pull complete'] @@ -385,8 +389,11 @@ def log_pull_stream(stream): 'progress': current_progress} else: # add all other messages to list to show above progress messages - message_log.append("{id}: {status} {progress}".format(id=current_id, status=current_status, - progress=current_progress)) + message_log.append("{id}: {status} {progress}".format( + id=current_id, + status=current_status, + progress=current_progress + )) # remove messages from progress that have completed if current_id in progress_messages: @@ -424,7 +431,8 @@ def log_pull_stream(stream): curses.nocbreak() curses.endwin() - print('\n'.join(messages_to_print)) + with pretty_output(FG_WHITE) as p: + p.write('\n'.join(messages_to_print)) def get_docker_container_dicts(docker_client): @@ -514,7 +522,8 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ # PostGIS if POSTGIS_CONTAINER in containers_to_create or force: - print("\nInstalling the PostGIS Docker container...") + with pretty_output(FG_WHITE) as p: + p.write("\nInstalling the PostGIS Docker container...") # Default environmental vars tethys_default_pass = 'pass' @@ -523,8 +532,9 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ # User environmental variables if not defaults: - print("Provide passwords for the three Tethys database users or press enter to accept the default " - "passwords shown in square brackets:") + with pretty_output(FG_WHITE) as p: + p.write("Provide passwords for the three Tethys database users or press enter to accept the default " + "passwords shown in square brackets:") # tethys_default tethys_default_pass_1 = getpass.getpass('Password for "tethys_default" database user [pass]: ') @@ -533,7 +543,8 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ tethys_default_pass_2 = getpass.getpass('Confirm password for "tethys_default" database user: ') while tethys_default_pass_1 != tethys_default_pass_2: - print('Passwords do not match, please try again: ') + with pretty_output(FG_WHITE) as p: + p.write('Passwords do not match, please try again: ') tethys_default_pass_1 = getpass.getpass('Password for "tethys_default" database user [pass]: ') tethys_default_pass_2 = getpass.getpass('Confirm password for "tethys_default" database user: ') @@ -548,9 +559,12 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ tethys_db_manager_pass_2 = getpass.getpass('Confirm password for "tethys_db_manager" database user: ') while tethys_db_manager_pass_1 != tethys_db_manager_pass_2: - print('Passwords do not match, please try again: ') - tethys_db_manager_pass_1 = getpass.getpass('Password for "tethys_db_manager" database user [pass]: ') - tethys_db_manager_pass_2 = getpass.getpass('Confirm password for "tethys_db_manager" database user: ') + with pretty_output(FG_WHITE) as p: + p.write('Passwords do not match, please try again: ') + tethys_db_manager_pass_1 = getpass.getpass('Password for "tethys_db_manager" database user ' + '[pass]: ') + tethys_db_manager_pass_2 = getpass.getpass('Confirm password for "tethys_db_manager" database ' + 'user: ') tethys_db_manager_pass = tethys_db_manager_pass_1 else: @@ -563,7 +577,8 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ tethys_super_pass_2 = getpass.getpass('Confirm password for "tethys_super" database user: ') while tethys_super_pass_1 != tethys_super_pass_2: - print('Passwords do not match, please try again: ') + with pretty_output(FG_WHITE) as p: + p.write('Passwords do not match, please try again: ') tethys_super_pass_1 = getpass.getpass('Password for "tethys_super" database user [pass]: ') tethys_super_pass_2 = getpass.getpass('Confirm password for "tethys_super" database user: ') @@ -581,7 +596,8 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ # GeoServer if GEOSERVER_CONTAINER in containers_to_create or force: - print("\nInstalling the GeoServer Docker container...") + with pretty_output(FG_WHITE) as p: + p.write("\nInstalling the GeoServer Docker container...") if "cluster" in GEOSERVER_IMAGE: @@ -589,8 +605,9 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ # Environmental variables from user input environment = dict() - print("The GeoServer docker can be configured to run in a clustered mode (multiple instances of " - "GeoServer running in the docker container) for better performance.\n") + with pretty_output(FG_WHITE) as p: + p.write("The GeoServer docker can be configured to run in a clustered mode (multiple instances of " + "GeoServer running in the docker container) for better performance.\n") enabled_nodes = input('Number of GeoServer Instances Enabled (max 4) [1]: ') environment['ENABLED_NODES'] = validate_numeric_cli_input(enabled_nodes, 1, 4) @@ -600,9 +617,11 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ max_rest_nodes)) environment['REST_NODES'] = validate_numeric_cli_input(rest_nodes, 1, max_rest_nodes) - print("\nGeoServer can be configured with limits to certain types of requests to prevent it from " - "becoming overwhelmed. This can be done automatically based on a number of processors or each " - "limit can be set explicitly.\n") + with pretty_output(FG_WHITE) as p: + p.write("\nGeoServer can be configured with limits to certain types of requests to prevent it from " + "becoming overwhelmed. This can be done automatically based on a number of processors or " + "each " + "limit can be set explicitly.\n") flow_control_mode = input('Would you like to specify number of Processors (c) OR set ' 'limits explicitly (e) [C/e]: ') @@ -614,7 +633,7 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ else: max_ows_global = input('Maximum number of simultaneous OGC web service requests ' - '(e.g.: WMS, WCS, WFS) [100]: ') + '(e.g.: WMS, WCS, WFS) [100]: ') environment['MAX_OWS_GLOBAL'] = validate_numeric_cli_input(max_ows_global, '100') max_wms_getmap = input('Maximum number of simultaneous GetMap requests [8]: ') @@ -676,10 +695,10 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ } host_config = create_host_config( - binds=[ - '/usr/lib/tethys/geoserver/data:/var/geoserver/data' - ] - ) + binds=[ + '/usr/lib/tethys/geoserver/data:/var/geoserver/data' + ] + ) docker_client.create_container( name=GEOSERVER_CONTAINER, @@ -697,7 +716,8 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ # 52 North WPS if N52WPS_CONTAINER in containers_to_create or force: - print("\nInstalling the 52 North WPS Docker container...") + with pretty_output(FG_WHITE) as p: + p.write("\nInstalling the 52 North WPS Docker container...") # Default environmental vars name = 'NONE' @@ -714,8 +734,9 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ password = 'wps' if not defaults: - print("Provide contact information for the 52 North Web Processing Service or press enter to accept the " - "defaults shown in square brackets: ") + with pretty_output(FG_WHITE) as p: + p.write("Provide contact information for the 52 North Web Processing Service or press enter to accept " + "the defaults shown in square brackets: ") name = input('Name [NONE]: ') if name == '': @@ -771,7 +792,8 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ password_2 = getpass.getpass('Confirm Password: ') while password_1 != password_2: - print('Passwords do not match, please try again.') + with pretty_output(FG_WHITE) as p: + p.write('Passwords do not match, please try again.') password_1 = getpass.getpass('Admin Password [wps]: ') password_2 = getpass.getpass('Confirm Password: ') @@ -794,7 +816,8 @@ def install_docker_containers(docker_client, force=False, containers=ALL_DOCKER_ 'PASSWORD': password} ) - print("Finished installing Docker containers.") + with pretty_output(FG_WHITE) as p: + p.write("Finished installing Docker containers.") def start_docker_containers(docker_client, containers=ALL_DOCKER_INPUTS): @@ -810,22 +833,24 @@ def start_docker_containers(docker_client, containers=ALL_DOCKER_INPUTS): # Start PostGIS try: if not container_status[POSTGIS_CONTAINER] and container == POSTGIS_INPUT: - print('Starting PostGIS container...') + with pretty_output(FG_WHITE) as p: + p.write('Starting PostGIS container...') docker_client.start(container=POSTGIS_CONTAINER, # restart_policy='always', port_bindings={5432: DEFAULT_POSTGIS_PORT}) elif container == POSTGIS_INPUT: - print('PostGIS container already running...') + with pretty_output(FG_WHITE) as p: + p.write('PostGIS container already running...') except KeyError: if container == POSTGIS_INPUT: - print('PostGIS container not installed...') - except: - raise + with pretty_output(FG_WHITE) as p: + p.write('PostGIS container not installed...') try: if not container_status[GEOSERVER_CONTAINER] and container == GEOSERVER_INPUT: # Start GeoServer - print('Starting GeoServer container...') + with pretty_output(FG_WHITE) as p: + p.write('Starting GeoServer container...') if 'cluster' in container_images[GEOSERVER_CONTAINER]: docker_client.start(container=GEOSERVER_CONTAINER, # restart_policy='always', @@ -839,27 +864,28 @@ def start_docker_containers(docker_client, containers=ALL_DOCKER_INPUTS): # restart_policy='always', port_bindings={8080: DEFAULT_GEOSERVER_PORT}) elif not container or container == GEOSERVER_INPUT: - print('GeoServer container already running...') + with pretty_output(FG_WHITE) as p: + p.write('GeoServer container already running...') except KeyError: if container == GEOSERVER_INPUT: - print('GeoServer container not installed...') - except: - raise + with pretty_output(FG_WHITE) as p: + p.write('GeoServer container not installed...') try: if not container_status[N52WPS_CONTAINER] and container == N52WPS_INPUT: # Start 52 North WPS - print('Starting 52 North WPS container...') + with pretty_output(FG_WHITE) as p: + p.write('Starting 52 North WPS container...') docker_client.start(container=N52WPS_CONTAINER, # restart_policy='always', port_bindings={8080: DEFAULT_N52WPS_PORT}) elif container == N52WPS_INPUT: - print('52 North WPS container already running...') + with pretty_output(FG_WHITE) as p: + p.write('52 North WPS container already running...') except KeyError: if not container or container == N52WPS_INPUT: - print('52 North WPS container not installed...') - except: - raise + with pretty_output(FG_WHITE) as p: + p.write('52 North WPS container not installed...') def stop_docker_containers(docker_client, silent=False, containers=ALL_DOCKER_INPUTS): @@ -874,49 +900,52 @@ def stop_docker_containers(docker_client, silent=False, containers=ALL_DOCKER_IN try: if container_status[POSTGIS_CONTAINER] and container == POSTGIS_INPUT: if not silent: - print('Stopping PostGIS container...') + with pretty_output(FG_WHITE) as p: + p.write('Stopping PostGIS container...') docker_client.stop(container=POSTGIS_CONTAINER) elif not silent and container == POSTGIS_INPUT: - print('PostGIS container already stopped.') + with pretty_output(FG_WHITE) as p: + p.write('PostGIS container already stopped.') except KeyError: if not container or container == POSTGIS_INPUT: - print('PostGIS container not installed...') - except: - raise + with pretty_output(FG_WHITE) as p: + p.write('PostGIS container not installed...') # Stop GeoServer try: if container_status[GEOSERVER_CONTAINER] and container == GEOSERVER_INPUT: if not silent: - print('Stopping GeoServer container...') + with pretty_output(FG_WHITE) as p: + p.write('Stopping GeoServer container...') docker_client.stop(container=GEOSERVER_CONTAINER) elif not silent and container == GEOSERVER_INPUT: - print('GeoServer container already stopped.') + with pretty_output(FG_WHITE) as p: + p.write('GeoServer container already stopped.') except KeyError: if not container or container == GEOSERVER_INPUT: - print('GeoServer container not installed...') - except: - raise + with pretty_output(FG_WHITE) as p: + p.write('GeoServer container not installed...') # Stop 52 North WPS try: if container_status[N52WPS_CONTAINER] and container == N52WPS_INPUT: if not silent: - print('Stopping 52 North WPS container...') + with pretty_output(FG_WHITE) as p: + p.write('Stopping 52 North WPS container...') docker_client.stop(container=N52WPS_CONTAINER) elif not silent and container == N52WPS_INPUT: - print('52 North WPS container already stopped.') + with pretty_output(FG_WHITE) as p: + p.write('52 North WPS container already stopped.') except KeyError: if not container or container == N52WPS_INPUT: - print('52 North WPS container not installed...') - except: - raise + with pretty_output(FG_WHITE) as p: + p.write('52 North WPS container not installed...') def remove_docker_containers(docker_client, containers=ALL_DOCKER_INPUTS): @@ -929,17 +958,20 @@ def remove_docker_containers(docker_client, containers=ALL_DOCKER_INPUTS): for container in containers: # Remove PostGIS if container == POSTGIS_INPUT and POSTGIS_CONTAINER not in containers_not_installed: - print('Removing PostGIS...') + with pretty_output(FG_WHITE) as p: + p.write('Removing PostGIS...') docker_client.remove_container(container=POSTGIS_CONTAINER) # Remove GeoServer if container == GEOSERVER_INPUT and GEOSERVER_CONTAINER not in containers_not_installed: - print('Removing GeoServer...') + with pretty_output(FG_WHITE) as p: + p.write('Removing GeoServer...') docker_client.remove_container(container=GEOSERVER_CONTAINER, v=True) # Remove 52 North WPS if container == N52WPS_INPUT and N52WPS_CONTAINER not in containers_not_installed: - print('Removing 52 North WPS...') + with pretty_output(FG_WHITE) as p: + p.write('Removing 52 North WPS...') docker_client.remove_container(container=N52WPS_CONTAINER) @@ -955,9 +987,11 @@ def docker_init(containers=None, defaults=False): images_to_install = get_images_to_install(docker_client, containers=containers) if len(images_to_install) < 1: - print("Docker images already pulled.") + with pretty_output(FG_WHITE) as p: + p.write("Docker images already pulled.") else: - print("Pulling Docker images...") + with pretty_output(FG_WHITE) as p: + p.write("Pulling Docker images...") # Pull the Docker images for image in images_to_install: @@ -1038,27 +1072,36 @@ def docker_status(): # PostGIS if POSTGIS_CONTAINER in container_status and container_status[POSTGIS_CONTAINER]: - print('PostGIS/Database: Running') + with pretty_output(FG_WHITE) as p: + p.write('PostGIS/Database: Running') elif POSTGIS_CONTAINER in container_status and not container_status[POSTGIS_CONTAINER]: - print('PostGIS/Database: Stopped') + with pretty_output(FG_WHITE) as p: + p.write('PostGIS/Database: Stopped') else: - print('PostGIS/Database: Not Installed') + with pretty_output(FG_WHITE) as p: + p.write('PostGIS/Database: Not Installed') # GeoServer if GEOSERVER_CONTAINER in container_status and container_status[GEOSERVER_CONTAINER]: - print('GeoServer: Running') + with pretty_output(FG_WHITE) as p: + p.write('GeoServer: Running') elif GEOSERVER_CONTAINER in container_status and not container_status[GEOSERVER_CONTAINER]: - print('GeoServer: Stopped') + with pretty_output(FG_WHITE) as p: + p.write('GeoServer: Stopped') else: - print('GeoServer: Not Installed') + with pretty_output(FG_WHITE) as p: + p.write('GeoServer: Not Installed') # 52 North WPS if N52WPS_CONTAINER in container_status and container_status[N52WPS_CONTAINER]: - print('52 North WPS: Running') + with pretty_output(FG_WHITE) as p: + p.write('52 North WPS: Running') elif N52WPS_CONTAINER in container_status and not container_status[N52WPS_CONTAINER]: - print('52 North WPS: Stopped') + with pretty_output(FG_WHITE) as p: + p.write('52 North WPS: Stopped') else: - print('52 North WPS: Not Installed') + with pretty_output(FG_WHITE) as p: + p.write('52 North WPS: Not Installed') def docker_update(containers=None, defaults=False): @@ -1113,50 +1156,82 @@ def docker_ip(): if container_status[POSTGIS_CONTAINER]: postgis_container = containers[POSTGIS_CONTAINER] postgis_port = postgis_container['Ports'][0]['PublicPort'] - print('\nPostGIS/Database:') - print(' Host: {0}'.format(docker_host)) - print(' Port: {0}'.format(postgis_port)) + with pretty_output(FG_WHITE) as p: + p.write('\nPostGIS/Database:') + p.write(' Host: {0}'.format(docker_host)) + p.write(' Port: {0}'.format(postgis_port)) else: - print('\nPostGIS/Database: Not Running.') + with pretty_output(FG_WHITE) as p: + p.write('\nPostGIS/Database: Not Running.') except KeyError: # If key error is raised, it is likely not installed. - print('\nPostGIS/Database: Not Installed.') - except: - raise + with pretty_output(FG_WHITE) as p: + p.write('\nPostGIS/Database: Not Installed.') # GeoServer try: if container_status[GEOSERVER_CONTAINER]: geoserver_container = containers[GEOSERVER_CONTAINER] geoserver_port = geoserver_container['Ports'][0]['PublicPort'] - print('\nGeoServer:') - print(' Host: {0}'.format(docker_host)) - print(' Port: {0}'.format(geoserver_port)) - print(' Endpoint: http://{0}:{1}/geoserver/rest'.format(docker_host, geoserver_port)) + with pretty_output(FG_WHITE) as p: + p.write('\nGeoServer:') + p.write(' Host: {0}'.format(docker_host)) + p.write(' Port: {0}'.format(geoserver_port)) + p.write(' Endpoint: http://{0}:{1}/geoserver/rest'.format(docker_host, geoserver_port)) else: - print('\nGeoServer: Not Running.') + with pretty_output(FG_WHITE) as p: + p.write('\nGeoServer: Not Running.') except KeyError: # If key error is raised, it is likely not installed. - print('\nGeoServer: Not Installed.') - except: - raise + with pretty_output(FG_WHITE) as p: + p.write('\nGeoServer: Not Installed.') # 52 North WPS try: if container_status[N52WPS_CONTAINER]: n52wps_container = containers[N52WPS_CONTAINER] n52wps_port = n52wps_container['Ports'][0]['PublicPort'] - print('\n52 North WPS:') - print(' Host: {0}'.format(docker_host)) - print(' Port: {0}'.format(n52wps_port)) - print(' Endpoint: http://{0}:{1}/wps/WebProcessingService\n'.format(docker_host, n52wps_port)) + with pretty_output(FG_WHITE) as p: + p.write('\n52 North WPS:') + p.write(' Host: {0}'.format(docker_host)) + p.write(' Port: {0}'.format(n52wps_port)) + p.write(' Endpoint: http://{0}:{1}/wps/WebProcessingService\n'.format(docker_host, n52wps_port)) else: - print('\n52 North WPS: Not Running.') + with pretty_output(FG_WHITE) as p: + p.write('\n52 North WPS: Not Running.') except KeyError: # If key error is raised, it is likely not installed. - print('\n52 North WPS: Not Installed.') - except: - raise + with pretty_output(FG_WHITE) as p: + p.write('\n52 North WPS: Not Installed.') + + +def docker_command(args): + """ + Docker management commands. + """ + if args.command == 'init': + docker_init(containers=args.containers, defaults=args.defaults) + + elif args.command == 'start': + docker_start(containers=args.containers) + + elif args.command == 'stop': + docker_stop(containers=args.containers, boot2docker=args.boot2docker) + + elif args.command == 'status': + docker_status() + + elif args.command == 'update': + docker_update(containers=args.containers, defaults=args.defaults) + + elif args.command == 'remove': + docker_remove(containers=args.containers) + + elif args.command == 'ip': + docker_ip() + + elif args.command == 'restart': + docker_restart(containers=args.containers) diff --git a/tethys_apps/cli/gen_commands.py b/tethys_apps/cli/gen_commands.py index 00c1f44f2..66bcc52ab 100644 --- a/tethys_apps/cli/gen_commands.py +++ b/tethys_apps/cli/gen_commands.py @@ -7,6 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ +from __future__ import print_function from builtins import input import os import string @@ -15,21 +16,22 @@ from platform import linux_distribution from django.template import Template, Context -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") from django.conf import settings +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") + # Initialize settings try: __import__(os.environ['DJANGO_SETTINGS_MODULE']) -except: +except Exception: # Initialize settings with templates variable to allow gen to work properly settings.configure(TEMPLATES=[ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', } ]) -import django +import django # noqa: E402 django.setup() @@ -119,6 +121,7 @@ def generate_command(args): secret_key = ''.join([random.choice(string.ascii_letters + string.digits) for n in range(50)]) context.update({'secret_key': secret_key, 'allowed_host': args.allowed_host, + 'allowed_hosts': args.allowed_hosts, 'db_username': args.db_username, 'db_password': args.db_password, 'db_port': args.db_port, @@ -127,29 +130,33 @@ def generate_command(args): }) if args.type == GEN_NGINX_OPTION: - hostname = ''. join(settings.ALLOWED_HOSTS) + hostname = str(settings.ALLOWED_HOSTS[0]) if len(settings.ALLOWED_HOSTS) > 0 else '127.0.0.1' workspaces_root = get_settings_value('TETHYS_WORKSPACES_ROOT') static_root = get_settings_value('STATIC_ROOT') context.update({'hostname': hostname, 'workspaces_root': workspaces_root, 'static_root': static_root, + 'client_max_body_size': args.client_max_body_size }) if args.type == GEN_UWSGI_SERVICE_OPTION: conda_home = get_environment_value('CONDA_HOME') conda_env_name = get_environment_value('CONDA_ENV_NAME') - linux_distro = linux_distribution(full_distribution_name=0)[0] user_option_prefix = '' - if linux_distro in ['redhat', 'centos']: - user_option_prefix = 'http-' + + try: + linux_distro = linux_distribution(full_distribution_name=0)[0] + if linux_distro in ['redhat', 'centos']: + user_option_prefix = 'http-' + except Exception: + pass context.update({'nginx_user': nginx_user, 'conda_home': conda_home, 'conda_env_name': conda_env_name, 'tethys_home': TETHYS_HOME, - 'linux_distribution': linux_distro, 'user_option_prefix': user_option_prefix }) @@ -158,7 +165,8 @@ def generate_command(args): conda_env_name = get_environment_value('CONDA_ENV_NAME') context.update({'conda_home': conda_home, - 'conda_env_name': conda_env_name}) + 'conda_env_name': conda_env_name, + 'uwsgi_processes': args.uwsgi_processes}) if args.directory: if os.path.isdir(args.directory): diff --git a/tethys_apps/cli/gen_templates/nginx b/tethys_apps/cli/gen_templates/nginx index 7e1ac145b..c37425624 100644 --- a/tethys_apps/cli/gen_templates/nginx +++ b/tethys_apps/cli/gen_templates/nginx @@ -13,11 +13,11 @@ server { charset utf-8; # max upload size - client_max_body_size 75M; # adjust to taste + client_max_body_size {{ client_max_body_size }}; # adjust to taste # Tethys Workspaces location /workspaces { - internal; + internal; alias {{ workspaces_root }}; # your Tethys workspaces files - amend as required } diff --git a/tethys_apps/cli/gen_templates/settings b/tethys_apps/cli/gen_templates/settings index f63baff21..a275bd7d1 100644 --- a/tethys_apps/cli/gen_templates/settings +++ b/tethys_apps/cli/gen_templates/settings @@ -75,7 +75,11 @@ LOGGING = { }, } +{% if allowed_hosts %} +ALLOWED_HOSTS = {{ allowed_hosts|safe }} +{% else %} ALLOWED_HOSTS = [{% if allowed_host %}'{{ allowed_host }}'{% endif %}] +{% endif %} # Application definition diff --git a/tethys_apps/cli/gen_templates/uwsgi_settings b/tethys_apps/cli/gen_templates/uwsgi_settings index 852c5be2b..85391a3a8 100644 --- a/tethys_apps/cli/gen_templates/uwsgi_settings +++ b/tethys_apps/cli/gen_templates/uwsgi_settings @@ -14,7 +14,7 @@ uwsgi: master: true pidfile2: /run/uwsgi/tethys.pid # maximum number of worker processes - processes: 10 + processes: {{ uwsgi_processes }} # the socket file with correct permissions socket: /run/uwsgi/tethys.sock chmod-socket: 600 diff --git a/tethys_apps/cli/link_commands.py b/tethys_apps/cli/link_commands.py index 2b066cab4..0fc835843 100644 --- a/tethys_apps/cli/link_commands.py +++ b/tethys_apps/cli/link_commands.py @@ -1,10 +1,11 @@ +from tethys_apps.utilities import link_service_to_app_setting +from tethys_apps.cli.cli_colors import pretty_output, FG_RED + + def link_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ - from ..utilities import link_service_to_app_setting - from .cli_colors import pretty_output, FG_RED - try: service = args.service setting = args.setting @@ -40,7 +41,7 @@ def link_command(args): exit(0) except Exception as e: - print(e) with pretty_output(FG_RED) as p: + p.write(e) p.write('An unexpected error occurred. Please try again.') exit(1) diff --git a/tethys_apps/cli/list_command.py b/tethys_apps/cli/list_command.py new file mode 100644 index 000000000..c5258c012 --- /dev/null +++ b/tethys_apps/cli/list_command.py @@ -0,0 +1,20 @@ +from __future__ import print_function +from tethys_apps.helpers import get_installed_tethys_apps, get_installed_tethys_extensions + + +def list_command(args): + """ + List installed apps. + """ + installed_apps = get_installed_tethys_apps() + installed_extensions = get_installed_tethys_extensions() + + if installed_apps: + print('Apps:') + for item in installed_apps: + print(' {}'.format(item)) + + if installed_extensions: + print('Extensions:') + for item in installed_extensions: + print(' {}'.format(item)) diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index 930161b7c..42c76add2 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -11,9 +11,9 @@ import os import subprocess +from tethys_apps.cli.cli_colors import pretty_output, FG_RED from tethys_apps.base.testing.environment import set_testing_environment -#/usr/lib/tethys/src/tethys_apps/cli CURRENT_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) TETHYS_HOME = os.sep.join(CURRENT_SCRIPT_DIR.split(os.sep)[:-3]) TETHYS_SRC_DIRECTORY = os.sep.join(CURRENT_SCRIPT_DIR.split(os.sep)[:-2]) @@ -39,7 +39,8 @@ def get_manage_path(args): # Throw error if path is not valid if not os.path.isfile(manage_path): - print('ERROR: Can\'t open file "{0}", no such file.'.format(manage_path)) + with pretty_output(FG_RED) as p: + p.write('ERROR: Can\'t open file "{0}", no such file.'.format(manage_path)) exit(1) return manage_path @@ -86,11 +87,11 @@ def manage_command(args): elif args.command == MANAGE_COLLECT: # Convenience command to run collectstatic and collectworkspaces - ## Run pre_collectstatic + # Run pre_collectstatic intermediate_process = ['python', manage_path, 'pre_collectstatic'] run_process(intermediate_process) - ## Setup for main collectstatic + # Setup for main collectstatic intermediate_process = ['python', manage_path, 'collectstatic'] if args.noinput: @@ -98,13 +99,15 @@ def manage_command(args): run_process(intermediate_process) - ## Run collectworkspaces command + # Run collectworkspaces command primary_process = ['python', manage_path, 'collectworkspaces'] elif args.command == MANAGE_CREATESUPERUSER: primary_process = ['python', manage_path, 'createsuperuser'] + elif args.command == MANAGE_SYNC: from tethys_apps.harvester import SingletonHarvester + harvester = SingletonHarvester() harvester.harvest() if primary_process: @@ -116,7 +119,7 @@ def run_process(process): try: if 'test' in process: set_testing_environment(True) - subprocess.call(process) + return subprocess.call(process) except KeyboardInterrupt: pass finally: diff --git a/tethys_apps/cli/scaffold_commands.py b/tethys_apps/cli/scaffold_commands.py index e9f809616..e7964938c 100644 --- a/tethys_apps/cli/scaffold_commands.py +++ b/tethys_apps/cli/scaffold_commands.py @@ -1,4 +1,3 @@ -from builtins import * import os import re import logging @@ -6,6 +5,7 @@ import shutil from django.template import Template, Context +from tethys_apps.cli.cli_colors import pretty_output, FG_RED, FG_YELLOW, FG_WHITE # Constants APP_PREFIX = 'tethysapp' @@ -38,11 +38,13 @@ def proper_name_validator(value, default): value = value.replace('-', ' ') value = value.replace('"', '') value = value.replace("'", "") - print('Warning: Illegal characters were detected in proper name "{0}". They have been replaced or ' - 'removed with valid characters: "{1}"'.format(before, value)) + with pretty_output(FG_YELLOW) as p: + p.write('Warning: Illegal characters were detected in proper name "{0}". They have been replaced or ' + 'removed with valid characters: "{1}"'.format(before, value)) # Otherwise, throw error else: - print('Error: Proper name can only contain letters and numbers and spaces.') + with pretty_output(FG_RED) as p: + p.write('Error: Proper name can only contain letters and numbers and spaces.') return False, value return True, value @@ -83,7 +85,8 @@ def theme_color_validator(value, default): value = '#' + value return True, value except ValueError: - print("Error: Value given is not a valid hexadecimal color.") + with pretty_output(FG_RED) as p: + p.write("Error: Value given is not a valid hexadecimal color.") return False, value @@ -128,7 +131,8 @@ def scaffold_command(args): # Validate template if not os.path.isdir(template_root): - print('Error: "{}" is not a valid template.'.format(template_name)) + with pretty_output(FG_WHITE) as p: + p.write('Error: "{}" is not a valid template.'.format(template_name)) exit(1) # Validate project name @@ -144,8 +148,9 @@ def scaffold_command(args): if contains_uppers: before = project_name project_name = project_name.lower() - print('Warning: Uppercase characters in project name "{0}" ' - 'changed to lowercase: "{1}".'.format(before, project_name)) + with pretty_output(FG_YELLOW) as p: + p.write('Warning: Uppercase characters in project name "{0}" ' + 'changed to lowercase: "{1}".'.format(before, project_name)) # Check for valid characters name project_error_regex = re.compile(r'^[a-zA-Z0-9_]+$') @@ -157,12 +162,14 @@ def scaffold_command(args): if project_warning_regex.match(project_name): before = project_name project_name = project_name.replace('-', '_') - print('Warning: Dashes in project name "{0}" have been replaced ' - 'with underscores "{1}"'.format(before, project_name)) + with pretty_output(FG_YELLOW) as p: + p.write('Warning: Dashes in project name "{0}" have been replaced ' + 'with underscores "{1}"'.format(before, project_name)) # Otherwise, throw error else: - print('Error: Invalid characters in project name "{0}". ' - 'Only letters, numbers, and underscores.'.format(project_name)) + with pretty_output(FG_YELLOW) as p: + p.write('Error: Invalid characters in project name "{0}". ' + 'Only letters, numbers, and underscores.'.format(project_name)) exit(1) # Project name derivatives @@ -173,7 +180,8 @@ def scaffold_command(args): class_name = ''.join(title_case_project_name) default_theme_color = get_random_color() - print('Creating new Tethys project named "{0}".'.format(project_dir)) + with pretty_output(FG_WHITE) as p: + p.write('Creating new Tethys project named "{0}".'.format(project_dir)) # Get metadata from user if not is_extension: @@ -281,7 +289,8 @@ def scaffold_command(args): try: response = input('{0} ["{1}"]: '.format(item['prompt'], item['default'])) or item['default'] except (KeyboardInterrupt, SystemExit): - print('\nScaffolding cancelled.') + with pretty_output(FG_YELLOW) as p: + p.write('\nScaffolding cancelled.') exit(1) if callable(item['validator']): @@ -290,7 +299,8 @@ def scaffold_command(args): valid = True if not valid: - print('Invalid response: {}'.format(response)) + with pretty_output(FG_RED) as p: + p.write('Invalid response: {}'.format(response)) context[item['name']] = response @@ -313,20 +323,24 @@ def scaffold_command(args): response = input('Directory "{}" already exists. ' 'Would you like to overwrite it? [Y/n]: '.format(project_root)) or default except (KeyboardInterrupt, SystemExit): - print('\nScaffolding cancelled.') + with pretty_output(FG_YELLOW) as p: + p.write('\nScaffolding cancelled.') exit(1) if response.lower() in valid_choices: valid = True if response.lower() in negative_choices: - print('Scaffolding cancelled.') + with pretty_output(FG_YELLOW) as p: + p.write('Scaffolding cancelled.') exit(0) try: shutil.rmtree(project_root) except OSError: - print('Error: Unable to overwrite "{}". Please remove the directory and try again.'.format(project_root)) + with pretty_output(FG_YELLOW) as p: + p.write('Error: Unable to overwrite "{}". ' + 'Please remove the directory and try again.'.format(project_root)) exit(1) # Walk the template directory, creating the templates and directories in the new project as we go @@ -339,7 +353,8 @@ def scaffold_command(args): # Create Root Directory os.makedirs(curr_project_root) - print('Created: "{}"'.format(curr_project_root)) + with pretty_output(FG_WHITE) as p: + p.write('Created: "{}"'.format(curr_project_root)) # Create Files for template_file in template_files: @@ -365,6 +380,8 @@ def scaffold_command(args): if template: with open(project_file_path, 'w') as pfp: pfp.write(template.render(template_context)) - print('Created: "{}"'.format(project_file_path)) + with pretty_output(FG_WHITE) as p: + p.write('Created: "{}"'.format(project_file_path)) - print('Successfully scaffolded new project "{}"'.format(project_name)) + with pretty_output(FG_WHITE) as p: + p.write('Successfully scaffolded new project "{}"'.format(project_name)) diff --git a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/__init__.py b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/__init__.py index 62a094218..2e2033b3c 100644 --- a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/__init__.py +++ b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/__init__.py @@ -4,4 +4,4 @@ pkg_resources.declare_namespace(__name__) except ImportError: import pkgutil - __path__ = pkgutil.extend_path(__path__, __name__) \ No newline at end of file + __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/model.py b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/model.py index f8f1bf485..770e9b4cb 100644 --- a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/model.py +++ b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/model.py @@ -1 +1 @@ -# Put your persistent store models in this file \ No newline at end of file +# Put your persistent store models in this file diff --git a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/tests/tests.py_tmpl b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/tests/tests.py_tmpl index 369f8ad2e..b428c0949 100644 --- a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/tests/tests.py_tmpl +++ b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/+project+/tests/tests.py_tmpl @@ -114,17 +114,17 @@ class {{class_name}}TestCase(TethysTestCase): It is required that the function name begins with the word "test" or it will not be executed. Generally, the code written here will consist of many assert methods. A list of assert methods is included here for reference or to get you started: - assertEqual(a, b) a == b - assertNotEqual(a, b) a != b - assertTrue(x) bool(x) is True - assertFalse(x) bool(x) is False - assertIs(a, b) a is b - assertIsNot(a, b) a is not b - assertIsNone(x) x is None - assertIsNotNone(x) x is not None - assertIn(a, b) a in b - assertNotIn(a, b) a not in b - assertIsInstance(a, b) isinstance(a, b) + assertEqual(a, b) a == b + assertNotEqual(a, b) a != b + assertTrue(x) bool(x) is True + assertFalse(x) bool(x) is False + assertIs(a, b) a is b + assertIsNot(a, b) a is not b + assertIsNone(x) x is None + assertIsNotNone(x) x is not None + assertIn(a, b) a in b + assertNotIn(a, b) a not in b + assertIsInstance(a, b) isinstance(a, b) assertNotIsInstance(a, b) !isinstance(a, b) Learn more about assert methods here: https://docs.python.org/2.7/library/unittest.html#assert-methods diff --git a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/__init__.py b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/__init__.py index 62a094218..2e2033b3c 100644 --- a/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/__init__.py +++ b/tethys_apps/cli/scaffold_templates/app_templates/default/tethysapp/__init__.py @@ -4,4 +4,4 @@ pkg_resources.declare_namespace(__name__) except ImportError: import pkgutil - __path__ = pkgutil.extend_path(__path__, __name__) \ No newline at end of file + __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/__init__.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/__init__.py index 62a094218..2e2033b3c 100644 --- a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/__init__.py +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/__init__.py @@ -4,4 +4,4 @@ pkg_resources.declare_namespace(__name__) except ImportError: import pkgutil - __path__ = pkgutil.extend_path(__path__, __name__) \ No newline at end of file + __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/model.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/model.py index f8f1bf485..770e9b4cb 100644 --- a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/model.py +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/+project+/model.py @@ -1 +1 @@ -# Put your persistent store models in this file \ No newline at end of file +# Put your persistent store models in this file diff --git a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/__init__.py b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/__init__.py index 62a094218..2e2033b3c 100644 --- a/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/__init__.py +++ b/tethys_apps/cli/scaffold_templates/extension_templates/default/tethysext/__init__.py @@ -4,4 +4,4 @@ pkg_resources.declare_namespace(__name__) except ImportError: import pkgutil - __path__ = pkgutil.extend_path(__path__, __name__) \ No newline at end of file + __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/tethys_apps/cli/scheduler_commands.py b/tethys_apps/cli/scheduler_commands.py index 104971120..133763a9a 100644 --- a/tethys_apps/cli/scheduler_commands.py +++ b/tethys_apps/cli/scheduler_commands.py @@ -1,5 +1,6 @@ -from .cli_colors import * +from .cli_colors import FG_RED, FG_GREEN, FG_YELLOW, BOLD, pretty_output from django.core.exceptions import ObjectDoesNotExist +from builtins import input def scheduler_create_command(args): @@ -14,7 +15,7 @@ def scheduler_create_command(args): existing_scheduler = Scheduler.objects.filter(name=name).first() if existing_scheduler: - with pretty_output(FG_RED) as p: + with pretty_output(FG_YELLOW) as p: p.write('A Scheduler with name "{}" already exists. Command aborted.'.format(name)) exit(0) @@ -74,9 +75,9 @@ def schedulers_remove_command(args): p.write('Successfully removed Scheduler "{0}"!'.format(name)) exit(0) else: - proceed = raw_input('Are you sure you want to delete this Scheduler? [y/n]: ') + proceed = input('Are you sure you want to delete this Scheduler? [y/n]: ') while proceed not in ['y', 'n', 'Y', 'N']: - proceed = raw_input('Please enter either "y" or "n": ') + proceed = input('Please enter either "y" or "n": ') if proceed in ['y', 'Y']: scheduler.delete() diff --git a/tethys_apps/cli/services_commands.py b/tethys_apps/cli/services_commands.py index 6bf2c3c9b..7888949bd 100644 --- a/tethys_apps/cli/services_commands.py +++ b/tethys_apps/cli/services_commands.py @@ -1,10 +1,13 @@ +from __future__ import print_function from django.core.exceptions import ObjectDoesNotExist from django.db.utils import IntegrityError from django.forms.models import model_to_dict -from .cli_colors import * +from .cli_colors import BOLD, pretty_output, FG_RED, FG_GREEN from .cli_helpers import add_geoserver_rest_to_endpoint +from builtins import input + SERVICES_CREATE = 'create' SERVICES_CREATE_PERSISTENT = 'persistent' SERVICES_CREATE_SPATIAL = 'spatial' @@ -69,9 +72,9 @@ def services_remove_persistent_command(args): p.write('Successfully removed Persistent Store Service {0}!'.format(persistent_service_id)) exit(0) else: - proceed = raw_input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') + proceed = input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') while proceed not in ['y', 'n', 'Y', 'N']: - proceed = raw_input('Please enter either "y" or "n": ') + proceed = input('Please enter either "y" or "n": ') if proceed in ['y', 'Y']: service.delete() @@ -121,7 +124,7 @@ def services_create_spatial_command(args): new_persistent_service.save() with pretty_output(FG_GREEN) as p: - p.write('Successfully created new Persistent Store Service!') + p.write('Successfully created new Spatial Dataset Service!') except IndexError: with pretty_output(FG_RED) as p: p.write('The connection argument (-c) must be of the form ' @@ -155,9 +158,9 @@ def services_remove_spatial_command(args): p.write('Successfully removed Spatial Dataset Service {0}!'.format(spatial_service_id)) exit(0) else: - proceed = raw_input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') + proceed = input('Are you sure you want to delete this Persistent Store Service? [y/n]: ') while proceed not in ['y', 'n', 'Y', 'N']: - proceed = raw_input('Please enter either "y" or "n": ') + proceed = input('Please enter either "y" or "n": ') if proceed in ['y', 'Y']: service.delete() diff --git a/tethys_apps/cli/syncstores_command.py b/tethys_apps/cli/syncstores_command.py new file mode 100644 index 000000000..854f59dd8 --- /dev/null +++ b/tethys_apps/cli/syncstores_command.py @@ -0,0 +1,51 @@ +from __future__ import print_function +import subprocess + +from builtins import input + +from tethys_apps.cli.manage_commands import get_manage_path +from tethys_apps.cli.cli_colors import TC_WARNING, TC_ENDC + + +def syncstores_command(args): + """ + Sync persistent stores. + """ + # Get the path to manage.py + manage_path = get_manage_path(args) + + # This command is a wrapper for a custom Django manage.py method called syncstores. + # See tethys_apps.mangement.commands.syncstores + process = ['python', manage_path, 'syncstores'] + + if args.refresh: + valid_inputs = ('y', 'n', 'yes', 'no') + no_inputs = ('n', 'no') + proceed = input('{1}WARNING:{2} You have specified the database refresh option. This will drop all of the ' + 'databases for the following apps: {0}. This could result in significant data loss and ' + 'cannot be undone. Do you wish to continue? (y/n): '.format(', '.join(args.app), + TC_WARNING, + TC_ENDC)).lower() + + while proceed not in valid_inputs: + proceed = input('Invalid option. Do you wish to continue? (y/n): ').lower() + + if proceed not in no_inputs: + process.extend(['-r']) + else: + print('Operation cancelled by user.') + exit(0) + + if args.firsttime: + process.extend(['-f']) + + if args.database: + process.extend(['-d', args.database]) + + if args.app: + process.extend(args.app) + + try: + subprocess.call(process) + except KeyboardInterrupt: + pass diff --git a/tethys_apps/cli/test_command.py b/tethys_apps/cli/test_command.py new file mode 100644 index 000000000..1f4f2fb04 --- /dev/null +++ b/tethys_apps/cli/test_command.py @@ -0,0 +1,69 @@ +import os +import webbrowser + +from tethys_apps.cli.manage_commands import get_manage_path, TETHYS_SRC_DIRECTORY, run_process + + +def test_command(args): + args.manage = False + # Get the path to manage.py + manage_path = get_manage_path(args) + tests_path = os.path.join(TETHYS_SRC_DIRECTORY, 'tests') + + # Define the process to be run + primary_process = ['python', manage_path, 'test'] + + # Tag to later check if tests are being run on a specific app or extension + app_package_tag = 'tethys_apps.tethysapp.' + extension_package_tag = 'tethysext.' + + if args.coverage or args.coverage_html: + os.environ['TETHYS_TEST_DIR'] = tests_path + if args.file and app_package_tag in args.file: + app_package_parts = args.file.split(app_package_tag) + app_name = app_package_parts[1].split('.')[0] + core_app_package = '{}{}'.format(app_package_tag, app_name) + app_package = 'tethysapp.{}'.format(app_name) + config_opt = '--source={},{}'.format(core_app_package, app_package) + elif args.file and extension_package_tag in args.file: + extension_package_parts = args.file.split(extension_package_tag) + extension_name = extension_package_parts[1].split('.')[0] + core_extension_package = '{}{}'.format(extension_package_tag, extension_name) + extension_package = 'tethysext.{}'.format(extension_name) + config_opt = '--source={},{}'.format(core_extension_package, extension_package) + else: + config_opt = '--rcfile={0}'.format(os.path.join(tests_path, 'coverage.cfg')) + primary_process = ['coverage', 'run', config_opt, manage_path, 'test'] + + if args.file: + primary_process.append(args.file) + elif args.unit: + primary_process.append(os.path.join(tests_path, 'unit_tests')) + elif args.gui: + primary_process.append(os.path.join(tests_path, 'gui_tests')) + + test_status = run_process(primary_process) + + if args.coverage: + if args.file and (app_package_tag in args.file or extension_package_tag in args.file): + run_process(['coverage', 'report']) + else: + run_process(['coverage', 'report', config_opt]) + + if args.coverage_html: + report_dirname = 'coverage_html_report' + index_fname = 'index.html' + + if args.file and (app_package_tag in args.file or extension_package_tag in args.file): + run_process(['coverage', 'html', '--directory={0}'.format(os.path.join(tests_path, report_dirname))]) + else: + run_process(['coverage', 'html', config_opt]) + + try: + status = run_process(['open', os.path.join(tests_path, report_dirname, index_fname)]) + if status != 0: + raise Exception + except Exception: + webbrowser.open_new_tab(os.path.join(tests_path, report_dirname, index_fname)) + + exit(test_status) diff --git a/tethys_apps/cli/uninstall_command.py b/tethys_apps/cli/uninstall_command.py new file mode 100644 index 000000000..e3d556572 --- /dev/null +++ b/tethys_apps/cli/uninstall_command.py @@ -0,0 +1,19 @@ +import subprocess + +from tethys_apps.cli.manage_commands import get_manage_path + + +def uninstall_command(args): + """ + Uninstall an app command. + """ + # Get the path to manage.py + manage_path = get_manage_path(args) + item_name = args.app_or_extension + process = ['python', manage_path, 'tethys_app_uninstall', item_name] + if args.is_extension: + process.append('-e') + try: + subprocess.call(process) + except KeyboardInterrupt: + pass diff --git a/tethys_apps/decorators.py b/tethys_apps/decorators.py index da5aa7fbb..4d4a0789b 100644 --- a/tethys_apps/decorators.py +++ b/tethys_apps/decorators.py @@ -4,13 +4,12 @@ * Author: nswain * Created On: May 09, 2016 * Copyright: (c) Aquaveo 2016 -* License: +* License: ******************************************************************************** """ -try: - from urlparse import urlparse -except ImportError: - from urllib.parse import urlparse +from future.standard_library import install_aliases + +from urllib.parse import urlparse from django.core.handlers.wsgi import WSGIRequest from django.contrib import messages @@ -21,6 +20,8 @@ from tethys_portal.views import error as tethys_portal_error from tethys_apps.base import has_permission +install_aliases() + def permission_required(*args, **kwargs): """ @@ -82,13 +83,16 @@ def my_controller(request): \""" ... - """ + """ # noqa: E501 use_or = kwargs.pop('use_or', False) message = kwargs.pop('message', "We're sorry, but you are not allowed to perform this operation.") raise_exception = kwargs.pop('raise_exception', False) perms = [arg for arg in args if isinstance(arg, basestring)] + if not perms: + raise ValueError('Must supply at least one permission to test.') + def decorator(controller_func): def _wrapped_controller(*args, **kwargs): # With OR check, we assume the permission test passes upfront @@ -179,4 +183,4 @@ def _wrapped_controller(*args, **kwargs): return response return wraps(controller_func)(_wrapped_controller) - return decorator \ No newline at end of file + return decorator diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index 3155eb5d1..31bba73d7 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -9,7 +9,7 @@ """ from __future__ import (absolute_import, division, print_function, unicode_literals) -from builtins import * +from builtins import * # noqa: F401, F403 import os import inspect @@ -19,7 +19,7 @@ from django.db.utils import ProgrammingError from django.core.exceptions import ObjectDoesNotExist from tethys_apps.base import TethysAppBase, TethysExtensionBase -from tethys_apps.terminal_colors import TerminalColors +from tethys_apps.base.testing.environment import is_testing_environment tethys_log = logging.getLogger('tethys.' + __name__) @@ -32,6 +32,11 @@ class SingletonHarvester(object): extension_modules = {} apps = [] _instance = None + BLUE = '\033[94m' + GREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' def harvest(self): """ @@ -40,21 +45,22 @@ def harvest(self): self.harvest_extensions() self.harvest_apps() - def harvest_extensions(self): """ Searches for and loads Tethys extensions. """ try: + if not is_testing_environment(): + print(self.BLUE + 'Loading Tethys Extensions...' + self.ENDC) + import tethysext - print(TerminalColors.BLUE + 'Loading Tethys Extensions...' + TerminalColors.ENDC) tethys_extensions = dict() for _, modname, ispkg in pkgutil.iter_modules(tethysext.__path__): if ispkg: tethys_extensions[modname] = 'tethysext.{}'.format(modname) self._harvest_extension_instances(tethys_extensions) - except: + except Exception: '''DO NOTHING''' def harvest_apps(self): @@ -62,7 +68,8 @@ def harvest_apps(self): Searches the apps package for apps """ # Notify user harvesting is taking place - print(TerminalColors.BLUE + 'Loading Tethys Apps...' + TerminalColors.ENDC) + if not is_testing_environment(): + print(self.BLUE + 'Loading Tethys Apps...' + self.ENDC) # List the apps packages in directory apps_dir = os.path.join(os.path.dirname(__file__), 'tethysapp') @@ -85,14 +92,14 @@ def get_url_patterns(self): extension_url_patterns.update(extension.url_patterns) return app_url_patterns, extension_url_patterns - + def __new__(cls): """ Make App Harvester a Singleton """ if not cls._instance: cls._instance = super(SingletonHarvester, cls).__new__(cls) - + return cls._instance @staticmethod @@ -133,7 +140,7 @@ def _harvest_extension_instances(self, extension_packages): Arg: extension_packages(dict): Dictionary where keys are the name of the extension and value is the extension package module object. - """ + """ # noqa:E501 valid_ext_instances = [] valid_extension_modules = {} loaded_extensions = [] @@ -173,7 +180,7 @@ def _harvest_extension_instances(self, extension_packages): except TypeError: continue - except: + except Exception: tethys_log.exception( 'Extension {0} not loaded because of the following error:'.format(extension_package)) continue @@ -183,8 +190,9 @@ def _harvest_extension_instances(self, extension_packages): self.extension_modules = valid_extension_modules # Update user - print(TerminalColors.BLUE + 'Tethys Extensions Loaded: ' - + TerminalColors.ENDC + '{0}'.format(', '.join(loaded_extensions)) + '\n') + if not is_testing_environment(): + print(self.BLUE + 'Tethys Extensions Loaded: ' + + self.ENDC + '{0}'.format(', '.join(loaded_extensions)) + '\n') def _harvest_app_instances(self, app_packages_list): """ @@ -193,7 +201,7 @@ def _harvest_app_instances(self, app_packages_list): """ valid_app_instance_list = [] loaded_apps = [] - + for app_package in app_packages_list: # Skip these things if app_package in ['__init__.py', '__init__.pyc', '.gitignore', '.DS_Store']: @@ -207,7 +215,6 @@ def _harvest_app_instances(self, app_packages_list): # (e.g.: apps.apps..app) app_module = __import__(app_module_name, fromlist=['']) - for name, obj in inspect.getmembers(app_module): # Retrieve the members of the app_module and iterate through # them to find the the class that inherits from AppBase. @@ -227,7 +234,7 @@ def _harvest_app_instances(self, app_packages_list): # load/validate app url patterns try: app_instance.url_patterns - except: + except Exception: tethys_log.exception( 'App {0} not loaded because of an issue with loading urls:'.format(app_package)) app_instance.remove_from_db() @@ -237,7 +244,7 @@ def _harvest_app_instances(self, app_packages_list): try: app_instance.register_app_permissions() except (ProgrammingError, ObjectDoesNotExist) as e: - tethys_log.error(e) + tethys_log.warning(e) # compile valid apps if validated_app_instance: @@ -251,7 +258,7 @@ def _harvest_app_instances(self, app_packages_list): except TypeError: continue - except: + except Exception: tethys_log.exception( 'App {0} not loaded because of the following error:'.format(app_package)) continue @@ -260,5 +267,6 @@ def _harvest_app_instances(self, app_packages_list): self.apps = valid_app_instance_list # Update user - print(TerminalColors.BLUE + 'Tethys Apps Loaded: ' - + TerminalColors.ENDC + '{0}'.format(', '.join(loaded_apps)) + '\n') + if not is_testing_environment(): + print(self.BLUE + 'Tethys Apps Loaded: ' + + self.ENDC + '{0}'.format(', '.join(loaded_apps)) + '\n') diff --git a/tethys_apps/helpers.py b/tethys_apps/helpers.py index 2dcf76903..28ecbce59 100644 --- a/tethys_apps/helpers.py +++ b/tethys_apps/helpers.py @@ -51,4 +51,4 @@ def get_installed_tethys_extensions(): except (IndexError, ImportError): '''DO NOTHING''' - return extension_paths \ No newline at end of file + return extension_paths diff --git a/tethys_apps/management/__init__.py b/tethys_apps/management/__init__.py index b5eeb48b2..3ce78d55d 100644 --- a/tethys_apps/management/__init__.py +++ b/tethys_apps/management/__init__.py @@ -6,4 +6,4 @@ * Copyright: (c) Brigham Young University 2014 * License: BSD 2-Clause ******************************************************************************** -""" \ No newline at end of file +""" diff --git a/tethys_apps/management/commands/__init__.py b/tethys_apps/management/commands/__init__.py index 1b643a91c..2facb9e16 100644 --- a/tethys_apps/management/commands/__init__.py +++ b/tethys_apps/management/commands/__init__.py @@ -6,4 +6,4 @@ * Copyright: (c) Brigham Young University 2015 * License: BSD 2-Clause ******************************************************************************** -""" \ No newline at end of file +""" diff --git a/tethys_apps/management/commands/collectworkspaces.py b/tethys_apps/management/commands/collectworkspaces.py index e34699072..a918a4ac0 100644 --- a/tethys_apps/management/commands/collectworkspaces.py +++ b/tethys_apps/management/commands/collectworkspaces.py @@ -7,6 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ +from __future__ import print_function import os import shutil @@ -75,7 +76,8 @@ def handle(self, *args, **options): shutil.move(app_ws_path, tethys_ws_root_path) else: print('WARNING: Workspace directory for app "{}" already exists in the TETHYS_WORKSPACES_ROOT ' - 'directory. A symbolic link is being created to the existing directory. To force overwrite ' + 'directory. A symbolic link is being created to the existing directory. ' + 'To force overwrite ' 'the existing directory, re-run the command with the "-f" argument.'.format(app)) shutil.rmtree(app_ws_path, ignore_errors=True) @@ -84,6 +86,3 @@ def handle(self, *args, **options): os.symlink(tethys_ws_root_path, app_ws_path) print('INFO: Successfully linked "workspaces" directory to TETHYS_WORKSPACES_ROOT for app ' '"{0}".'.format(app)) - else: - print('WARNING: Workspace directory for app "{}" is already symbolically linked to another directory ' - 'within the TETHYS_WORKSPACES_ROOT directory. Skipping... '.format(app)) diff --git a/tethys_apps/management/commands/pre_collectstatic.py b/tethys_apps/management/commands/pre_collectstatic.py index df9e307d0..224e9f78b 100644 --- a/tethys_apps/management/commands/pre_collectstatic.py +++ b/tethys_apps/management/commands/pre_collectstatic.py @@ -7,6 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ +from __future__ import print_function import os import shutil @@ -25,7 +26,7 @@ def handle(self, *args, **options): """ Symbolically link the static directories of each app into the static/public directory specified by the STATIC_ROOT parameter of the settings.py. Do this prior to running Django's collectstatic method. - """ + """ # noqa: E501 if not settings.STATIC_ROOT: print('WARNING: Cannot find the STATIC_ROOT setting in the settings.py file. ' 'Please provide the path to the static directory using the STATIC_ROOT setting and try again.') @@ -38,7 +39,6 @@ def handle(self, *args, **options): installed_apps_and_extensions = get_installed_tethys_apps() installed_apps_and_extensions.update(get_installed_tethys_extensions()) - # Provide feedback to user print('INFO: Linking static and public directories of apps and extensions to "{0}".'.format(static_root)) @@ -68,4 +68,3 @@ def handle(self, *args, **options): elif os.path.isdir(static_path): os.symlink(static_path, static_root_path) print('INFO: Successfully linked static directory to STATIC_ROOT for app "{0}".'.format(item)) - diff --git a/tethys_apps/management/commands/syncstores.py b/tethys_apps/management/commands/syncstores.py index 2e71266ab..8f2dd4d29 100644 --- a/tethys_apps/management/commands/syncstores.py +++ b/tethys_apps/management/commands/syncstores.py @@ -8,11 +8,14 @@ ******************************************************************************** """ from django.core.management.base import BaseCommand -from tethys_apps.terminal_colors import TerminalColors +from tethys_apps.cli.cli_colors import TC_BLUE, TC_WARNING, TC_ENDC ALL_APPS = 'all' -#TODO: remove syncstores interface and update documentation once able to initialize/create persistent stores from app admin interface +# TODO: remove syncstores interface and update documentation once able to initialize/create persistent stores from app +# admin interface + + class Command(BaseCommand): """ Command class that handles the syncstores command. Provides persistent store management functionality. @@ -58,11 +61,12 @@ def provision_persistent_stores(self, app_names, options): target_app_names = [a.package for a in target_apps] for app_name in app_names: if app_name not in target_app_names: - self.stdout.write('{0}WARNING:{1} The app named "{2}" cannot be found. Please make sure it is installed ' - 'and try again.'.format(TerminalColors.WARNING, TerminalColors.ENDC, app_name)) + self.stdout.write('{0}WARNING:{1} The app named "{2}" cannot be found. ' + 'Please make sure it is installed ' + 'and try again.'.format(TC_WARNING, TC_ENDC, app_name)) # Notify user of database provisioning - self.stdout.write(TerminalColors.BLUE + '\nProvisioning Persistent Stores...' + TerminalColors.ENDC) + self.stdout.write(TC_BLUE + '\nProvisioning Persistent Stores...' + TC_ENDC) # Get apps and provision persistent stores if not already created for app in target_apps: diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index b406bac4c..344e60390 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -41,7 +41,8 @@ def handle(self, *args, **options): item_name = item_name[prefix_length:] if item_name not in installed_items: - warnings.warn('WARNING: {0} with name "{1}" cannot be uninstalled, because it is not installed or not an {0}.'.format(app_or_extension, item_name)) + warnings.warn('WARNING: {0} with name "{1}" cannot be uninstalled, because it is not installed or' + ' not an {0}.'.format(app_or_extension, item_name)) exit(0) item_with_prefix = '{0}-{1}'.format(PREFIX, item_name) @@ -88,8 +89,8 @@ def handle(self, *args, **options): # Remove the namespace package file if applicable. for site_package in site.getsitepackages(): try: - os.remove(os.path.join(site_package, "{}-{}-nspkg.pth".format(PREFIX, item_name.replace('_','-')))) - except: + os.remove(os.path.join(site_package, "{}-{}-nspkg.pth".format(PREFIX, item_name.replace('_', '-')))) + except Exception: continue self.stdout.write('{} "{}" successfully uninstalled.'.format(app_or_extension, item_with_prefix)) diff --git a/tethys_apps/migrations/0001_initial_20.py b/tethys_apps/migrations/0001_initial_20.py index f4f258c2e..a58f880ba 100644 --- a/tethys_apps/migrations/0001_initial_20.py +++ b/tethys_apps/migrations/0001_initial_20.py @@ -53,59 +53,96 @@ class Migration(migrations.Migration): migrations.CreateModel( name='CustomSetting', fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), + ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='tethys_apps.TethysAppSetting')), ('value', models.CharField(blank=True, max_length=1024)), - ('type', models.CharField(choices=[('STRING', 'String'), ('INTEGER', 'Integer'), ('FLOAT', 'Float'), ('BOOLEAN', 'Boolean')], default='STRING', max_length=200)), + ('type', models.CharField(choices=[('STRING', 'String'), ('INTEGER', 'Integer'), ('FLOAT', 'Float'), + ('BOOLEAN', 'Boolean')], default='STRING', max_length=200)), ], bases=('tethys_apps.tethysappsetting',), ), migrations.CreateModel( name='DatasetServiceSetting', fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('engine', models.CharField(choices=[('tethys_dataset_services.engines.CkanDatasetEngine', 'CKAN'), ('tethys_dataset_services.engines.HydroShareDatasetEngine', 'HydroShare')], default='tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), - ('dataset_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.DatasetService')), + ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, + serialize=False, to='tethys_apps.TethysAppSetting')), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.CkanDatasetEngine', 'CKAN'), + ('tethys_dataset_services.engines.HydroShareDatasetEngine', + 'HydroShare')], + default='tethys_dataset_services.engines.CkanDatasetEngine', + max_length=200)), + ('dataset_service', models.ForeignKey(blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + to='tethys_services.DatasetService')), ], bases=('tethys_apps.tethysappsetting',), ), migrations.CreateModel( name='PersistentStoreConnectionSetting', fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('persistent_store_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.PersistentStoreService')), + ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='tethys_apps.TethysAppSetting')), + ('persistent_store_service', models.ForeignKey(blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + to='tethys_services.PersistentStoreService')), ], bases=('tethys_apps.tethysappsetting',), ), migrations.CreateModel( name='PersistentStoreDatabaseSetting', fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), + ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='tethys_apps.TethysAppSetting')), ('spatial', models.BooleanField(default=False)), ('dynamic', models.BooleanField(default=False)), - ('persistent_store_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.PersistentStoreService')), + ('persistent_store_service', models.ForeignKey(blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + to='tethys_services.PersistentStoreService')), ], bases=('tethys_apps.tethysappsetting',), ), migrations.CreateModel( name='SpatialDatasetServiceSetting', fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('engine', models.CharField(choices=[('tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', 'GeoServer')], default='tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), - ('spatial_dataset_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.SpatialDatasetService')), + ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='tethys_apps.TethysAppSetting')), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', + 'GeoServer')], + default='tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', + max_length=200)), + ('spatial_dataset_service', models.ForeignKey(blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + to='tethys_services.SpatialDatasetService')), ], bases=('tethys_apps.tethysappsetting',), ), migrations.CreateModel( name='WebProcessingServiceSetting', fields=[ - ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_apps.TethysAppSetting')), - ('web_processing_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tethys_services.WebProcessingService')), + ('tethysappsetting_ptr', models.OneToOneField(auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='tethys_apps.TethysAppSetting')), + ('web_processing_service', models.ForeignKey(blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + to='tethys_services.WebProcessingService')), ], bases=('tethys_apps.tethysappsetting',), ), migrations.AddField( model_name='tethysappsetting', name='tethys_app', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings_set', to='tethys_apps.TethysApp'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings_set', + to='tethys_apps.TethysApp'), ), ] diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 869526336..da7c664a0 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -7,7 +7,6 @@ * License: BSD 2-Clause ******************************************************************************** """ -from builtins import str as text import sqlalchemy import logging from django.db import models @@ -20,17 +19,16 @@ from tethys_apps.base.mixins import TethysBaseMixin from tethys_sdk.testing import is_testing_environment, get_test_db_name +from tethys_apps.base.function_extractor import TethysFunctionExtractor +log = logging.getLogger('tethys') + try: from tethys_services.models import (DatasetService, SpatialDatasetService, WebProcessingService, PersistentStoreService) except RuntimeError: - log = logging.getLogger('tethys') log.exception('An error occurred while trying to import tethys service models.') -from tethys_apps.base.function_extractor import TethysFunctionExtractor - - class TethysApp(models.Model, TethysBaseMixin): """ DB Model for Tethys Apps @@ -63,11 +61,8 @@ class Meta: verbose_name = 'Tethys App' verbose_name_plural = 'Installed Apps' - def __unicode__(self): - return text(self.name) - def __str__(self): - return text(self.name) + return self.name def add_settings(self, setting_list): """ @@ -119,7 +114,8 @@ def persistent_store_database_settings(self): @property def configured(self): - for setting in [s for s in self.settings if s.required]: + required_settings = [s for s in self.settings if s.required] + for setting in required_settings: try: if setting.get_value() is None: return False @@ -149,8 +145,8 @@ class Meta: verbose_name = 'Tethys Extension' verbose_name_plural = 'Installed Extensions' - def __unicode__(self): - return text(self.name) + def __str__(self): + return self.name class TethysAppSetting(models.Model): @@ -167,9 +163,6 @@ class TethysAppSetting(models.Model): initializer = models.CharField(max_length=1000, default='') initialized = models.BooleanField(default=False) - def __unicode__(self): - return self.name - def __str__(self): return self.name @@ -239,7 +232,7 @@ class CustomSetting(TethysAppSetting): required=True ) - """ + """ # noqa: E501 TYPE_STRING = 'STRING' TYPE_INTEGER = 'INTEGER' TYPE_FLOAT = 'FLOAT' @@ -253,7 +246,7 @@ class CustomSetting(TethysAppSetting): (TYPE_FLOAT, 'Float'), (TYPE_BOOLEAN, 'Boolean'), ) - value = models.CharField(max_length=1024, blank=True) + value = models.CharField(max_length=1024, blank=True, default='') type = models.CharField(max_length=200, choices=TYPE_CHOICES, default=TYPE_STRING) def clean(self): @@ -283,7 +276,7 @@ def get_value(self): """ Get the value, automatically casting it to the correct type. """ - if self.value == '': + if self.value == '' or self.value is None: return None # TODO Why don't we raise a NotAssigned error here? if self.type == self.TYPE_STRING: @@ -471,7 +464,7 @@ def get_value(self, as_public_endpoint=False, as_endpoint=False, as_engine=False return wps_service.endpoint if as_public_endpoint: - return wps_service.pubic_endpoint + return wps_service.public_endpoint return wps_service @@ -517,7 +510,7 @@ def get_value(self, as_url=False, as_sessionmaker=False, as_engine=False): if ps_service is None: raise TethysAppSettingNotAssigned('Cannot create engine or url for PersistentStoreConnection "{0}" for app ' '"{1}": no PersistentStoreService found.'.format(self.name, - self.tethys_app.package)) + self.tethys_app.package)) # Order matters here. Think carefully before changing... if as_engine: return ps_service.get_engine() @@ -585,7 +578,6 @@ def get_namespaced_persistent_store_name(self): """ Return the namespaced persistent store database name (e.g. my_first_app_db). """ - from django.conf import settings # Convert name given by user to database safe name safe_name = self.name.lower().replace(' ', '_') @@ -667,7 +659,7 @@ def drop_persistent_store_database(self): engine = self.get_value(as_engine=True) # Connection - drop_connection = engine.connect() + drop_connection = None namespaced_ps_name = self.get_namespaced_persistent_store_name() @@ -687,16 +679,16 @@ def drop_persistent_store_database(self): WHERE pg_stat_activity.datname = '{0}' AND pg_stat_activity.pid <> pg_backend_pid(); '''.format(namespaced_ps_name) - drop_connection.execute(disconnect_sessions_statement) + if drop_connection: + drop_connection.execute(disconnect_sessions_statement) - # Try again to drop the database - drop_connection.execute('commit') - drop_connection.execute(drop_db_statement) - drop_connection.close() + # Try again to drop the database + drop_connection.execute('commit') + drop_connection.execute(drop_db_statement) else: raise e finally: - drop_connection.close() + drop_connection and drop_connection.close() def create_persistent_store_database(self, refresh=False, force_first_time=False): """ @@ -745,6 +737,7 @@ def create_persistent_store_database(self, refresh=False, force_first_time=False create_connection.execute('commit') try: create_connection.execute(create_db_statement) + except sqlalchemy.exc.ProgrammingError: raise PersistentStorePermissionError('Database user "{0}" has insufficient permissions to create ' 'the persistent store database "{1}": must have CREATE DATABASES ' diff --git a/tethys_apps/static_finders.py b/tethys_apps/static_finders.py index cd9b53a7c..7ca38fae6 100644 --- a/tethys_apps/static_finders.py +++ b/tethys_apps/static_finders.py @@ -70,4 +70,4 @@ def list(self, ignore_patterns): for prefix, root in self.locations: storage = self.storages[root] for path in utils.get_files(storage, ignore_patterns): - yield path, storage \ No newline at end of file + yield path, storage diff --git a/tethys_apps/template_loaders.py b/tethys_apps/template_loaders.py index 17d2ecceb..f2f61f0db 100644 --- a/tethys_apps/template_loaders.py +++ b/tethys_apps/template_loaders.py @@ -3,8 +3,8 @@ * Name: template_loaders.py * Author: swainn * Created On: December 14, 2015 -* Copyright: -* License: +* Copyright: (c) Aquaveo 2015 +* License: ******************************************************************************** """ import io diff --git a/tethys_apps/terminal_colors.py b/tethys_apps/terminal_colors.py index 4d17ce8df..e69de29bb 100644 --- a/tethys_apps/terminal_colors.py +++ b/tethys_apps/terminal_colors.py @@ -1,16 +0,0 @@ -""" -******************************************************************************** -* Name: terminal_colors.py -* Author: Nathan Swain -* Created On: 2014 -* Copyright: (c) Brigham Young University 2014 -* License: BSD 2-Clause -******************************************************************************** -""" - -class TerminalColors: - BLUE = '\033[94m' - GREEN = '\033[92m' - WARNING = '\033[93m' - FAIL = '\033[91m' - ENDC = '\033[0m' \ No newline at end of file diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 046ad04b3..a6ce1f509 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -7,13 +7,14 @@ * License: BSD 2-Clause ******************************************************************************** """ +from __future__ import print_function +from builtins import input import logging import os from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.utils._os import safe_join from tethys_apps.harvester import SingletonHarvester -from tethys_apps.models import TethysApp tethys_log = logging.getLogger('tethys.' + __name__) @@ -33,7 +34,6 @@ def get_directories_in_tethys(directory_names, with_app_name=False): tethysapp_contents = next(os.walk(tethysapp_dir))[1] potential_dirs = [safe_join(tethysapp_dir, item) for item in tethysapp_contents] - # Determine the directories of tethys extensions harvester = SingletonHarvester() @@ -65,6 +65,7 @@ def get_active_app(request=None, url=None): """ Get the active TethysApp object based on the request or URL. """ + from tethys_apps.models import TethysApp apps_root = 'apps' if request is not None: @@ -96,8 +97,9 @@ def get_active_app(request=None, url=None): def create_ps_database_setting(app_package, name, description='', required=False, initializer='', initialized=False, spatial=False, dynamic=False): - from cli.cli_colors import pretty_output, FG_RED, FG_GREEN + from tethys_apps.cli.cli_colors import pretty_output, FG_RED, FG_GREEN from tethys_apps.models import PersistentStoreDatabaseSetting + from tethys_apps.models import TethysApp try: app = TethysApp.objects.get(package=app_package) @@ -139,7 +141,8 @@ def create_ps_database_setting(app_package, name, description='', required=False def remove_ps_database_setting(app_package, name, force=False): - from cli.cli_colors import pretty_output, FG_RED, FG_GREEN + from tethys_apps.models import TethysApp + from tethys_apps.cli.cli_colors import pretty_output, FG_RED, FG_GREEN from tethys_apps.models import PersistentStoreDatabaseSetting try: @@ -158,10 +161,10 @@ def remove_ps_database_setting(app_package, name, force=False): return False if not force: - proceed = raw_input('Are you sure you want to delete the PersistentStoreDatabaseSetting named "{}"? [y/n]: ' - .format(name)) + proceed = input('Are you sure you want to delete the ' + 'PersistentStoreDatabaseSetting named "{}"? [y/n]: '.format(name)) while proceed not in ['y', 'n', 'Y', 'N']: - proceed = raw_input('Please enter either "y" or "n": ') + proceed = input('Please enter either "y" or "n": ') if proceed in ['y', 'Y']: setting.delete() @@ -189,10 +192,11 @@ def link_service_to_app_setting(service_type, service_uid, app_package, setting_ :param setting_uid: The name or id of the setting being linked to a service. :return: True if successful, False otherwise. """ - from cli.cli_colors import pretty_output, FG_GREEN, FG_RED + from tethys_apps.cli.cli_colors import pretty_output, FG_GREEN, FG_RED from tethys_sdk.app_settings import (SpatialDatasetServiceSetting, PersistentStoreConnectionSetting, PersistentStoreDatabaseSetting) from tethys_services.models import (SpatialDatasetService, PersistentStoreService) + from tethys_apps.models import TethysApp service_type_to_model_dict = { 'spatial': SpatialDatasetService, diff --git a/tethys_apps/views.py b/tethys_apps/views.py index ed4c2facb..c103089e8 100644 --- a/tethys_apps/views.py +++ b/tethys_apps/views.py @@ -112,7 +112,7 @@ def send_beta_feedback_email(request): send_mail(subject, message, from_email=None, recipient_list=app.feedback_emails) except Exception as e: json = {'success': False, - 'error': 'Failed to send email: ' + e.message} + 'error': 'Failed to send email: ' + str(e)} return JsonResponse(json) json = {'success': True, @@ -128,7 +128,7 @@ def update_job_status(request, job_id): job = TethysJob.objects.filter(id=job_id)[0] job.status json = {'success': True} - except Exception as e: + except Exception: json = {'success': False} return JsonResponse(json) diff --git a/tethys_compute/admin.py b/tethys_compute/admin.py index 5e44f1fde..a154eeefd 100644 --- a/tethys_compute/admin.py +++ b/tethys_compute/admin.py @@ -8,10 +8,7 @@ ******************************************************************************** """ from django.contrib import admin -from django.forms import Textarea -from django.db import models from tethys_compute.models import Scheduler, TethysJob -# Register your models here. @admin.register(Scheduler) @@ -21,8 +18,9 @@ class SchedulerAdmin(admin.ModelAdmin): @admin.register(TethysJob) class JobAdmin(admin.ModelAdmin): - list_display = ['name', 'description', 'label', 'user', 'creation_time', 'execute_time', 'completion_time', 'status'] + list_display = ['name', 'description', 'label', 'user', 'creation_time', 'execute_time', 'completion_time', + 'status'] list_display_links = ('name',) def has_add_permission(self, request): - return False \ No newline at end of file + return False diff --git a/tethys_compute/job_manager.py b/tethys_compute/job_manager.py index 500ec53d4..6e3e40b75 100644 --- a/tethys_compute/job_manager.py +++ b/tethys_compute/job_manager.py @@ -7,11 +7,13 @@ * License: BSD 2-Clause ******************************************************************************** """ +from __future__ import print_function import re from abc import abstractmethod import logging import warnings + from django.urls import reverse from tethys_compute.models import (TethysJob, @@ -50,18 +52,11 @@ def __init__(self, app): self.label = app.package self.app_workspace = app.get_app_workspace() self.job_templates = dict() - for template in app.job_templates(): - # TODO remove when JobTemplate is made completely abstract - if template.__class__ == JobTemplate: - msg = 'The job template "{0}" in the app "{1}" uses JobTemplate directly. ' \ - 'This is now depreciated. Please use the job type specific template {2} instead.'\ - .format(template.name, self.app.package, JOB_CAST[template.type].__name__) - warnings.warn(msg, DeprecationWarning) - template.__class__ = JOB_CAST[template.type] - template.__init__(name=template.name, parameters=template.parameters) + templates = app.job_templates() or list() + for template in templates: self.job_templates[template.name] = template - def create_empty_job(self, name, user, job_type, **kwargs): + def create_job(self, name, user, template_name=None, job_type=None, **kwargs): """ Creates a new job from a JobTemplate. @@ -74,14 +69,22 @@ def create_empty_job(self, name, user, job_type, **kwargs): Returns: A new job object of the type specified by job_type. """ - assert issubclass(job_type, TethysJob) + if template_name is not None: + msg = 'The job template "{0}" was used in the "{1}" app. Using job templates is now deprecated. ' \ + 'See docs: <>.'\ + .format(template_name, self.app.package) + warnings.warn(msg, DeprecationWarning) + print(msg) + return self.old_create_job(name, user, template_name, **kwargs) + + job_type = JOB_TYPES[job_type] user_workspace = self.app.get_user_workspace(user) kwrgs = dict(name=name, user=user, label=self.label, workspace=user_workspace.path) kwrgs.update(kwargs) job = job_type(**kwrgs) return job - def create_job(self, name, user, template_name, **kwargs): + def old_create_job(self, name, user, template_name, **kwargs): """ Creates a new job from a JobTemplate. @@ -96,7 +99,7 @@ def create_job(self, name, user, template_name, **kwargs): """ try: template = self.job_templates[template_name] - except KeyError as e: + except KeyError: raise KeyError('A job template with name %s was not defined' % (template_name,)) user_workspace = self.app.get_user_workspace(user) kwrgs = dict(name=name, user=user, label=self.label, workspace=user_workspace.path) @@ -136,7 +139,7 @@ def get_job(self, job_id, user=None, filters=None): Returns: A instance of a subclass of TethysJob if a job with job_id exists (and was created by user if the user argument is passed in). - """ + """ # noqa: E501 filters = filters or dict() filters['label'] = self.label filters['id'] = job_id @@ -179,7 +182,7 @@ def replace_in_string(string_value): def replace_in_dict(dict_value): new_dict_value = dict() - for key, value in dict_value.iteritems(): + for key, value in dict_value.items(): new_dict_value[key] = replace_in_value(value) return new_dict_value @@ -191,14 +194,15 @@ def replace_in_tuple(tuple_value): new_tuple_value = tuple(replace_in_list(tuple_value)) return new_tuple_value - TYPE_DICT = {str: replace_in_string, - dict: replace_in_dict, - list: replace_in_list, - tuple: replace_in_tuple, - } + TYPE_DICT = { + str: replace_in_string, + dict: replace_in_dict, + list: replace_in_list, + tuple: replace_in_tuple, + } new_parameters = dict() - for parameter, value in parameters.iteritems(): + for parameter, value in parameters.items(): new_value = replace_in_value(value) new_parameters[parameter] = new_value return new_parameters @@ -242,7 +246,7 @@ class BasicJobTemplate(JobTemplate): parameters (dict): A dictionary of parameters to pass to the BasicJob constructor. """ def __init__(self, name, parameters=None): - super(self.__class__, self).__init__(name, JOB_TYPES['BASIC'], parameters) + super(BasicJobTemplate, self).__init__(name, JOB_TYPES['BASIC'], parameters) def process_parameters(self): pass @@ -254,12 +258,8 @@ class CondorJobDescription(object): """ def __init__(self, condorpy_template_name=None, remote_input_files=None, **kwargs): self.remote_input_files = remote_input_files - self.attributes = dict() - - if condorpy_template_name: - template = CondorJob.get_condorpy_template(condorpy_template_name) - self.attributes.update(template) - self.attributes.update(kwargs) + self.condorpy_template_name = condorpy_template_name + self.attributes = kwargs def process_attributes(self, app_workspace, user_workspace): self.__dict__ = JobManager._replace_workspaces(self.__dict__, app_workspace, user_workspace) @@ -271,42 +271,20 @@ class CondorJobTemplate(JobTemplate): Args: name (str): Name to refer to the template. - parameters (dict, DEPRECATED): A dictionary of key-value pairs. Each Job type defines the possible parameters. job_description (CondorJobDescription): An object containing the attributes for the condorpy job. scheduler (Scheduler): An object containing the connection information to submit the condorpy job remotely. """ - def __init__(self, name, parameters=None, job_description=None, scheduler=None, **kwargs): - parameters = parameters or dict() + def __init__(self, name, job_description, scheduler=None, **kwargs): + parameters = dict() parameters['scheduler'] = scheduler - # TODO job_description will be required when parameters is fully deprecated - if job_description: - parameters['remote_input_files'] = job_description.remote_input_files - parameters['attributes'] = job_description.attributes - else: - msg = 'The job_description argument was not defined in the job_template {0}. ' \ - 'This argument will be required in version 1.5 of Tethys.'.format(name) - warnings.warn(msg, DeprecationWarning) + parameters['remote_input_files'] = job_description.remote_input_files + parameters['condorpy_template_name'] = job_description.condorpy_template_name + parameters['attributes'] = job_description.attributes parameters.update(kwargs) - super(self.__class__, self).__init__(name, JOB_TYPES['CONDORJOB'], parameters) + super(CondorJobTemplate, self).__init__(name, JOB_TYPES['CONDORJOB'], parameters) def process_parameters(self): - attributes = dict() - - def update_attribute(attribute_name): - if attribute_name in self.parameters: - attribute = self.parameters.pop(attribute_name) - attributes[attribute_name] = attribute - - if 'condorpy_template_name' in self.parameters: - template_name = self.parameters.pop('condorpy_template_name') - template = CondorJob.get_condorpy_template(template_name) - attributes.update(template) - if 'attributes' in self.parameters: - attributes.update(self.parameters.pop('attributes')) - for attribute_name in ['executable']: - update_attribute(attribute_name) - - self.parameters['_attributes'] = attributes + pass class CondorWorkflowTemplate(JobTemplate): @@ -319,14 +297,14 @@ class CondorWorkflowTemplate(JobTemplate): jobs (list): A list of CondorWorkflowJobTemplates. max_jobs (dict, optional): A dictionary of category-max_job pairs defining the maximum number of jobs that will run simultaneously from each category. config (str, optional): A path to a configuration file for the condorpy DAG. - """ - def __init__(self, name, parameters=None, jobs=None, max_jobs=None, config=None, **kwargs): + """ # noqa: E501 + def __init__(self, name, parameters=None, jobs=None, max_jobs=None, config='', **kwargs): parameters = parameters or dict() self.node_templates = set(jobs) - parameters['_max_jobs'] = max_jobs - parameters['_config'] = config + parameters['max_jobs'] = max_jobs + parameters['config'] = config parameters.update(kwargs) - super(self.__class__, self).__init__(name, JOB_TYPES['CONDORWORKFLOW'], parameters) + super(CondorWorkflowTemplate, self).__init__(name, JOB_TYPES['CONDORWORKFLOW'], parameters) def process_parameters(self): pass @@ -334,7 +312,7 @@ def process_parameters(self): # add methods to workflow to get nodes by name. def create_job(self, app_workspace, user_workspace, **kwargs): - job = super(self.__class__, self).create_job(app_workspace, user_workspace, **kwargs) + job = super(CondorWorkflowTemplate, self).create_job(app_workspace, user_workspace, **kwargs) job.save() node_dict = dict() @@ -354,10 +332,6 @@ def add_to_node_dict(node): # add code to link nodes return job -# TODO remove when JobTemplate is made completely abstract -JOB_CAST = {CondorJob: CondorJobTemplate, - BasicJob: BasicJobTemplate, - } NODE_TYPES = {'JOB': CondorWorkflowJobNode, # 'SUBWWORKFLOW': CondorWorkflowSubworkflowNode, @@ -398,9 +372,7 @@ def create_node(self, workflow, app_workspace, user_workspace): kwargs = JobManager._replace_workspaces(self.parameters, app_workspace, user_workspace) if 'parents' in kwargs: kwargs.pop('parents') - node = self.type(name=self.name, - workflow=workflow, - **kwargs) + node = self.type(name=self.name, workflow=workflow, **kwargs) node.save() return node @@ -412,12 +384,13 @@ class CondorWorkflowJobTemplate(CondorWorkflowNodeBaseTemplate): Args: name (str): Name to refer to the template. job_description (CondorJobDescription): An instance of `CondorJobDescription` containing of key-value pairs of job attributes. - """ + """ # noqa: E501 def __init__(self, name, job_description, **kwargs): parameters = kwargs parameters['remote_input_files'] = job_description.remote_input_files - parameters['_attributes'] = job_description.attributes - super(self.__class__, self).__init__(name, NODE_TYPES['JOB'], parameters) + parameters['condorpy_template_name'] = job_description.condorpy_template_name + parameters['attributes'] = job_description.attributes + super(CondorWorkflowJobTemplate, self).__init__(name, NODE_TYPES['JOB'], parameters) def process_parameters(self): pass @@ -433,4 +406,3 @@ class CondorWorkflowDataJobTemplate(CondorWorkflowNodeBaseTemplate): class CondorWorkflowFinalTemplate(CondorWorkflowNodeBaseTemplate): pass - diff --git a/tethys_compute/migrations/0001_initial_20.py b/tethys_compute/migrations/0001_initial_20.py index 8b6005e3a..f64661ae4 100644 --- a/tethys_compute/migrations/0001_initial_20.py +++ b/tethys_compute/migrations/0001_initial_20.py @@ -82,7 +82,10 @@ class Migration(migrations.Migration): ('workspace', models.CharField(default='', max_length=1024)), ('extended_properties', tethys_compute.utilities.DictionaryField(blank=True, default='')), ('_process_results_function', models.CharField(blank=True, max_length=1024, null=True)), - ('_status', models.CharField(choices=[('PEN', 'Pending'), ('SUB', 'Submitted'), ('RUN', 'Running'), ('COM', 'Complete'), ('ERR', 'Error'), ('ABT', 'Aborted'), ('VAR', 'Various'), ('VCP', 'Various-Complete')], default='PEN', max_length=3)), + ('_status', models.CharField(choices=[('PEN', 'Pending'), ('SUB', 'Submitted'), ('RUN', 'Running'), + ('COM', 'Complete'), ('ERR', 'Error'), ('ABT', 'Aborted'), + ('VAR', 'Various'), ('VCP', 'Various-Complete')], default='PEN', + max_length=3)), ], options={ 'verbose_name': 'Job', @@ -91,14 +94,18 @@ class Migration(migrations.Migration): migrations.CreateModel( name='BasicJob', fields=[ - ('tethysjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.TethysJob')), + ('tethysjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='tethys_compute.TethysJob')), ], bases=('tethys_compute.tethysjob',), ), migrations.CreateModel( name='CondorBase', fields=[ - ('tethysjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.TethysJob')), + ('tethysjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='tethys_compute.TethysJob')), ('cluster_id', models.IntegerField(blank=True, default=0)), ('remote_id', models.CharField(blank=True, max_length=32, null=True)), ], @@ -107,8 +114,12 @@ class Migration(migrations.Migration): migrations.CreateModel( name='CondorWorkflowJobNode', fields=[ - ('condorpyjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyJob')), - ('condorworkflownode_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.CondorWorkflowNode')), + ('condorpyjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, + parent_link=True, to='tethys_compute.CondorPyJob')), + ('condorworkflownode_ptr', models.OneToOneField(auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='tethys_compute.CondorWorkflowNode')), ], bases=('tethys_compute.condorworkflownode', 'tethys_compute.condorpyjob'), ), @@ -125,27 +136,36 @@ class Migration(migrations.Migration): migrations.AddField( model_name='condorworkflownode', name='workflow', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='node_set', to='tethys_compute.CondorPyWorkflow'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='node_set', + to='tethys_compute.CondorPyWorkflow'), ), migrations.CreateModel( name='CondorJob', fields=[ - ('condorpyjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyJob')), - ('condorbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.CondorBase')), + ('condorpyjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, + parent_link=True, to='tethys_compute.CondorPyJob')), + ('condorbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='tethys_compute.CondorBase')), ], bases=('tethys_compute.condorbase', 'tethys_compute.condorpyjob'), ), migrations.CreateModel( name='CondorWorkflow', fields=[ - ('condorpyworkflow_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tethys_compute.CondorPyWorkflow')), - ('condorbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tethys_compute.CondorBase')), + ('condorpyworkflow_ptr', models.OneToOneField(auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, to='tethys_compute.CondorPyWorkflow')), + ('condorbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='tethys_compute.CondorBase')), ], bases=('tethys_compute.condorbase', 'tethys_compute.condorpyworkflow'), ), migrations.AddField( model_name='condorbase', name='scheduler', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tethys_compute.Scheduler'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + to='tethys_compute.Scheduler'), ), ] diff --git a/tethys_compute/migrations/__init__.py b/tethys_compute/migrations/__init__.py index a5e4bf07b..e37944e29 100644 --- a/tethys_compute/migrations/__init__.py +++ b/tethys_compute/migrations/__init__.py @@ -1,6 +1,6 @@ # add the following to operations in migrations # from . import initialize_settings - # migrations.RunPython(initialize_settings), +# migrations.RunPython(initialize_settings), def initialize_settings(apps, schema_editor): @@ -25,9 +25,10 @@ def initialize_settings(apps, schema_editor): s = Setting(name=setting, category=category) s.save() + def clear_settings(apps, schema_edititor): SettingsCategory = apps.get_model('tethys_compute', 'SettingsCategory') Setting = apps.get_model('tethys_compute', 'Setting') SettingsCategory.objects.all().delete() - Setting.objects.all().delete() \ No newline at end of file + Setting.objects.all().delete() diff --git a/tethys_compute/models.py b/tethys_compute/models.py index 7a6865416..3da4849e2 100644 --- a/tethys_compute/models.py +++ b/tethys_compute/models.py @@ -11,13 +11,12 @@ import shutil import datetime import inspect -from abc import abstractmethod, abstractproperty +from abc import abstractmethod import logging -log = logging.getLogger('tethys.tethys_compute.models') from django.db import models from django.contrib.auth.models import User -from django.db.models.signals import pre_save, pre_delete +from django.db.models.signals import pre_save, pre_delete, post_save from django.dispatch import receiver from django.utils import timezone from model_utils.managers import InheritanceManager @@ -27,6 +26,8 @@ from condorpy import Job, Workflow, Node, Templates +log = logging.getLogger('tethys' + __name__) + class Scheduler(models.Model): name = models.CharField(max_length=1024) @@ -97,6 +98,7 @@ def run_time(self): end_time = self.completion_time or datetime.datetime.now(start_time.tzinfo) run_time = end_time - start_time else: + # TODO: Is this code reachable? if self.completion_time and self.execute_time: run_time = self.completion_time - self.execute_time else: @@ -117,7 +119,7 @@ def update_status(self, *args, **kwargs): old_status = self._status if self._status in ['PEN', 'SUB', 'RUN', 'VAR']: if not hasattr(self, '_last_status_update') \ - or datetime.datetime.now()-self.last_status_update > self.update_status_interval: + or datetime.datetime.now() - self.last_status_update > self.update_status_interval: self._update_status(*args, **kwargs) self._last_status_update = datetime.datetime.now() if self._status == 'RUN' and (old_status == 'PEN' or old_status == 'SUB'): @@ -214,7 +216,6 @@ def resume(self): # condorpy_logger.activate_console_logging() - class CondorBase(TethysJob): """ Base class for CondorJob and CondorWorkflow @@ -238,7 +239,7 @@ class CondorBase(TethysJob): def condor_object(self): """ Returns: an instance of a condorpy job or condorpy workflow with scheduler, cluster_id, and remote_id attributes set - """ + """ # noqa: E501 condor_object = self._condor_object condor_object._cluster_id = self.cluster_id condor_object._cwd = self.workspace @@ -254,7 +255,7 @@ def condor_object(self): condor_object._remote_id = self.remote_id return condor_object - @abstractproperty + @abstractmethod def _condor_object(self): """ Returns: an instance of a condorpy job or condorpy workflow @@ -287,7 +288,7 @@ def _update_status(self): running_statuses = statuses['Unexpanded'] + statuses['Idle'] + statuses['Running'] if not running_statuses: condor_status = 'Various-Complete' - except Exception as e: + except Exception: # raise e condor_status = 'Submission_err' self._status = self.STATUS_MAP[condor_status] @@ -328,10 +329,23 @@ class CondorPyJob(models.Model): _num_jobs = models.IntegerField(default=1) _remote_input_files = ListField(default='') + def __init__(self, *args, **kwargs): + # if condorpy_template_name or attributes is passed in then get the template and add it to the _attributes + attributes = kwargs.pop('attributes', dict()) + _attributes = kwargs.get('_attributes', dict()) + attributes.update(_attributes) + condorpy_template_name = kwargs.pop('condorpy_template_name', None) + if condorpy_template_name is not None: + template = self.get_condorpy_template(condorpy_template_name) + template.update(attributes) + attributes = template + kwargs['_attributes'] = attributes + super(CondorPyJob, self).__init__(*args, **kwargs) + @classmethod def get_condorpy_template(cls, template_name): template_name = template_name or 'base' - template = getattr(Templates, template_name) + template = getattr(Templates, template_name, None) if not template: template = Templates.base return template @@ -353,11 +367,11 @@ def condorpy_job(self): def attributes(self): return self._attributes - # @attributes.setter - # def attributes(self, attributes): - # assert isinstance(attributes, dict) - # self.condorpy_job._attributes = attributes - # self._attributes = attributes + @attributes.setter + def attributes(self, attributes): + assert isinstance(attributes, dict) + self._attributes = attributes + self.condorpy_job._attributes = attributes @property def num_jobs(self): @@ -383,7 +397,7 @@ def initial_dir(self): return os.path.join(self.workspace, self.condorpy_job.initial_dir) def get_attribute(self, attribute): - self.condorpy_job.get(attribute) + return self.condorpy_job.get(attribute) def set_attribute(self, attribute, value): setattr(self.condorpy_job, attribute, value) @@ -457,6 +471,11 @@ def condorpy_workflow(self): def max_jobs(self): return self._max_jobs + @max_jobs.setter + def max_jobs(self, max_jobs): + self.condorpy_workflow._max_jobs = max_jobs + self._max_jobs = max_jobs + @property def config(self): return self._config @@ -581,11 +600,11 @@ class CondorWorkflowNode(models.Model): noop = models.BooleanField(default=False) done = models.BooleanField(default=False) - @abstractproperty + @abstractmethod def type(self): pass - @abstractproperty + @abstractmethod def job(self): pass @@ -626,19 +645,6 @@ class CondorWorkflowJobNode(CondorWorkflowNode, CondorPyJob): """ CondorWorkflow JOB type node """ - def __init__(self, *args, **kwargs): - """ - Initialize both CondorWorkflowNode and CondorPyJob - - Args: - name: - workflow: - attributes: - num_jobs: - remote_input_files: - """ - CondorWorkflowNode.__init__(self, *args, **kwargs) - CondorPyJob.__init__(self, *args, **kwargs) @property def type(self): @@ -660,3 +666,12 @@ def update_database_fields(self): @receiver(pre_save, sender=CondorWorkflowJobNode) def condor_workflow_job_node_pre_save(sender, instance, raw, using, update_fields, **kwargs): instance.update_database_fields() + + +@receiver(post_save, sender=CondorJob) +@receiver(post_save, sender=BasicJob) +@receiver(post_save, sender=TethysJob) +def tethys_job_post_save(sender, instance, raw, using, update_fields, **kwargs): + if instance.name.find('{id}') >= 0: + instance.name = instance.name.format(id=instance.id) + instance.save() diff --git a/tethys_compute/tests.py b/tethys_compute/tests.py deleted file mode 100644 index 54bb157c6..000000000 --- a/tethys_compute/tests.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -******************************************************************************** -* Name: tests.py -* Author: Scott Christensen -* Created On: 2015 -* Copyright: (c) Brigham Young University 2015 -* License: BSD 2-Clause -******************************************************************************** -""" -from django.test import TestCase - -# Create your tests here. diff --git a/tethys_compute/urls.py b/tethys_compute/urls.py index 915d6a8bd..bdf285dc0 100644 --- a/tethys_compute/urls.py +++ b/tethys_compute/urls.py @@ -7,13 +7,3 @@ * License: BSD 2-Clause ******************************************************************************** """ -from django.conf.urls import url - -# from tethys_compute import views -# -# urlpatterns = [ -# url(r'^cluster/$', views.index, name='index'), -# url(r'^cluster/add/$', views.create_cluster, name='create_cluster'), -# url(r'^cluster/(?P\d+)/update/$', views.update_cluster, name='update_cluster'), -# url(r'^cluster/(?P\d+)/delete/$', views.delete_cluster, name='delete_cluster'), -# ] \ No newline at end of file diff --git a/tethys_compute/utilities.py b/tethys_compute/utilities.py index 74b291f3b..421ebc43e 100644 --- a/tethys_compute/utilities.py +++ b/tethys_compute/utilities.py @@ -10,7 +10,6 @@ import json from django import forms -from django.core import exceptions from django.db import models from django.utils.translation import ugettext_lazy as _ from future.utils import with_metaclass @@ -22,6 +21,7 @@ # deprecated code copied from: https://github.com/django/django/blob/stable/1.9.x/django/db/models/fields/subclassing.py +# TODO: Talk to Scott about this class SubfieldBase(type): """ A metaclass for custom Field subclasses. This ensures the model's attribute @@ -49,9 +49,12 @@ def __get__(self, obj, type=None): return obj.__dict__[self.field.name] def __set__(self, obj, value): + # Google says this is bad + # TODO: this is NOT tested obj.__dict__[self.field.name] = self.field.to_python(value) +# TODO: Talk to Scott about this - Not used, can we take this out? def make_contrib(superclass, func=None): """ Returns a suitable contribute_to_class() method for the Field subclass. @@ -86,15 +89,16 @@ def to_python(self, value): elif isinstance(value, basestring): try: return dict(json.loads(value)) - except (ValueError, TypeError): - raise exceptions.ValidationError(self.error_messages['invalid value for json: %s' % value]) + except (ValueError, TypeError) as e: + raise e + # raise exceptions.ValidationError(self.error_messages['invalid value for json: %s' % value]) if isinstance(value, dict): return value else: return {} - def from_db_value(self, value, expression, connection, context): + def from_db_value(self, value, expression, connection, context): return self.to_python(value) def get_prep_value(self, value): @@ -164,4 +168,4 @@ def clean(self, value, model_instance): def formfield(self, **kwargs): defaults = {'widget': forms.Textarea} defaults.update(kwargs) - return super(ListField, self).formfield(**defaults) \ No newline at end of file + return super(ListField, self).formfield(**defaults) diff --git a/tethys_compute/views.py b/tethys_compute/views.py deleted file mode 100644 index 1d8a85f6f..000000000 --- a/tethys_compute/views.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -******************************************************************************** -* Name: views.py -* Author: Scott Christensen -* Created On: 2015 -* Copyright: (c) Brigham Young University 2015 -* License: BSD 2-Clause -******************************************************************************** -""" -# from django.shortcuts import render, redirect, get_object_or_404, get_list_or_404 -# from django.http import HttpResponse, HttpResponseServerError -# from django.urls import reverse -# from django.core.exceptions import PermissionDenied -# -# from tethyscluster.cli_api import TethysCluster -# from tethyscluster import config, cluster -# from subprocess import Popen -# from multiprocessing import Process -# from threading import Timer -# -# from tethys_compute.models import TethysJob, Cluster - -# Create your views here. - -# def index(request): -# clusters = Cluster.objects.all() -# return render(request, 'tethys_compute/cluster_index.html', {'title':'Computing Resources', 'clusters':clusters}) -# -# -# -# -# def create_cluster(request): -# if request.POST: -# name = request.POST['name'] -# size = int(request.POST['size']) -# try: -# #sc = TethysCluster() -# #sc.start(name, cluster_size=size) -# -# # cm = config.get_cluster_manager() -# # cl = cm.get_default_template_cluster() -# # cl.update({'cluster_size':size}) -# # cl.start() -# -# process = Popen(['tethyscluster', 'start', '-s', str(size), name]) -# -# # t = Timer(120, _status_update) -# # t.start() -# -# except Exception as e: -# return HttpResponseServerError('There was an error with TethysCluster: %s' % str(e.message)) -# -# cluster = Cluster(name=name, size=size) -# cluster.save() -# -# return redirect(reverse('index')) -# else: -# raise Exception -# -# -# def update_cluster(request, pk): -# if request.POST: -# cluster = get_object_or_404(Cluster, id=pk) -# name = cluster.name -# delta_size = abs(request.POST['size'] - cluster.size) -# if delta_size != 0: -# cmd = 'addnode' if request.POST['size'] > cluster.size else 'deletenode' -# -# Popen(['tethyscluster', cmd, '-n', delta_size, name]) -# -# cluster.size = request.POST['size'] -# cluster.status = 'UPD' -# cluster.save() -# -# return redirect(reverse('index')) -# else: -# raise Exception -# -# def delete_cluster(request, pk): -# cluster = get_object_or_404(Cluster, id=pk) -# name = cluster.name -# -# try: -# # sc = TethysCluster() -# # sc.terminate(name) -# -# # cm = config.get_cluster_manager() -# # cl = cm.get_cluster(name) -# # cl.terminate_cluster(force=True) -# -# # Popen(['tethyscluster', 'terminate', '-f', '-c', name]) -# -# process = Process(target=_delete_cluster, args=(name,)) -# process.start() -# -# except: -# HttpResponse('There was an error with TethysCluster') -# -# cluster.status = 'DEL' -# cluster.save() -# -# return redirect(reverse('index')) -# -# -# def _start_cluster(name, size): -# cm = config.get_cluster_manager() -# cl = cm.get_default_template_cluster(name) -# cl.update({'cluster_size':size}) -# cl.start() -# -# def _delete_cluster(name): -# cm = config.get_cluster_manager() -# cl = cm.get_cluster(name) -# cl.terminate_cluster(force=True) -# cluster = Cluster.objects.get(name=name) -# cluster.delete() \ No newline at end of file diff --git a/tethys_config/__init__.py b/tethys_config/__init__.py index 4752315fe..e4e835c96 100644 --- a/tethys_config/__init__.py +++ b/tethys_config/__init__.py @@ -8,4 +8,4 @@ ******************************************************************************** """ # Load the custom app config -default_app_config = 'tethys_config.apps.TethysPortalConfig' \ No newline at end of file +default_app_config = 'tethys_config.apps.TethysPortalConfig' diff --git a/tethys_config/admin.py b/tethys_config/admin.py index ef56f06ee..72e0bd86f 100644 --- a/tethys_config/admin.py +++ b/tethys_config/admin.py @@ -37,4 +37,4 @@ def has_add_permission(self, request): return False -admin.site.register(SettingsCategory, SettingCategoryAdmin) \ No newline at end of file +admin.site.register(SettingsCategory, SettingCategoryAdmin) diff --git a/tethys_config/init.py b/tethys_config/init.py index 45f988f3e..2e505aa4b 100644 --- a/tethys_config/init.py +++ b/tethys_config/init.py @@ -121,7 +121,7 @@ def initial_settings(apps, schema_editor): date_modified=now) home_category.setting_set.create(name="Feature 2 Body", - content="Describe the apps and tools that your Tethys Portal provides and add" + content="Describe the apps and tools that your Tethys Portal provides and add " "custom pictures to each feature as a finishing touch.", date_modified=now) diff --git a/tethys_config/models.py b/tethys_config/models.py index 475b85b38..3b47c4a76 100644 --- a/tethys_config/models.py +++ b/tethys_config/models.py @@ -17,9 +17,6 @@ class Meta: verbose_name = 'Settings Category' verbose_name_plural = 'Site Settings' - def __unicode__(self): - return self.name - def __str__(self): return self.name @@ -30,9 +27,6 @@ class Setting(models.Model): date_modified = models.DateTimeField('date modified', auto_now=True) category = models.ForeignKey(SettingsCategory) - def __unicode__(self): - return self.name - def __str__(self): return self.name diff --git a/tethys_config/tests.py b/tethys_config/tests.py deleted file mode 100644 index f785d1b21..000000000 --- a/tethys_config/tests.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -******************************************************************************** -* Name: tests.py -* Author: Nathan Swain -* Created On: 2014 -* Copyright: (c) Brigham Young University 2014 -* License: BSD 2-Clause -******************************************************************************** -""" -from django.test import TestCase - -# Create your tests here. diff --git a/tethys_config/views.py b/tethys_config/views.py index 00fa4bbb1..e69de29bb 100644 --- a/tethys_config/views.py +++ b/tethys_config/views.py @@ -1,12 +0,0 @@ -""" -******************************************************************************** -* Name: views.py -* Author: Nathan Swain -* Created On: 2014 -* Copyright: (c) Brigham Young University 2014 -* License: BSD 2-Clause -******************************************************************************** -""" -from django.shortcuts import render - -# Create your views here. diff --git a/tethys_gizmos/__init__.py b/tethys_gizmos/__init__.py index e57838cbb..e3d1e52cc 100644 --- a/tethys_gizmos/__init__.py +++ b/tethys_gizmos/__init__.py @@ -6,4 +6,4 @@ * Copyright: (c) Brigham Young University 2014 * License: BSD 2-Clause ******************************************************************************** -""" \ No newline at end of file +""" diff --git a/tethys_gizmos/admin.py b/tethys_gizmos/admin.py index 94f5292fc..bd1fee0b0 100644 --- a/tethys_gizmos/admin.py +++ b/tethys_gizmos/admin.py @@ -7,6 +7,6 @@ * License: BSD 2-Clause ******************************************************************************** """ -from django.contrib import admin +# from django.contrib import admin # Register your models here. diff --git a/tethys_gizmos/context_processors.py b/tethys_gizmos/context_processors.py index 217a23365..f4aa089a1 100644 --- a/tethys_gizmos/context_processors.py +++ b/tethys_gizmos/context_processors.py @@ -16,4 +16,4 @@ def tethys_gizmos_context(request): # Setup variables context = {'gizmos_rendered': []} - return context \ No newline at end of file + return context diff --git a/tethys_gizmos/gizmo_options/__init__.py b/tethys_gizmos/gizmo_options/__init__.py index 9916965d4..39a901f87 100644 --- a/tethys_gizmos/gizmo_options/__init__.py +++ b/tethys_gizmos/gizmo_options/__init__.py @@ -7,6 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ +# flake8: noqa from .date_picker import * from .button import * from .range_slider import * diff --git a/tethys_gizmos/gizmo_options/base.py b/tethys_gizmos/gizmo_options/base.py index 1fcaa39f2..3febd7cb0 100644 --- a/tethys_gizmos/gizmo_options/base.py +++ b/tethys_gizmos/gizmo_options/base.py @@ -11,13 +11,14 @@ from past.builtins import basestring + class TethysGizmoOptions(dict): """ Base class for Tethys Gizmo Options objects. """ - + gizmo_name = "tethys_gizmo_options" - + def __init__(self, attributes={}, classes=''): """ Constructor for Tethys Gizmo Options base. @@ -36,7 +37,7 @@ def __init__(self, attributes={}, classes=''): pairs = [x.strip().strip('\'').strip('\"') for x in pairs] attributes = dict() for i in range(1, len(pairs), 2): - attributes[pairs[i]] = pairs[i+1] + attributes[pairs[i]] = pairs[i + 1] self.attributes = attributes self.classes = classes @@ -58,7 +59,7 @@ def get_tethys_gizmos_css(): @staticmethod def get_vendor_js(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ return () @@ -66,7 +67,7 @@ def get_vendor_js(): @staticmethod def get_gizmo_js(): """ - JavaScript specific to gizmo to be placed in the + JavaScript specific to gizmo to be placed in the {% block scripts %} block """ return () @@ -74,7 +75,7 @@ def get_gizmo_js(): @staticmethod def get_vendor_css(): """ - CSS vendor libraries to be placed in the + CSS vendor libraries to be placed in the {% block styles %} block """ return () @@ -82,11 +83,12 @@ def get_vendor_css(): @staticmethod def get_gizmo_css(): """ - CSS specific to gizmo to be placed in the - {% block content_dependent_styles %} block + CSS specific to gizmo to be placed in the + {% block content_dependent_styles %} block """ return () - + + class SecondaryGizmoOptions(dict): """ Base class for Secondary Tethys Gizmo Options objects. @@ -100,4 +102,4 @@ def __init__(self): super(SecondaryGizmoOptions, self).__init__() # Dictionary magic - self.__dict__ = self \ No newline at end of file + self.__dict__ = self diff --git a/tethys_gizmos/gizmo_options/bokeh_view.py b/tethys_gizmos/gizmo_options/bokeh_view.py index 3de643db3..bee9494d0 100644 --- a/tethys_gizmos/gizmo_options/bokeh_view.py +++ b/tethys_gizmos/gizmo_options/bokeh_view.py @@ -1,7 +1,7 @@ # coding=utf-8 from bokeh.embed import components from bokeh.resources import CDN - + from .base import TethysGizmoOptions __all__ = ['BokehView'] @@ -10,7 +10,7 @@ class BokehView(TethysGizmoOptions): """ Simple options object for Bokeh plotting. - + .. note:: For more information about Bokeh and for Python examples, see http://bokeh.pydata.org. Attributes: @@ -20,27 +20,27 @@ class BokehView(TethysGizmoOptions): attributes(Optional[dict]): Dictionary of attributed to add to the outer div. classes(Optional[str]): Space separated string of classes to add to the outer div. hidden(Optional[bool]): If True, the plot will be hidden. Default is False. - + Controller Code Example:: - + from tethys_sdk.gizmos import BokehView from bokeh.plotting import figure - + plot = figure(plot_height=300) plot.circle([1,2], [3,4]) my_bokeh_view = BokehView(plot, height="300px") context = {'bokeh_view_input': my_bokeh_view} - + Template Code Example:: - + {% load tethys_gizmos %} - + {% gizmo bokeh_view_input %} """ gizmo_name = "bokeh_view" - def __init__(self, plot_input, height='520px', width='100%', + def __init__(self, plot_input, height='520px', width='100%', attributes='', classes='', divid='', hidden=False): """ Constructor @@ -56,15 +56,15 @@ def __init__(self, plot_input, height='520px', width='100%', @staticmethod def get_vendor_css(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ return CDN.css_files - + @staticmethod def get_vendor_js(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ - return CDN.js_files \ No newline at end of file + return CDN.js_files diff --git a/tethys_gizmos/gizmo_options/button.py b/tethys_gizmos/gizmo_options/button.py index d40180471..6df1e936e 100644 --- a/tethys_gizmos/gizmo_options/button.py +++ b/tethys_gizmos/gizmo_options/button.py @@ -14,7 +14,7 @@ class ButtonGroup(TethysGizmoOptions): """ - The button group gizmo can be used to generate a single button or a group of buttons. Groups of buttons can be stacked horizontally or vertically. For a single button, specify a button group with one button. This gizmo is a wrapper for Twitter Bootstrap buttons. + The button group gizmo can be used to generate a single button or a group of buttons. Groups of buttons can be stacked horizontally or vertically. For a single button, specify a button group with one button. This gizmo is a wrapper for Twitter Bootstrap buttons. Attributes: buttons(list, required): A list of dictionaries where each dictionary contains the options for a button. @@ -68,7 +68,7 @@ class ButtonGroup(TethysGizmoOptions): {% gizmo horizontal_buttons %} {% gizmo vertical_buttons %} - """ + """ # noqa: E501 gizmo_name = "button_group" def __init__(self, buttons, vertical=False, attributes='', classes=''): @@ -92,7 +92,7 @@ class Button(TethysGizmoOptions): href(str): Link for anchor type buttons. submit(bool): Set this to true to make the button a submit type button for forms. disabled(bool): Set the disabled state. - attributes(dict): A dictionary representing additional HTML attributes to add to the primary element (e.g. {"onclick": "run_me();"}). + attributes(dict): A dictionary representing additional HTML attributes to add to the primary element (e.g. {"onclick": "run_me();"}). classes(str): Additional classes to add to the primary HTML element (e.g. "example-class another-class"). Controller Example @@ -191,7 +191,7 @@ class Button(TethysGizmoOptions): {% gizmo remove_button %} {% gizmo previous_button %} {% gizmo next_button %} - """ + """ # noqa: E501 gizmo_name = "button" def __init__(self, display_text='', name='', style='', icon='', href='', @@ -208,4 +208,4 @@ def __init__(self, display_text='', name='', style='', icon='', href='', self.icon = icon self.href = href self.submit = submit - self.disabled = disabled \ No newline at end of file + self.disabled = disabled diff --git a/tethys_gizmos/gizmo_options/datatable_view.py b/tethys_gizmos/gizmo_options/datatable_view.py index b6b2426f5..c257c4b29 100644 --- a/tethys_gizmos/gizmo_options/datatable_view.py +++ b/tethys_gizmos/gizmo_options/datatable_view.py @@ -12,10 +12,12 @@ __all__ = ['DataTableView'] + class DataTableView(TethysGizmoOptions): """ - Table views can be used to display tabular data. The table view gizmo can be configured to have columns that are editable. When used in this capacity, embed the table view in a form with a submit button. - + Table views can be used to display tabular data. The table view gizmo can be configured to have columns that are + editable. When used in this capacity, embed the table view in a form with a submit button. + .. note:: The current version of DataTables in Tethys Platform is 1.10.12. Attributes: @@ -43,14 +45,14 @@ class DataTableView(TethysGizmoOptions): context = { 'datatable_view': datatable_default} - Regular Template Example + Regular Template Example :: - + {% load tethys_gizmos %} - + {% gizmo datatable_view %} - + .. note:: You can also add extensions to the data table view as shown in the next example. To learn more about DataTable extensions, go to https://datatables.net/extensions/index. @@ -66,35 +68,35 @@ class DataTableView(TethysGizmoOptions): ('Bob', 26, 'boss')], colReorder=True, ) - + context = { 'datatable_with_extension': datatable_with_extension} - ColReorder Template Example + ColReorder Template Example :: {% load tethys_gizmos %} - + #LOAD IN EXTENSION JAVASCRIPT/CSS {% block global_scripts %} {{ block.super }} {% endblock %} - + {% block styles %} {{ block.super }} {% endblock %} #END LOAD IN EXTENSION JAVASCRIPT/CSS - + {% gizmo datatable_with_extension %} - """ - ##UNSUPPORTED_EXTENSIONS = ('autoFill', 'select', 'keyTable', 'rowReorder') - ##SUPPORTED_EXTENSIONS = ('buttons', 'colReorder', 'fizedColumns', - ## 'fixedHeader', 'responsive', 'scroller') + """ # noqa: E501 + # UNSUPPORTED_EXTENSIONS = ('autoFill', 'select', 'keyTable', 'rowReorder') + # SUPPORTED_EXTENSIONS = ('buttons', 'colReorder', 'fizedColumns', + # 'fixedHeader', 'responsive', 'scroller') gizmo_name = "datatable_view" - + def __init__(self, rows, column_names, footer=False, attributes={}, classes='', **kwargs): """ Constructor @@ -105,23 +107,23 @@ def __init__(self, rows, column_names, footer=False, attributes={}, classes='', self.rows = rows self.column_names = column_names self.footer = footer - self.datatable_options = {} + self.datatable_options = {} for key, value in kwargs.items(): - data_name = re.sub("([a-z])([A-Z])","\g<1>-\g<2>",key).lower() + data_name = re.sub("([a-z])([A-Z])", "\g<1>-\g<2>", key).lower() self.datatable_options[data_name] = dumps(value) - + @staticmethod def get_vendor_css(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ return ('https://cdn.datatables.net/1.10.12/css/jquery.dataTables.min.css',) - + @staticmethod def get_vendor_js(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ return ('https://cdn.datatables.net/1.10.12/js/jquery.dataTables.min.js',) @@ -129,7 +131,7 @@ def get_vendor_js(): @staticmethod def get_gizmo_js(): """ - JavaScript specific to gizmo to be placed in the + JavaScript specific to gizmo to be placed in the {% block scripts %} block """ - return ('tethys_gizmos/js/datatable_view.js',) \ No newline at end of file + return ('tethys_gizmos/js/datatable_view.js',) diff --git a/tethys_gizmos/gizmo_options/date_picker.py b/tethys_gizmos/gizmo_options/date_picker.py index 04784802d..781e7d134 100644 --- a/tethys_gizmos/gizmo_options/date_picker.py +++ b/tethys_gizmos/gizmo_options/date_picker.py @@ -14,7 +14,7 @@ class DatePicker(TethysGizmoOptions): """ - Date pickers are used to make the input of dates streamlined and easy. Rather than typing the date, the user is presented with a calendar to select the date. This date picker was implemented using `Bootstrap Datepicker `_. + Date pickers are used to make the input of dates streamlined and easy. Rather than typing the date, the user is presented with a calendar to select the date. This date picker was implemented using `Bootstrap Datepicker `_. Attributes: name (str, required): Name of the input element that will be used for form submission. @@ -74,7 +74,7 @@ class DatePicker(TethysGizmoOptions): {% gizmo date_picker %} {% gizmo date_picker_error %} - """ + """ # noqa: E501 gizmo_name = "date_picker" def __init__(self, name, display_text='', autoclose=False, calendar_weeks=False, clear_button=False, @@ -106,19 +106,18 @@ def __init__(self, name, display_text='', autoclose=False, calendar_weeks=False, self.disabled = disabled self.error = error - @staticmethod def get_vendor_css(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ return ('tethys_gizmos/vendor/bootstrap_datepicker/css/datepicker3.css',) - + @staticmethod def get_vendor_js(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ - return ('tethys_gizmos/vendor/bootstrap_datepicker/js/bootstrap_datepicker.js',) \ No newline at end of file + return ('tethys_gizmos/vendor/bootstrap_datepicker/js/bootstrap_datepicker.js',) diff --git a/tethys_gizmos/gizmo_options/esri_map.py b/tethys_gizmos/gizmo_options/esri_map.py index 5d5a735bb..32834ef16 100644 --- a/tethys_gizmos/gizmo_options/esri_map.py +++ b/tethys_gizmos/gizmo_options/esri_map.py @@ -12,7 +12,7 @@ class ESRIMap(TethysGizmoOptions): Attributes height(string, required): Height of map container in normal css units width(string, required): Width of map container in normal css units - basemap(string, required): Basemap layer. Values=[streets,satellite,hybrid,topo,gray,dark-gray,oceans,national-geographic,terrain,osm,dark-gray-vector,gray-vector,street-vector,topo-vector,streets-night-vector,streets-relief-vector,streets-navigation-vector] + basemap(string, required): Basemap layer. Values=[streets,satellite,hybrid,topo,gray,dark-gray,oceans, national-geographic,terrain,osm,dark-gray-vector,gray-vector,street-vector, topo-vector,streets-night-vector,streets-relief-vector,streets-navigation-vector] zoom(string,required): Zoom Level of the Basemap. view(EMView): An EVView object specifying the initial view or extent for the map @@ -31,10 +31,10 @@ class ESRIMap(TethysGizmoOptions): {% gizmo esri_map_view_options %} - """ + """ # noqa: E501 gizmo_name = "esri_map" - def __init__(self, height='100%', width='100%', basemap='topo',view={'center':[-100,40],'zoom':2},layers=[]): + def __init__(self, height='100%', width='100%', basemap='topo', view={'center': [-100, 40], 'zoom': 2}, layers=[]): """ Constructor """ @@ -90,7 +90,7 @@ class EMView(SecondaryGizmoOptions): zoom=3.5, ) - """ + """ # noqa: E501 def __init__(self, center, zoom): """ Constructor @@ -122,12 +122,12 @@ class EMLayer(SecondaryGizmoOptions): esri_image_layer = EMLayer(type='ImageryLayer', url='https://sampleserver6.arcgisonline.com/arcgis/rest/services/NLCDLandCover2001/ImageServer') - """ - def __init__(self,type,url): + """ # noqa: E501 + def __init__(self, type, url): """ Constructor """ - #Initialize super class - super(EMLayer,self).__init__() + # Initialize super class + super(EMLayer, self).__init__() self.type = type self.url = url diff --git a/tethys_gizmos/gizmo_options/google_map_view.py b/tethys_gizmos/gizmo_options/google_map_view.py index df0b794f2..3b129306b 100644 --- a/tethys_gizmos/gizmo_options/google_map_view.py +++ b/tethys_gizmos/gizmo_options/google_map_view.py @@ -119,9 +119,9 @@ class GoogleMapView(TethysGizmoOptions): {% gizmo google_map_view_options %} - """ + """ # noqa:E501 gizmo_name = "google_map_view" - + def __init__(self, height, width, maps_api_key="", reference_kml_action="", drawing_types_enabled=[], initial_drawing_mode="", output_format='GEOJSON', input_overlays=[None], attributes={}, classes=''): """ @@ -138,19 +138,19 @@ def __init__(self, height, width, maps_api_key="", reference_kml_action="", draw self.initial_drawing_mode = initial_drawing_mode self.output_format = output_format self.input_overlays = input_overlays - + @staticmethod def get_vendor_js(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ return ('tethys_gizmos/vendor/farbtastic/farbtastic.js',) - @staticmethod + @staticmethod def get_vendor_css(): """ - CSS vendor libraries to be placed in the + CSS vendor libraries to be placed in the {% block styles %} block """ return ('tethys_gizmos/vendor/farbtastic/farbtastic.css',) @@ -158,7 +158,7 @@ def get_vendor_css(): @staticmethod def get_gizmo_js(): """ - JavaScript specific to gizmo to be placed in the + JavaScript specific to gizmo to be placed in the {% block scripts %} block """ return ('tethys_gizmos/js/tethys_google_map_view.js',) diff --git a/tethys_gizmos/gizmo_options/jobs_table.py b/tethys_gizmos/gizmo_options/jobs_table.py index b11b90333..9785c12db 100644 --- a/tethys_gizmos/gizmo_options/jobs_table.py +++ b/tethys_gizmos/gizmo_options/jobs_table.py @@ -61,9 +61,9 @@ class JobsTable(TethysGizmoOptions): {% gizmo jobs_table_options %} - """ + """ # noqa: E501 gizmo_name = "jobs_table" - + def __init__(self, jobs, column_fields, status_actions=True, run_btn=True, delete_btn=True, results_url='', hover=False, striped=False, bordered=False, condensed=False, attributes={}, classes='', refresh_interval=5000, delay_loading_status=True): @@ -74,9 +74,11 @@ def __init__(self, jobs, column_fields, status_actions=True, run_btn=True, delet super(JobsTable, self).__init__(attributes=attributes, classes=classes) self.jobs = jobs - self.rows = self.get_rows(jobs, column_fields) - self.column_fields = column_fields - self.column_names = [col_name.title().replace('_', ' ') for col_name in column_fields] + self.rows = None + self.column_fields = None + self.column_names = None + self.set_rows_and_columns(jobs, column_fields) + self.status_actions = status_actions self.run = run_btn self.delete = delete_btn @@ -90,45 +92,66 @@ def __init__(self, jobs, column_fields, status_actions=True, run_btn=True, delet self.refresh_interval = refresh_interval self.delay_loading_status = delay_loading_status - @classmethod - def get_rows(cls, jobs, column_fields): - rows = [] - column_names = [] + def set_rows_and_columns(self, jobs, column_fields): + self.rows = list() + self.column_fields = list() + self.column_names = list() + + if len(jobs) == 0: + return + + first_job = jobs[0] + for field in column_fields: + column_name = field.title().replace('_', ' ') + try: + getattr(first_job, field) # verify that the field name is a valid attribute on the job + self.column_names.append(column_name) + self.column_fields.append(field) + except AttributeError: + log.warning('Column %s was not added because the %s has no attribute %s.', + column_name, str(first_job), field) + for job in jobs: - row_values = [] - for attribute in column_fields: - column_name = attribute.title().replace('_', ' ') - if hasattr(job, attribute): - value = getattr(job, attribute) - # Truncate fractional seconds - if attribute == 'run_time': - # times = [] - # total_seconds = value.seconds - # times.append(('days', run_time.days)) - # times.append(('hr', total_seconds/3600)) - # times.append(('min', (total_seconds%3600)/60)) - # times.append(('sec', total_seconds%60)) - # run_time_str = '' - # for time_str, time in times: - # if time: - # run_time_str += "%s %s " % (time, time_str) - # if not run_time_str or (run_time.days == 0 and total_seconds < 2): - # run_time_str = '%.2f sec' % (total_seconds + float(run_time.microseconds)/1000000,) - value = str(value).split('.')[0] - row_values.append(value) - else: - log.waring('Column %s was not added because %s Job %s has no attribute %s.', - column_name, str(job), attribute) - - rows.append(row_values) - column_names.append(column_name) - return rows + row_values = self.get_row(job, self.column_fields) + self.rows.append(row_values) + + @staticmethod + def get_row(job, job_attributes): + """Get the field values for one row (corresponding to one job). + + Args: + job (TethysJob): An instance of a subclass of TethysJob + job_attributes (list): a list of attribute names corresponding to the fields in the jobs table + + Returns: + A list of field values for one row. + + """ + row_values = list() + for attribute in job_attributes: + value = getattr(job, attribute) + # Truncate fractional seconds + if attribute == 'run_time': + # times = [] + # total_seconds = value.seconds + # times.append(('days', run_time.days)) + # times.append(('hr', total_seconds/3600)) + # times.append(('min', (total_seconds%3600)/60)) + # times.append(('sec', total_seconds%60)) + # run_time_str = '' + # for time_str, time in times: + # if time: + # run_time_str += "%s %s " % (time, time_str) + # if not run_time_str or (run_time.days == 0 and total_seconds < 2): + # run_time_str = '%.2f sec' % (total_seconds + float(run_time.microseconds)/1000000,) + value = str(value).split('.')[0] + row_values.append(value) + + return row_values @staticmethod def get_gizmo_js(): """ - JavaScript specific to gizmo to be placed in the - {% block scripts %} block + JavaScript specific to gizmo to be placed in the {% block scripts %} block """ return ('tethys_gizmos/js/jobs_table.js',) - diff --git a/tethys_gizmos/gizmo_options/map_view.py b/tethys_gizmos/gizmo_options/map_view.py index eaf8eac32..2ee57e080 100644 --- a/tethys_gizmos/gizmo_options/map_view.py +++ b/tethys_gizmos/gizmo_options/map_view.py @@ -9,8 +9,11 @@ """ from .base import TethysGizmoOptions, SecondaryGizmoOptions from django.conf import settings +import logging +log = logging.getLogger('tethys.tethys_gizmos.gizmo_options.map_view') -__all__ = ['MapView', 'MVDraw', 'MVView', 'MVLayer', 'MVLegendClass', 'MVLegendImageClass', 'MVLegendGeoServerImageClass'] +__all__ = ['MapView', 'MVDraw', 'MVView', 'MVLayer', + 'MVLegendClass', 'MVLegendImageClass', 'MVLegendGeoServerImageClass'] class MapView(TethysGizmoOptions): @@ -240,7 +243,7 @@ class MapView(TethysGizmoOptions): {% gizmo map_view_options %} - """ + """ # noqa: E501 gizmo_name = "map_view" def __init__(self, height='100%', width='100%', basemap='OpenStreetMap', view={'center': [-100, 40], 'zoom': 2}, @@ -323,7 +326,7 @@ class MVView(SecondaryGizmoOptions): minZoom=2 ) - """ + """ # noqa: E501 def __init__(self, projection, center, zoom, maxZoom=28, minZoom=0): """ @@ -364,9 +367,11 @@ class MVDraw(SecondaryGizmoOptions): point_color='#663399' ) - """ + """ # noqa: E501 - def __init__(self, controls, initial, output_format='GeoJSON',line_color="#ffcc33",fill_color='rgba(255, 255, 255, 0.2)',point_color="#ffcc33"): + def __init__(self, controls, initial, output_format='GeoJSON', + line_color="#ffcc33", fill_color='rgba(255, 255, 255, 0.2)', + point_color="#ffcc33"): """ Constructor """ @@ -374,7 +379,6 @@ def __init__(self, controls, initial, output_format='GeoJSON',line_color="#ffcc3 super(MVDraw, self).__init__() self.controls = controls - # Validate initial if initial not in self.controls: raise ValueError('Value of "initial" must be contained in the "controls" list.') @@ -521,10 +525,12 @@ class MVLayer(SecondaryGizmoOptions): options={'url': 'http://sampleserver1.arcgisonline.com/ArcGIS/rest/services/' + 'Specialty/ESRI_StateCityHighway_USA/MapServer'}, legend_title='ESRI USA Highway', legend_extent=[-173, 17, -65, 72]), - """ + """ # noqa: E501 - def __init__(self, source, options, legend_title, layer_options=None, editable=True, legend_classes=None, legend_extent=None, - legend_extent_projection='EPSG:4326', feature_selection=False, geometry_attribute=None, data={}): + def __init__(self, source, options, legend_title, layer_options=None, editable=True, + legend_classes=None, legend_extent=None, + legend_extent_projection='EPSG:4326', + feature_selection=False, geometry_attribute=None, data=None): """ Constructor """ @@ -540,11 +546,11 @@ def __init__(self, source, options, legend_title, layer_options=None, editable=T self.legend_extent_projection = legend_extent_projection self.feature_selection = feature_selection self.geometry_attribute = geometry_attribute - self.data = data + self.data = data or dict() - # TODO: this should be a log if feature_selection and not geometry_attribute: - print("WARNING: geometry_attribute not defined -using default value 'the_geom'") + log.warning("geometry_attribute not defined -using default value 'the_geom'") + class MVLegendClass(SecondaryGizmoOptions): """ @@ -565,7 +571,7 @@ class MVLegendClass(SecondaryGizmoOptions): line_class = MVLegendClass(type='line', value='Roads', stroke='rbga(0,0,0,0.7)') polygon_class = MVLegendClass(type='polygon', value='Lakes', stroke='#0000aa', fill='#0000ff') - """ + """ # noqa: E501 def __init__(self, type, value, fill='', stroke='', ramp=[]): """ @@ -616,8 +622,6 @@ def __init__(self, type, value, fill='', stroke='', ramp=[]): self.ramp = ramp else: raise ValueError('Argument "ramp" must be specified for MVLegendClass of type "raster".') - else: - raise ValueError('Invalid type specified: {0}.'.format(type)) class MVLegendImageClass(SecondaryGizmoOptions): @@ -635,7 +639,7 @@ class MVLegendImageClass(SecondaryGizmoOptions): image_class = MVLegendImageClass(value='Cities', image_url='https://upload.wikimedia.org/wikipedia/commons/d/da/The_City_London.jpg' ) - """ + """ # noqa: E501 def __init__(self, value, image_url): """ @@ -648,6 +652,7 @@ def __init__(self, value, image_url): self.value = value self.image_url = image_url + class MVLegendGeoServerImageClass(MVLegendImageClass): """ MVLegendGeoServerImageClasses are used to define the classes listed in the legend using the GeoServer generated legend. @@ -670,7 +675,7 @@ class MVLegendGeoServerImageClass(MVLegendImageClass): layer='rivers', width=20, height=10) - """ + """ # noqa: E501 def __init__(self, value, geoserver_url, style, layer, width=20, height=10): """ diff --git a/tethys_gizmos/gizmo_options/message_box.py b/tethys_gizmos/gizmo_options/message_box.py index c45c11f2e..573ad89ed 100644 --- a/tethys_gizmos/gizmo_options/message_box.py +++ b/tethys_gizmos/gizmo_options/message_box.py @@ -56,7 +56,7 @@ class MessageBox(TethysGizmoOptions): {% block after_app_content %} {% gizmo message_box %} {% endblock %} - """ + """ # noqa: E501 gizmo_name = "message_box" def __init__(self, name, title, message='', dismiss_button='Cancel', affirmative_button='Ok', diff --git a/tethys_gizmos/gizmo_options/plot_view.py b/tethys_gizmos/gizmo_options/plot_view.py index 07f059024..e6caec933 100644 --- a/tethys_gizmos/gizmo_options/plot_view.py +++ b/tethys_gizmos/gizmo_options/plot_view.py @@ -8,7 +8,7 @@ class PlotViewBase(TethysGizmoOptions): """ Plot view classes inherit from this class. - """ + """ gizmo_name = "plot_view" def __init__(self, width='500px', height='500px', engine='d3'): @@ -30,7 +30,7 @@ def __init__(self, width='500px', height='500px', engine='d3'): @staticmethod def get_vendor_js(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ return ('tethys_gizmos/vendor/highcharts/js/highcharts.js', @@ -42,7 +42,7 @@ def get_vendor_js(): @staticmethod def get_gizmo_js(): """ - JavaScript specific to gizmo to be placed in the + JavaScript specific to gizmo to be placed in the {% block scripts %} block """ return ('tethys_gizmos/js/plot_view.js',) @@ -50,8 +50,8 @@ def get_gizmo_js(): @staticmethod def get_gizmo_css(): """ - CSS specific to gizmo to be placed in the - {% block content_dependent_styles %} block + CSS specific to gizmo to be placed in the + {% block content_dependent_styles %} block """ return ('tethys_gizmos/css/plot_view.css',) @@ -61,7 +61,7 @@ class PlotObject(TethysGizmoOptions): Base Plot Object that is constructed by plot views. """ - def __init__(self, chart={}, title='', subtitle='', legend=None, display_legend=True, + def __init__(self, chart={}, title='', subtitle='', legend=None, display_legend=True, tooltip=True, x_axis={}, y_axis={}, tooltip_format={}, plotOptions={}, **kwargs): """ Constructor @@ -82,11 +82,11 @@ def __init__(self, chart={}, title='', subtitle='', legend=None, display_legend if display_legend: default_legend = { - 'layout': 'vertical', - 'align': 'right', - 'verticalAlign': 'middle', - 'borderWidth': 0 - } + 'layout': 'vertical', + 'align': 'right', + 'verticalAlign': 'middle', + 'borderWidth': 0 + } self.legend = legend or default_legend if tooltip: @@ -668,8 +668,8 @@ def __init__(self, series=[], height='500px', width='500px', engine='d3', title= if group_tools: tooltip_format = { 'headerFormat': '{point.key}', - 'pointFormat': '' + '' % ( - axis_units), + 'pointFormat': '' + + '' % (axis_units), 'footerFormat': '
{series.name}: {point.y:.1f} %s
{series.name}: {point.y:.1f} %s
', 'shared': True, 'useHTML': True @@ -875,7 +875,7 @@ class AreaRange(PlotViewBase): {% gizmo area_range_plot_object %} - """ + """ # noqa: E501 def __init__(self, series=[], height='500px', width='500px', engine='d3', title='', subtitle='', y_axis_title='', y_axis_units='', **kwargs): @@ -995,7 +995,7 @@ class HeatMap(PlotViewBase): {% gizmo heat_map_plot %} - """ + """ # noqa: E501 def __init__(self, series=[], height='500px', width='500px', engine='d3', title='', subtitle='', x_categories=[], y_categories=[], tooltip_phrase_one='', tooltip_phrase_two='', **kwargs): @@ -1024,7 +1024,9 @@ def __init__(self, series=[], height='500px', width='500px', engine='d3', title= } tooltip_format = { - 'formatter': 'function() {return "" + this.series.xAxis.categories[this.point.x] + " %s
" + this.point.value + " %s
" + this.series.yAxis.categories[this.point.y] + "";' % (tooltip_phrase_one, tooltip_phrase_two) + 'formatter': 'function() {return "" + this.series.xAxis.categories[this.point.x] + " %s
" + ' + 'this.point.value + " %s
" + this.series.yAxis.categories[this.point.y] + "";' + % (tooltip_phrase_one, tooltip_phrase_two) } # Initialize super class diff --git a/tethys_gizmos/gizmo_options/plotly_view.py b/tethys_gizmos/gizmo_options/plotly_view.py index 53d5677e3..ce4175d2c 100644 --- a/tethys_gizmos/gizmo_options/plotly_view.py +++ b/tethys_gizmos/gizmo_options/plotly_view.py @@ -9,7 +9,7 @@ class PlotlyView(TethysGizmoOptions): """ Simple options object for plotly view. - + .. note:: Information about the Plotly API can be found at https://plot.ly/python. Attributes: @@ -20,9 +20,9 @@ class PlotlyView(TethysGizmoOptions): classes(Optional[str]): Space separated string of classes to add to the outer div. hidden(Optional[bool]): If True, the plot will be hidden. Default is False. show_link(Optional[bool]): If True, the link to export plot to view in plotly is shown. Default is False. - + Controller Code Basic Example:: - + from datetime import datetime import plotly.graph_objs as go from tethys_sdk.gizmos import PlotlyView @@ -30,32 +30,32 @@ class PlotlyView(TethysGizmoOptions): x = [datetime(year=2013, month=10, day=04), datetime(year=2013, month=11, day=05), datetime(year=2013, month=12, day=06)] - + my_plotly_view = PlotlyView([go.Scatter(x=x, y=[1, 3, 6])]) - + context = {'plotly_view_input': my_plotly_view} - + Controller Code Pandas Example:: - + import numpy as np import pandas as pd from tethys_sdk.gizmos import PlotlyView - + df = pd.DataFrame(np.random.randn(1000, 2), columns=['A', 'B']).cumsum() my_plotly_view = PlotlyView(df.iplot(asFigure=True)) context = {'plotly_view_input': my_plotly_view} - + Template Code:: - + {% load tethys_gizmos %} - + {% gizmo plotly_view_input %} """ gizmo_name = "plotly_view" - - def __init__(self, plot_input, height='520px', width='100%', + + def __init__(self, plot_input, height='520px', width='100%', attributes='', classes='', divid='', hidden=False, show_link=False): """ @@ -63,9 +63,9 @@ def __init__(self, plot_input, height='520px', width='100%', """ # Initialize the super class super(PlotlyView, self).__init__() - - self.plotly_div = opy.plot(plot_input, - auto_open=False, + + self.plotly_div = opy.plot(plot_input, + auto_open=False, output_type='div', include_plotlyjs=False, show_link=show_link) @@ -79,7 +79,7 @@ def __init__(self, plot_input, height='520px', width='100%', @staticmethod def get_vendor_js(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ - return ('://plotly-load_from_python.js',) \ No newline at end of file + return ('://plotly-load_from_python.js',) diff --git a/tethys_gizmos/gizmo_options/range_slider.py b/tethys_gizmos/gizmo_options/range_slider.py index f5f432aa7..de450d0c1 100644 --- a/tethys_gizmos/gizmo_options/range_slider.py +++ b/tethys_gizmos/gizmo_options/range_slider.py @@ -62,10 +62,11 @@ class RangeSlider(TethysGizmoOptions): {% gizmo slider1 %} {% gizmo slider2 %} - """ + """ # noqa: E501 gizmo_name = "range_slider" - - def __init__(self, name, min, max, initial, step, disabled=False, display_text='', error='', attributes={}, classes=''): + + def __init__(self, name, min, max, initial, step, disabled=False, display_text='', error='', + attributes={}, classes=''): """ Constructor """ @@ -84,7 +85,7 @@ def __init__(self, name, min, max, initial, step, disabled=False, display_text=' @staticmethod def get_gizmo_js(): """ - JavaScript specific to gizmo to be placed in the + JavaScript specific to gizmo to be placed in the {% block scripts %} block """ - return ('tethys_gizmos/js/range_slider.js',) \ No newline at end of file + return ('tethys_gizmos/js/range_slider.js',) diff --git a/tethys_gizmos/gizmo_options/select_input.py b/tethys_gizmos/gizmo_options/select_input.py index 59ed5725b..84d0a14e7 100644 --- a/tethys_gizmos/gizmo_options/select_input.py +++ b/tethys_gizmos/gizmo_options/select_input.py @@ -43,27 +43,27 @@ class SelectInput(TethysGizmoOptions): initial=['Three'], select2_options={'placeholder': 'Select a number', 'allowClear': True}) - + select_input2_multiple = SelectInput(display_text='Select2 Multiple', name='select21', multiple=True, options=[('One', '1'), ('Two', '2'), ('Three', '3')], initial=['Two', 'One']) - + select_input2_error = SelectInput(display_text='Select2 Disabled', name='select22', multiple=False, options=[('One', '1'), ('Two', '2'), ('Three', '3')], disabled=True, error='Here is my error text') - + select_input = SelectInput(display_text='Select', name='select1', multiple=False, original=True, options=[('One', '1'), ('Two', '2'), ('Three', '3')], initial=['Three']) - + select_input_multiple = SelectInput(display_text='Select Multiple', name='select11', multiple=True, @@ -89,9 +89,9 @@ class SelectInput(TethysGizmoOptions): {% gizmo select_input %} {% gizmo select_input_multiple %} - """ + """ # noqa: E501 gizmo_name = "select_input" - + def __init__(self, name, display_text='', initial=[], multiple=False, original=False, select2_options=None, options='', disabled=False, error='', attributes={}, classes=''): @@ -116,7 +116,7 @@ def __init__(self, name, display_text='', initial=[], multiple=False, original=F @staticmethod def get_vendor_js(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ return ('tethys_gizmos/vendor/select2_4.0.2/js/select2.full.min.js',) @@ -124,7 +124,7 @@ def get_vendor_js(): @staticmethod def get_vendor_css(): """ - CSS vendor libraries to be placed in the + CSS vendor libraries to be placed in the {% block styles %} block """ return ('tethys_gizmos/vendor/select2_4.0.2/css/select2.min.css',) @@ -132,7 +132,7 @@ def get_vendor_css(): @staticmethod def get_gizmo_js(): """ - JavaScript specific to gizmo to be placed in the + JavaScript specific to gizmo to be placed in the {% block scripts %} block """ return ('tethys_gizmos/js/select_input.js',) diff --git a/tethys_gizmos/gizmo_options/table_view.py b/tethys_gizmos/gizmo_options/table_view.py index c2a4201eb..fda9220e7 100644 --- a/tethys_gizmos/gizmo_options/table_view.py +++ b/tethys_gizmos/gizmo_options/table_view.py @@ -64,11 +64,11 @@ class TableView(TethysGizmoOptions): :: {% load tethys_gizmos %} - + {% gizmo table_view %} {% gizmo table_view_edit %} - """ + """ # noqa: E501 gizmo_name = "table_view" def __init__(self, rows, column_names='', hover=False, striped=False, bordered=False, condensed=False, @@ -86,4 +86,4 @@ def __init__(self, rows, column_names='', hover=False, striped=False, bordered=F self.bordered = bordered self.condensed = condensed self.editable_columns = editable_columns - self.row_ids = row_ids \ No newline at end of file + self.row_ids = row_ids diff --git a/tethys_gizmos/gizmo_options/text_input.py b/tethys_gizmos/gizmo_options/text_input.py index fd58c75ea..8f8c73418 100644 --- a/tethys_gizmos/gizmo_options/text_input.py +++ b/tethys_gizmos/gizmo_options/text_input.py @@ -60,9 +60,9 @@ class TextInput(TethysGizmoOptions): {% gizmo text_input %} {% gizmo text_error_input %} - """ + """ # noqa: E501 gizmo_name = "text_input" - + def __init__(self, name, display_text='', initial='', placeholder='', prepend='', append='', icon_prepend='', icon_append='', disabled=False, error='', attributes={}, classes=''): """ @@ -80,4 +80,4 @@ def __init__(self, name, display_text='', initial='', placeholder='', prepend='' self.icon_prepend = icon_prepend self.icon_append = icon_append self.disabled = disabled - self.error = error \ No newline at end of file + self.error = error diff --git a/tethys_gizmos/gizmo_options/toggle_switch.py b/tethys_gizmos/gizmo_options/toggle_switch.py index 1d978147a..ca84084f2 100644 --- a/tethys_gizmos/gizmo_options/toggle_switch.py +++ b/tethys_gizmos/gizmo_options/toggle_switch.py @@ -73,9 +73,9 @@ class ToggleSwitch(TethysGizmoOptions): {% gizmo toggle_switch_styled %} {% gizmo toggle_switch_disabled %} - """ + """ # noqa: E501 gizmo_name = "toggle_switch" - + def __init__(self, name, display_text='', on_label='ON', off_label='OFF', on_style='primary', off_style='default', size='regular', initial=False, disabled=False, error='', attributes={}, classes=''): """ @@ -98,7 +98,7 @@ def __init__(self, name, display_text='', on_label='ON', off_label='OFF', on_sty @staticmethod def get_vendor_js(): """ - JavaScript vendor libraries to be placed in the + JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ return ('tethys_gizmos/vendor/bootstrap_switch/dist/js/bootstrap-switch.min.js',) @@ -106,7 +106,7 @@ def get_vendor_js(): @staticmethod def get_vendor_css(): """ - CSS vendor libraries to be placed in the + CSS vendor libraries to be placed in the {% block styles %} block """ return ('tethys_gizmos/vendor/bootstrap_switch/dist/css/bootstrap3/bootstrap-switch.min.css',) @@ -114,7 +114,7 @@ def get_vendor_css(): @staticmethod def get_gizmo_js(): """ - JavaScript specific to gizmo to be placed in the + JavaScript specific to gizmo to be placed in the {% block scripts %} block """ - return ('tethys_gizmos/js/toggle_switch.js',) \ No newline at end of file + return ('tethys_gizmos/js/toggle_switch.js',) diff --git a/tethys_gizmos/templates/tethys_gizmos/gizmos/test_gizmo.html b/tethys_gizmos/templates/tethys_gizmos/gizmos/test_gizmo.html new file mode 100644 index 000000000..41150e9c6 --- /dev/null +++ b/tethys_gizmos/templates/tethys_gizmos/gizmos/test_gizmo.html @@ -0,0 +1 @@ +{{ name }} \ No newline at end of file diff --git a/tethys_gizmos/templatetags/tethys_gizmos.py b/tethys_gizmos/templatetags/tethys_gizmos.py index 9deb964ad..19a17053e 100644 --- a/tethys_gizmos/templatetags/tethys_gizmos.py +++ b/tethys_gizmos/templatetags/tethys_gizmos.py @@ -48,6 +48,7 @@ gizmo_module_path = gizmo_module.__path__[0] EXTENSION_PATH_MAP[cls.gizmo_name] = os.path.abspath(os.path.dirname(gizmo_module_path)) except ImportError: + # TODO: Add Log? continue register = template.Library() @@ -91,7 +92,7 @@ def isstring(value): def return_item(l, i): try: return l[i] - except: + except Exception: return None @@ -118,7 +119,7 @@ def divide(value, divisor): v = float(value) d = float(divisor) - return v/d + return v / d class TethysGizmoIncludeDependency(template.Node): @@ -134,13 +135,13 @@ def _load_gizmo_name(self, gizmo_name): This loads the rendered gizmos into context """ self.gizmo_name = gizmo_name - + if self.gizmo_name is not None: # Handle case where gizmo_name is a string literal if self.gizmo_name[0] in ('"', "'"): self.gizmo_name = self.gizmo_name.replace("'", '') self.gizmo_name = self.gizmo_name.replace('"', '') - + def _load_gizmos_rendered(self, context): """ This loads the rendered gizmos into context @@ -148,7 +149,7 @@ def _load_gizmos_rendered(self, context): # Add gizmo name to 'gizmos_rendered' context variable (used to load static libraries if 'gizmos_rendered' not in context: context.update({'gizmos_rendered': []}) - + # add the gizmo in the tag to gizmos_rendered list if self.gizmo_name is not None: if self.gizmo_name not in context['gizmos_rendered']: @@ -162,10 +163,10 @@ def render(self, context): """ try: self._load_gizmos_rendered(context) - except: + except Exception as e: if settings.TEMPLATE_DEBUG: - raise - + raise e + return '' @@ -176,7 +177,7 @@ class TethysGizmoIncludeNode(TethysGizmoIncludeDependency): def __init__(self, options, gizmo_name, *args, **kwargs): self.options = options super(TethysGizmoIncludeNode, self).__init__(gizmo_name, *args, **kwargs) - + def render(self, context): resolved_options = template.Variable(self.options).resolve(context) @@ -186,7 +187,7 @@ def render(self, context): self._load_gizmo_name(resolved_options.gizmo_name) else: raise TemplateSyntaxError('A valid gizmo name is required for this input format.') - + self._load_gizmos_rendered(context) # Derive path to gizmo template @@ -207,16 +208,16 @@ def render(self, context): t = get_template(template_name) return t.render(resolved_options) - except: + except Exception as e: if hasattr(settings, 'TEMPLATES'): for template_settings in settings.TEMPLATES: - if 'OPTIONS' in template_settings and \ - 'debug' in template_settings['OPTIONS'] and \ - template_settings['OPTIONS']['debug']: - raise + if 'OPTIONS' in template_settings \ + and 'debug' in template_settings['OPTIONS'] \ + and template_settings['OPTIONS']['debug']: + raise e return '' - + @register.tag def gizmo(parser, token): """ @@ -227,7 +228,7 @@ def gizmo(parser, token): To insert a gizmo, use the "gizmo" tag and give it a Gizmo object of configuration parameters. Example:: - + {% load tethys_gizmos %} {% gizmo options %} @@ -235,7 +236,7 @@ def gizmo(parser, token): The old method of using the gizmo name is still supported. Example:: - + {% load tethys_gizmos %} {% gizmo gizmo_name options %} @@ -246,7 +247,7 @@ def gizmo(parser, token): """ gizmo_arg_list = token.split_contents()[1:] if len(gizmo_arg_list) == 1: - gizmo_options = gizmo_arg_list[0] + gizmo_options = gizmo_arg_list[0] gizmo_name = None elif len(gizmo_arg_list) == 2: gizmo_name, gizmo_options = gizmo_arg_list @@ -259,17 +260,17 @@ def gizmo(parser, token): @register.tag def import_gizmo_dependency(parser, token): """ - The gizmo dependency tag will add the dependencies for the gizmo specified + The gizmo dependency tag will add the dependencies for the gizmo specified so that is will be loaded when using the *gizmo_dependencies* tag. - To manually import a gizmo's dependency, use the "import_gizmo_dependency" + To manually import a gizmo's dependency, use the "import_gizmo_dependency" tag and give it the name of a gizmo. It needs to be inside of the "import_gizmos" block. Example:: - + {% load tethys_gizmos %} - + {% block import_gizmos %} {% import_gizmo_dependency example_gizmo %} {% import_gizmo_dependency "example_gizmo" %} @@ -290,7 +291,7 @@ class TethysGizmoDependenciesNode(template.Node): """ Loads gizmo dependencies and renders in "script" or "link" tag appropriately. """ - + def __init__(self, output_type, *args, **kwargs): super(TethysGizmoDependenciesNode, self).__init__(*args, **kwargs) self.output_type = output_type @@ -318,7 +319,7 @@ def render(self, context): # initialize lists to store global gizmo css/js dependencies if 'global_gizmo_js_list' not in context.render_context: context.render_context['global_gizmo_js_list'] = [] - + if 'global_gizmo_css_list' not in context.render_context: context.render_context['global_gizmo_css_list'] = [] @@ -328,7 +329,7 @@ def render(self, context): if 'gizmo_css_list' not in context.render_context: context.render_context['gizmo_css_list'] = [] - + # load list of gizmo css/js dependencies if 'gizmo_dependencies_loaded' not in context.render_context: # add all gizmos in context to be loaded @@ -342,29 +343,29 @@ def render(self, context): for rendered_gizmo in context['gizmos_rendered']: # Retrieve the "gizmo_dependencies" module and find the appropriate function dependencies_module = GIZMO_NAME_MAP[rendered_gizmo] - + # Only append dependencies if they do not already exist for dependency in dependencies_module.get_gizmo_css(): self._append_dependency(dependency, context.render_context['gizmo_css_list']) for dependency in dependencies_module.get_gizmo_js(): self._append_dependency(dependency, context.render_context['gizmo_js_list']) for dependency in dependencies_module.get_vendor_css(): - self._append_dependency(dependency, context.render_context['global_gizmo_css_list']) + self._append_dependency(dependency, context.render_context['global_gizmo_css_list']) for dependency in dependencies_module.get_vendor_js(): self._append_dependency(dependency, context.render_context['global_gizmo_js_list']) - + # Add the main gizmo dependencies last for dependency in TethysGizmoOptions.get_tethys_gizmos_css(): self._append_dependency(dependency, context.render_context['gizmo_css_list']) for dependency in TethysGizmoOptions.get_tethys_gizmos_js(): self._append_dependency(dependency, context.render_context['gizmo_js_list']) - + context.render_context['gizmo_dependencies_loaded'] = True - + # Create markup tags script_tags = [] style_tags = [] - + if self.output_type == CSS_GLOBAL_OUTPUT_TYPE or self.output_type is None: for dependency in context.render_context['global_gizmo_css_list']: style_tags.append(''.format(dependency)) @@ -372,15 +373,17 @@ def render(self, context): if self.output_type == CSS_OUTPUT_TYPE or self.output_type is None: for dependency in context.render_context['gizmo_css_list']: style_tags.append(''.format(dependency)) - + if self.output_type == JS_GLOBAL_OUTPUT_TYPE or self.output_type is None: for dependency in context.render_context['global_gizmo_js_list']: if dependency.endswith('plotly-load_from_python.js'): - script_tags.append(''.join([ - '', - ])) + script_tags.append(''.join( + [ + '', + ]) + ) else: script_tags.append(''.format(dependency)) diff --git a/tethys_gizmos/views/gizmo_showcase.py b/tethys_gizmos/views/gizmo_showcase.py index a55bde0c4..428eaa7d1 100644 --- a/tethys_gizmos/views/gizmo_showcase.py +++ b/tethys_gizmos/views/gizmo_showcase.py @@ -19,7 +19,10 @@ from bokeh.plotting import figure as bokeh_figure from requests.exceptions import ConnectionError -from tethys_sdk.gizmos import * +from tethys_sdk.gizmos import Button, ButtonGroup, DatePicker, RangeSlider, SelectInput, TextInput, ToggleSwitch, \ + LinePlot, ScatterPlot, PolarPlot, PiePlot, BarPlot, TimeSeries, AreaRange, PlotlyView, BokehView, TableView, \ + DataTableView, MessageBox, GoogleMapView, MVView, MVDraw, MVLayer, MVLegendClass, MapView, JobsTable, EMView, \ + EMLayer, ESRIMap from tethys_sdk.services import list_spatial_dataset_engines from tethys_compute.models import BasicJob @@ -36,7 +39,6 @@ def get_geoserver_wms(): if spatial_dataset_engine.type == 'GEOSERVER': try: spatial_dataset_engine.validate() - geoserver_engine = spatial_dataset_engine geoserver_endpoint = spatial_dataset_engine.endpoint geoserver_wms = geoserver_endpoint.replace('rest', 'wms') break @@ -156,7 +158,6 @@ def index(request): original=True, options=[('One', '1'), ('Two', '2'), ('Three', '3')]) - # Text Input text_input = TextInput(display_text='Text', name='inputAmount', @@ -327,17 +328,18 @@ def index(request): ) # D3 Scatter Plot - d3_scatter_plot_view = ScatterPlot(width='100%', - height='500px', - engine='d3', - title='D3 Scatter Plot', - subtitle='D3 Scatter Plot', - x_axis_title='Height', - x_axis_units='cm', - y_axis_title='Weight', - y_axis_units='kg', - series=[male_dataset, female_dataset] - ) + d3_scatter_plot_view = ScatterPlot( + width='100%', + height='500px', + engine='d3', + title='D3 Scatter Plot', + subtitle='D3 Scatter Plot', + x_axis_title='Height', + x_axis_units='cm', + y_axis_title='Weight', + y_axis_units='kg', + series=[male_dataset, female_dataset] + ) # Web Plot web_plot = PolarPlot( @@ -347,22 +349,22 @@ def index(request): title='Polar Chart', subtitle='Polar Chart', pane={ - 'size': '80%' + 'size': '80%' }, categories=['Infiltration', 'Soil Moisture', 'Precipitation', 'Evaporation', 'Roughness', 'Runoff', 'Permeability', 'Vegetation'], series=[ - { - 'name': 'Park City', - 'data': [0.2, 0.5, 0.1, 0.8, 0.2, 0.6, 0.8, 0.3], - 'pointPlacement': 'on' - }, - { - 'name': 'Little Dell', - 'data': [0.8, 0.3, 0.2, 0.5, 0.1, 0.8, 0.2, 0.6], - 'pointPlacement': 'on' - } + { + 'name': 'Park City', + 'data': [0.2, 0.5, 0.1, 0.8, 0.2, 0.6, 0.8, 0.3], + 'pointPlacement': 'on' + }, + { + 'name': 'Little Dell', + 'data': [0.8, 0.3, 0.2, 0.5, 0.1, 0.8, 0.2, 0.6], + 'pointPlacement': 'on' + } ] ) @@ -640,14 +642,14 @@ def index(request): x = [datetime(year=2013, month=10, day=4), datetime(year=2013, month=11, day=5), datetime(year=2013, month=12, day=6)] - + my_plotly_view = PlotlyView([go.Scatter(x=x, y=[1, 3, 6])]) - - #TODO: Add pandas example when pandas is included with Tethys Platform + + # TODO: Add pandas example when pandas is included with Tethys Platform # Bokeh View plot = bokeh_figure(plot_height=300) - plot.circle([1,2], [3,4]) + plot.circle([1, 2], [3, 4]) my_bokeh_view = BokehView(plot, height="300px") # Table View @@ -678,7 +680,7 @@ def index(request): ('Bob', 26, 'boss')], searching=False, orderClasses=False, - lengthMenu=[ [10, 25, 50, -1], [10, 25, 50, "All"] ], + lengthMenu=[[10, 25, 50, -1], [10, 25, 50, "All"]], ) datatable_with_extension = DataTableView(column_names=('Name', 'Age', 'Job'), @@ -847,13 +849,17 @@ def index(request): delete_btn=True, ) - #ESRI Map Gizmo + # ESRI Map Gizmo esri_map_view = EMView(center=[-100, 40], zoom=4) - esri_layer = EMLayer(type='FeatureLayer', - url='http://geoserver.byu.edu/arcgis/rest/services/gaugeviewer/AHPS_gauges/MapServer/0') + esri_layer = EMLayer( + type='FeatureLayer', + url='http://geoserver.byu.edu/arcgis/rest/services/gaugeviewer/AHPS_gauges/MapServer/0' + ) - vector_tile = EMLayer(type='ImageryLayer', - url='https://sampleserver6.arcgisonline.com/arcgis/rest/services/NLCDLandCover2001/ImageServer') + vector_tile = EMLayer( + type='ImageryLayer', + url='https://sampleserver6.arcgisonline.com/arcgis/rest/services/NLCDLandCover2001/ImageServer' + ) esri_map = ESRIMap(height='400px', width='100%', basemap='topo', view=esri_map_view, layers=[vector_tile, esri_layer]) @@ -891,7 +897,7 @@ def index(request): 'flash_message': flash_message, 'jobs_table_options': jobs_table_options, 'map_view_options': map_view_options, - "esri_map":esri_map, + "esri_map": esri_map, 'scatter_plot_view': scatter_plot_view, 'pie_plot_view': pie_plot_view, 'd3_pie_plot_view': d3_pie_plot_view, @@ -912,7 +918,8 @@ def get_kml(request): This action is used to pass the kml data to the google map. It must return JSON with the key 'kml_link'. """ kml_links = [ - 'http://ciwckan.chpc.utah.edu/dataset/00d54047-8581-4dc2-bdc2-b96f5a635455/resource/69f8e7df-da87-47cd-90a1-d15dc84e99ba/download/indexclusters.kml'] + 'http://ciwckan.chpc.utah.edu/dataset/00d54047-8581-4dc2-bdc2-b96f5a635455/resource' + '/69f8e7df-da87-47cd-90a1-d15dc84e99ba/download/indexclusters.kml'] return JsonResponse({'kml_links': kml_links}) @@ -925,7 +932,8 @@ def swap_kml(request): pass kml_links = [ - 'http://ciwckan.chpc.utah.edu/dataset/00d54047-8581-4dc2-bdc2-b96f5a635455/resource/53c7a910-5e00-4af7-803a-e48e0f17a131/download/elevation.kml'] + 'http://ciwckan.chpc.utah.edu/dataset/00d54047-8581-4dc2-bdc2-b96f5a635455/resource' + '/53c7a910-5e00-4af7-803a-e48e0f17a131/download/elevation.kml'] return HttpResponse(json.dumps(kml_links), content_type='application/json') @@ -935,31 +943,48 @@ def swap_overlays(request): This action is used to demonstrate how overlay layers can be swapped out dynamically using the javascript API. """ - overlay_json = {"type": "GeometryCollection", - "geometries": [{"type": "Polygon", - "coordinates": [[40.643135583312805, -111.48951530456543], - [40.636622594719725, -111.49432182312012], - [40.63310531666155, -111.4877986907959], - [40.63805550673186, -111.48110389709473], - [40.6413120105605, -111.48539543151855]], - "properties": {"id": 4, "value": 5}, "crs": {"type": "link", "properties": { - "href": "http://spatialreference.org/ref/epsg/4326/proj4/", "type": "proj4"}} - }, - {"type": "Point", - "coordinates": [40.629587853312174, -111.50959968566895], - "properties": {"id": 5, "value": 6}, "crs": {"type": "link", "properties": { - "href": "http://spatialreference.org/ref/epsg/4326/proj4/", "type": "proj4"}} - }, - {"type": "LineString", - "coordinates": [[40.62737305910759, -111.50118827819824], - [40.61564645424611, -111.5071964263916], - [40.61277963772034, -111.48608207702637], - [40.62802447679272, -111.49157524108887]], - "properties": {"id": 6, "value": 7}, "crs": {"type": "link", "properties": { - "href": "http://spatialreference.org/ref/epsg/4326/proj4/", "type": "proj4"}} - } - ] - } + overlay_json = { + "type": "GeometryCollection", + "geometries": [ + {"type": "Polygon", + "coordinates": [[40.643135583312805, -111.48951530456543], + [40.636622594719725, -111.49432182312012], + [40.63310531666155, -111.4877986907959], + [40.63805550673186, -111.48110389709473], + [40.6413120105605, -111.48539543151855]], + "properties": {"id": 4, "value": 5}, + "crs": {"type": "link", + "properties": { + "href": "http://spatialreference.org/ref/epsg/4326/proj4/", + "type": "proj4" + } + } + }, + {"type": "Point", + "coordinates": [40.629587853312174, -111.50959968566895], + "properties": {"id": 5, "value": 6}, + "crs": {"type": "link", + "properties": { + "href": "http://spatialreference.org/ref/epsg/4326/proj4/", + "type": "proj4" + } + } + }, + {"type": "LineString", + "coordinates": [[40.62737305910759, -111.50118827819824], + [40.61564645424611, -111.5071964263916], + [40.61277963772034, -111.48608207702637], + [40.62802447679272, -111.49157524108887]], + "properties": {"id": 6, "value": 7}, + "crs": {"type": "link", + "properties": { + "href": "http://spatialreference.org/ref/epsg/4326/proj4/", + "type": "proj4" + } + } + } + ] + } return HttpResponse(json.dumps(overlay_json), content_type='application/json') @@ -971,37 +996,42 @@ def google_map_view(request): """ # Editable Google Map - google_map_view = GoogleMapView(height='600px', - width='100%', - reference_kml_action=reverse('gizmos:get_kml'), - drawing_types_enabled=['POLYGONS', 'POINTS', 'POLYLINES', 'BOXES'], - initial_drawing_mode='BOXES', - input_overlays={"type": "GeometryCollection", - "geometries": [ - {"type": "Point", - "coordinates": [40.629197012613545, -111.5123462677002], - "properties": {"id": 1, "value": 1}}, - {"type": "Polygon", - "coordinates": [[40.63193284946615, -111.50153160095215], - [40.617210120505035, -111.50101661682129], - [40.623594711231775, -111.48625373840332], - [40.63193284946615, -111.49123191833496]], - "properties": {"id": 2, "value": 2}}, - {"type": "LineString", - "coordinates": [[40.65003865742191, -111.49123191833496], - [40.635319920747456, -111.49088859558105], - [40.64912697157757, -111.48127555847168], - [40.634668574229735, -111.48024559020996]], - "properties": {"id": 3, "value": 3}}, - {"type": "BoundingBox", - "bounds": [-111.54521942138672, 40.597792003905454, -111.46625518798828, - 40.66449372533465], - "properties": {"id": 4, "value": 4} - } - ] - }, - output_format='WKT', - ) + google_map_view = GoogleMapView( + height='600px', + width='100%', + reference_kml_action=reverse('gizmos:get_kml'), + drawing_types_enabled=['POLYGONS', 'POINTS', 'POLYLINES', 'BOXES'], + initial_drawing_mode='BOXES', + input_overlays={ + "type": "GeometryCollection", + "geometries": [ + {"type": "Point", + "coordinates": [40.629197012613545, -111.5123462677002], + "properties": {"id": 1, "value": 1} + }, + {"type": "Polygon", + "coordinates": [[40.63193284946615, -111.50153160095215], + [40.617210120505035, -111.50101661682129], + [40.623594711231775, -111.48625373840332], + [40.63193284946615, -111.49123191833496]], + "properties": {"id": 2, "value": 2} + }, + {"type": "LineString", + "coordinates": [[40.65003865742191, -111.49123191833496], + [40.635319920747456, -111.49088859558105], + [40.64912697157757, -111.48127555847168], + [40.634668574229735, -111.48024559020996]], + "properties": {"id": 3, "value": 3} + }, + {"type": "BoundingBox", + "bounds": [-111.54521942138672, 40.597792003905454, + -111.46625518798828, 40.66449372533465], + "properties": {"id": 4, "value": 4} + } + ] + }, + output_format='WKT', + ) if ('editable_map_submit' in request.POST) and (request.POST['geometry']): # Some example code showing how you can decode the JSON into python @@ -1149,22 +1179,28 @@ def map_view(request): return render(request, 'tethys_gizmos/gizmo_showcase/map_view.html', context) + @login_required() def esri_map(request): esri_map_view = EMView(center=[-100, 40], zoom=4) - esri_layer = EMLayer(type='FeatureLayer', - url='http://geoserver.byu.edu/arcgis/rest/services/gaugeviewer/AHPS_gauges/MapServer/0') + esri_layer = EMLayer( + type='FeatureLayer', + url='http://geoserver.byu.edu/arcgis/rest/services/gaugeviewer/AHPS_gauges/MapServer/0' + ) + + vector_tile = EMLayer( + type='ImageryLayer', + url='https://sampleserver6.arcgisonline.com/arcgis/rest/services/NLCDLandCover2001/ImageServer' + ) - vector_tile = EMLayer(type='ImageryLayer', - url='https://sampleserver6.arcgisonline.com/arcgis/rest/services/NLCDLandCover2001/ImageServer') + esri_map = ESRIMap(height='400px', width='100%', basemap='topo', + view=esri_map_view, layers=[vector_tile, esri_layer]) - esri_map = ESRIMap(height='400px', width='100%', basemap='topo', view=esri_map_view, - layers=[vector_tile, esri_layer]) + context = {"esri_map": esri_map} - context = {"esri_map":esri_map} + return render(request, 'tethys_gizmos/gizmo_showcase/esri_map.html', context) - return render(request,'tethys_gizmos/gizmo_showcase/esri_map.html',context) def jobs_table_results(request, job_id): return redirect(reverse('gizmos:showcase') + '#jobs_table_docs') diff --git a/tethys_gizmos/views/gizmos/__init__.py b/tethys_gizmos/views/gizmos/__init__.py index 8b2134d6a..b89b39348 100644 --- a/tethys_gizmos/views/gizmos/__init__.py +++ b/tethys_gizmos/views/gizmos/__init__.py @@ -6,4 +6,4 @@ * Copyright: (c) Brigham Young University 2014 * License: BSD 2-Clause ******************************************************************************** -""" \ No newline at end of file +""" diff --git a/tethys_gizmos/views/gizmos/jobs_table.py b/tethys_gizmos/views/gizmos/jobs_table.py index 82e5ec3e6..3a8fa1b24 100644 --- a/tethys_gizmos/views/gizmos/jobs_table.py +++ b/tethys_gizmos/views/gizmos/jobs_table.py @@ -36,7 +36,7 @@ def update_row(request, job_id): try: data = {key: _parse_value(val) for key, val in request.POST.items()} filter_string = data.pop('column_fields') - filters = [f.strip('\'\" ') for f in filter_string.strip('()').split(',')] + filters = [f.strip('\'\" ') for f in filter_string.strip('[]').split(',')] job = TethysJob.objects.get_subclass(id=job_id) status = job.status statuses = None @@ -47,7 +47,7 @@ def update_row(request, job_id): else: statuses = job.statuses - row = JobsTable.get_rows([job], filters)[0] + row = JobsTable.get_row(job, filters) data.update({'job': job, 'row': row, 'column_fields': filters, 'job_status': status, 'job_statuses': statuses, 'delay_loading_status': False}) @@ -95,4 +95,4 @@ def _parse_value(val): elif val == 'False': return False else: - return val \ No newline at end of file + return val diff --git a/tethys_portal/__init__.py b/tethys_portal/__init__.py index 7442778ce..ea3c4b2cd 100644 --- a/tethys_portal/__init__.py +++ b/tethys_portal/__init__.py @@ -6,4 +6,4 @@ * Copyright: (c) Brigham Young University 2014 * License: BSD 2-Clause ******************************************************************************** -""" \ No newline at end of file +""" diff --git a/tethys_portal/forms.py b/tethys_portal/forms.py index 484e8aeff..1259f310b 100644 --- a/tethys_portal/forms.py +++ b/tethys_portal/forms.py @@ -12,23 +12,32 @@ from django.contrib.auth.models import User from django.contrib.auth.password_validation import validate_password + class LoginForm(forms.Form): - username = forms.RegexField(label='', max_length=30, - regex=r'^[\w.@+-]+$', - error_messages={ - 'invalid': "This value may contain only letters, numbers and @/./+/-/_ characters."}, - widget=forms.TextInput(attrs={'placeholder': 'Username', - 'autofocus': 'autofocus'} - ) + + username = forms.RegexField( + label='', max_length=30, + regex=r'^[\w.@+-]+$', + error_messages={ + 'invalid': "This value may contain only letters, numbers and @/./+/-/_ characters." + }, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Username', + 'autofocus': 'autofocus' + } + ) ) - password = forms.CharField(label='', - widget=forms.PasswordInput( - attrs={'placeholder': 'Password'} - ) + password = forms.CharField( + label='', + widget=forms.PasswordInput( + attrs={'placeholder': 'Password'} + ) ) captcha = CaptchaField(label='') + class RegisterForm(forms.ModelForm): """ A form that creates a user, with no privileges, from the given username and @@ -40,27 +49,39 @@ class RegisterForm(forms.ModelForm): 'duplicate_email': "A user with this email already exists." } - username = forms.RegexField(label='', max_length=30, - regex=r'^[\w.@+-]+$', - error_messages={ - 'invalid': "This value may contain only letters, numbers and @/./+/-/_ characters."}, - widget=forms.TextInput(attrs={'placeholder': 'Username', - 'autofocus': 'autofocus'} - ) + username = forms.RegexField( + label='', max_length=30, + regex=r'^[\w.@+-]+$', + error_messages={ + 'invalid': "This value may contain only letters, numbers and @/./+/-/_ characters."}, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Username', + 'autofocus': 'autofocus' + } + ) ) - email = forms.CharField(label='', - max_length=30, - widget=forms.EmailInput(attrs={'placeholder': 'Email'} - ) + email = forms.CharField( + label='', + max_length=30, + widget=forms.EmailInput( + attrs={'placeholder': 'Email'} + ) ) - password1 = forms.CharField(label='', - widget=forms.PasswordInput(attrs={'placeholder': 'Password'}), + password1 = forms.CharField( + label='', + widget=forms.PasswordInput( + attrs={'placeholder': 'Password'} + ) ) - password2 = forms.CharField(label='', - widget=forms.PasswordInput(attrs={'placeholder': 'Confirm Password'}), + password2 = forms.CharField( + label='', + widget=forms.PasswordInput( + attrs={'placeholder': 'Confirm Password'} + ) ) captcha = CaptchaField(label='') @@ -118,31 +139,40 @@ class UserSettingsForm(forms.ModelForm): """ A form for modifying user settings. """ - first_name = forms.CharField(max_length=30, - label='', - required=False, - widget=forms.TextInput( - attrs={'placeholder': '', - 'class': 'form-control', - 'autofocus': 'autofocus'} - ) + first_name = forms.CharField( + max_length=30, + label='', + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': '', + 'class': 'form-control', + 'autofocus': 'autofocus' + } + ) ) - last_name = forms.CharField(max_length=30, - label='', - required=False, - widget=forms.TextInput( - attrs={'placeholder': '', - 'class': 'form-control'} - ) + last_name = forms.CharField( + max_length=30, + label='', + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': '', + 'class': 'form-control' + } + ) ) - email = forms.EmailField(max_length=30, - label='Email:', - widget=forms.EmailInput( - attrs={'placeholder': '', - 'class': 'form-control'} - ) + email = forms.EmailField( + max_length=30, + label='Email:', + widget=forms.EmailInput( + attrs={ + 'placeholder': '', + 'class': 'form-control' + } + ) ) class Meta: @@ -159,24 +189,29 @@ class UserPasswordChangeForm(forms.Form): 'password_incorrect': "Your old password was entered incorrectly. Please enter it again.", } - old_password = forms.CharField(label="", - widget=forms.PasswordInput( - attrs={'placeholder': 'Old Password', - 'autofocus': 'autofocus'} - ), - ) - - new_password1 = forms.CharField(label="", - widget=forms.PasswordInput( - attrs={'placeholder': 'New Password'} - ), - ) - - new_password2 = forms.CharField(label="", - widget=forms.PasswordInput( - attrs={'placeholder': 'Confirm New Password'} - ), - ) + old_password = forms.CharField( + label="", + widget=forms.PasswordInput( + attrs={ + 'placeholder': 'Old Password', + 'autofocus': 'autofocus' + } + ) + ) + + new_password1 = forms.CharField( + label="", + widget=forms.PasswordInput( + attrs={'placeholder': 'New Password'} + ) + ) + + new_password2 = forms.CharField( + label="", + widget=forms.PasswordInput( + attrs={'placeholder': 'Confirm New Password'} + ) + ) def __init__(self, user, *args, **kwargs): self.user = user diff --git a/tethys_portal/middleware.py b/tethys_portal/middleware.py index 8dfacd80d..6c5183c39 100644 --- a/tethys_portal/middleware.py +++ b/tethys_portal/middleware.py @@ -11,6 +11,7 @@ from django.shortcuts import redirect from social_django.middleware import SocialAuthExceptionMiddleware from social_core import exceptions as social_exceptions +from tethys_apps.cli.cli_colors import pretty_output, FG_WHITE class TethysSocialAuthExceptionMiddleware(SocialAuthExceptionMiddleware): @@ -23,7 +24,8 @@ def process_exception(self, request, exception): return redirect('user:settings', username=request.user.username) elif isinstance(exception, social_exceptions.AuthAlreadyAssociated): blurb = 'The {0} account you tried to connect to has already been associated with another account.' - print(exception.backend.name) + with pretty_output(FG_WHITE) as p: + p.write(exception.backend.name) if 'google' in exception.backend.name: blurb = blurb.format('Google') elif 'linkedin' in exception.backend.name: @@ -47,4 +49,4 @@ def process_exception(self, request, exception): if request.user.is_anonymous: return redirect('accounts:login') else: - return redirect('user:settings', username=request.user.username) \ No newline at end of file + return redirect('user:settings', username=request.user.username) diff --git a/tethys_portal/tests.py b/tethys_portal/tests.py deleted file mode 100644 index f5e8869dc..000000000 --- a/tethys_portal/tests.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -******************************************************************************** -* Name: tests.py -* Author: Nathan Swain -* Created On: 2014 -* Copyright: (c) Brigham Young University 2014 -* License: BSD 2-Clause -******************************************************************************** -""" -from django.urls import reverse -from django.test import TestCase -from django.test.client import Client -from django.contrib.auth.models import User - - -class LoginTestCase(TestCase): - """ - Tests for the login view - """ - def setUp(self): - """ - Perform the work that every test will need. - """ - # Create client - self.client = Client() - - # Create test users - User.objects.create(username='darth_vadar', password='darthpass', email='darth@deathstar.com', is_active=False) - User.objects.create(username='han_solo', password='hanpass', email='han@meleniumfalcon.com', is_active=True) - - def login_active_user_correct_credentials(self): - """ - Active user with correct credentials - """ - form_data = {'username': 'han_solo', - 'password': 'hanpass'} - - action_url = reverse('accounts:login') - - response = self.client.post(action_url, form_data) - - self.assertEqual(response.status_code, 200) - print(response.context) \ No newline at end of file diff --git a/tethys_portal/urls.py b/tethys_portal/urls.py index dbee07de1..1c4adaf95 100644 --- a/tethys_portal/urls.py +++ b/tethys_portal/urls.py @@ -14,15 +14,17 @@ from django.conf import settings from tethys_apps.urls import extension_urls -admin.autodiscover() +from tethys_portal.views import accounts as tethys_portal_accounts, developer as tethys_portal_developer, \ + error as tethys_portal_error, home as tethys_portal_home, user as tethys_portal_user +from tethys_apps import views as tethys_apps_views # ensure at least staff users logged in before accessing admin login page from django.contrib.admin.views.decorators import staff_member_required admin.site.login = staff_member_required(admin.site.login, redirect_field_name="", login_url='/accounts/login/') -from tethys_portal.views import accounts as tethys_portal_accounts, developer as tethys_portal_developer, \ - error as tethys_portal_error, home as tethys_portal_home, user as tethys_portal_user -from tethys_apps import views as tethys_apps_views +admin.autodiscover() +admin.site.login = staff_member_required(admin.site.login, redirect_field_name="", login_url='/accounts/login/') + account_urls = [ url(r'^login/$', tethys_portal_accounts.login_view, name='login'), diff --git a/tethys_portal/views/__init__.py b/tethys_portal/views/__init__.py index bc36761ad..0d9a51543 100644 --- a/tethys_portal/views/__init__.py +++ b/tethys_portal/views/__init__.py @@ -7,4 +7,3 @@ * License: BSD 2-Clause ******************************************************************************** """ -from .receivers import create_auth_token diff --git a/tethys_portal/views/accounts.py b/tethys_portal/views/accounts.py index fa6f6c7f0..75263373c 100644 --- a/tethys_portal/views/accounts.py +++ b/tethys_portal/views/accounts.py @@ -53,13 +53,11 @@ def login_view(request): # The password is valid, but the user account has been disabled # Return a disabled account 'error' message messages.error(request, "Sorry, but your account has been disabled. Please contact the site " - "administrator for more details." - ) + "administrator for more details.") else: # User was not authenticated, return errors - messages.warning(request, "Whoops! We were not able to log you in. Please check your username and " - "password and try again." - ) + messages.warning(request, "Whoops! We were not able to log you in. Please check your username and " + "password and try again.") else: # Create new empty login form @@ -95,7 +93,7 @@ def register(request): if form.is_valid(): # Validate username and password using form methods username = form.clean_username() - email = form.clean_email() + email = form.clean_email() # noqa: F841 password = form.clean_password2() # If no exceptions raised to here, then the username is unique and the passwords match. @@ -120,13 +118,11 @@ def register(request): # The password is valid, but the user account has been disabled # Return a disabled account 'error' message messages.error(request, "Sorry, but your account has been disabled. Please contact the site " - "administrator for more details." - ) + "administrator for more details.") else: # User was not authenticated, return errors - messages.warning(request, "Whoops! We were not able to log you in. Please check your username and " - "password and try again." - ) + messages.warning(request, "Whoops! We were not able to log you in. Please check your username and " + "password and try again.") else: # Create new empty form @@ -151,18 +147,20 @@ def logout_view(request): def reset_confirm(request, uidb64=None, token=None): - return password_reset_confirm(request, - template_name='tethys_portal/accounts/password_reset/reset_confirm.html', - uidb64=uidb64, - token=token, - post_reset_redirect=reverse('accounts:login') + return password_reset_confirm( + request, + template_name='tethys_portal/accounts/password_reset/reset_confirm.html', + uidb64=uidb64, + token=token, + post_reset_redirect=reverse('accounts:login') ) def reset(request): - return password_reset(request, - template_name='tethys_portal/accounts/password_reset/reset_request.html', - email_template_name='tethys_portal/accounts/password_reset/reset_email.html', - subject_template_name='tethys_portal/accounts/password_reset/reset_subject.txt', - post_reset_redirect=reverse('accounts:login') + return password_reset( + request, + template_name='tethys_portal/accounts/password_reset/reset_request.html', + email_template_name='tethys_portal/accounts/password_reset/reset_email.html', + subject_template_name='tethys_portal/accounts/password_reset/reset_subject.txt', + post_reset_redirect=reverse('accounts:login') ) diff --git a/tethys_portal/views/developer.py b/tethys_portal/views/developer.py index 3fa44f54d..7e177777e 100644 --- a/tethys_portal/views/developer.py +++ b/tethys_portal/views/developer.py @@ -19,4 +19,4 @@ def is_staff(user): @user_passes_test(is_staff) def home(request): context = {} - return render(request, 'tethys_portal/developer/home.html', context) \ No newline at end of file + return render(request, 'tethys_portal/developer/home.html', context) diff --git a/tethys_portal/views/error.py b/tethys_portal/views/error.py index 8377ca186..28e125493 100644 --- a/tethys_portal/views/error.py +++ b/tethys_portal/views/error.py @@ -38,7 +38,8 @@ def handler_404(request): """ context = {'error_code': '404', 'error_title': 'Page Not Found', - 'error_message': "We are unable to find the page you requested. Please, check the address and try again.", + 'error_message': "We are unable to find the page you requested. Please, check the address and try " + "again.", 'error_image': '/static/tethys_portal/images/error_404.png'} return render(request, 'tethys_portal/error.html', context, status=404) @@ -52,4 +53,3 @@ def handler_500(request): 'error_message': "We're sorry, but we seem to have a problem. Please, come back later and try again.", 'error_image': '/static/tethys_portal/images/error_500.png'} return render(request, 'tethys_portal/error.html', context, status=500) - diff --git a/tethys_portal/views/home.py b/tethys_portal/views/home.py index 2b8c2fb76..d033136e6 100644 --- a/tethys_portal/views/home.py +++ b/tethys_portal/views/home.py @@ -17,4 +17,4 @@ def home(request): if hasattr(settings, 'BYPASS_TETHYS_HOME_PAGE') and settings.BYPASS_TETHYS_HOME_PAGE: return redirect('app_library') - return render(request, 'tethys_portal/home.html', { "ENABLE_OPEN_SIGNUP": settings.ENABLE_OPEN_SIGNUP, }) + return render(request, 'tethys_portal/home.html', {"ENABLE_OPEN_SIGNUP": settings.ENABLE_OPEN_SIGNUP, }) diff --git a/tethys_portal/views/user.py b/tethys_portal/views/user.py index 1ec150e60..0aae811df 100644 --- a/tethys_portal/views/user.py +++ b/tethys_portal/views/user.py @@ -16,6 +16,7 @@ from tethys_portal.forms import UserSettingsForm, UserPasswordChangeForm + @login_required() def profile(request, username=None): """ @@ -27,11 +28,12 @@ def profile(request, username=None): context_user = User.objects.get(username=username) user_token, token_created = Token.objects.get_or_create(user=context_user) context = { - 'context_user': context_user, - 'user_token': user_token.key - } + 'context_user': context_user, + 'user_token': user_token.key + } return render(request, 'tethys_portal/user/profile.html', context) + @login_required() def settings(request, username=None): """ @@ -76,6 +78,7 @@ def settings(request, username=None): return render(request, 'tethys_portal/user/settings.html', context) + @login_required() def change_password(request, username=None): """ @@ -106,9 +109,6 @@ def change_password(request, username=None): # Return to the settings page return redirect('user:settings', username=username) - else: - pass - else: # Create a form populated with data from the instance user form = UserPasswordChangeForm(user=request_user) @@ -118,6 +118,7 @@ def change_password(request, username=None): return render(request, 'tethys_portal/user/change_password.html', context) + @login_required() def social_disconnect(request, username, provider, association_id): """ diff --git a/tethys_portal/wsgi.py b/tethys_portal/wsgi.py index bdafc07ed..5f5c3cad0 100644 --- a/tethys_portal/wsgi.py +++ b/tethys_portal/wsgi.py @@ -16,8 +16,7 @@ """ import os -import sys -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") - from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") application = get_wsgi_application() diff --git a/tethys_sdk/__init__.py b/tethys_sdk/__init__.py index 0b045f92d..99b988275 100644 --- a/tethys_sdk/__init__.py +++ b/tethys_sdk/__init__.py @@ -9,4 +9,3 @@ """ # This module provides a centralized location for SDK imports. - diff --git a/tethys_sdk/app_settings.py b/tethys_sdk/app_settings.py index 134c57c16..d4d4c8243 100644 --- a/tethys_sdk/app_settings.py +++ b/tethys_sdk/app_settings.py @@ -7,9 +7,13 @@ * License: BSD 2-Clause ******************************************************************************** """ -from tethys_apps.models import (CustomSetting, - DatasetServiceSetting, - SpatialDatasetServiceSetting, - WebProcessingServiceSetting, - PersistentStoreConnectionSetting, - PersistentStoreDatabaseSetting) \ No newline at end of file +# flake8: noqa +# DO NOT ERASE +from tethys_apps.models import ( + CustomSetting, + DatasetServiceSetting, + SpatialDatasetServiceSetting, + WebProcessingServiceSetting, + PersistentStoreConnectionSetting, + PersistentStoreDatabaseSetting +) diff --git a/tethys_sdk/base.py b/tethys_sdk/base.py index c274b8033..abbaa1949 100644 --- a/tethys_sdk/base.py +++ b/tethys_sdk/base.py @@ -7,6 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ +# flake8: noqa # DO NOT ERASE from tethys_apps.base import TethysAppBase, TethysExtensionBase from tethys_apps.base.url_map import url_map_maker diff --git a/tethys_sdk/compute.py b/tethys_sdk/compute.py index a2bb526b3..6564e4222 100644 --- a/tethys_sdk/compute.py +++ b/tethys_sdk/compute.py @@ -7,5 +7,6 @@ * License: BSD 2-Clause ******************************************************************************** """ +# flake8: noqa +# DO NOT ERASE from tethys_compute.scheduler_manager import list_schedulers, get_scheduler, create_scheduler - diff --git a/tethys_sdk/gizmos.py b/tethys_sdk/gizmos.py index e10e877f2..1ce3abd79 100644 --- a/tethys_sdk/gizmos.py +++ b/tethys_sdk/gizmos.py @@ -7,6 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ +# flake8: noqa # DO NOT ERASE from tethys_gizmos.gizmo_options import * -from tethys_gizmos.gizmo_options.base import TethysGizmoOptions, SecondaryGizmoOptions \ No newline at end of file +from tethys_gizmos.gizmo_options.base import TethysGizmoOptions, SecondaryGizmoOptions diff --git a/tethys_sdk/handoff.py b/tethys_sdk/handoff.py index 84daf0fec..dd4b0f39d 100644 --- a/tethys_sdk/handoff.py +++ b/tethys_sdk/handoff.py @@ -7,4 +7,6 @@ * License: BSD 2-Clause ******************************************************************************** """ -from tethys_apps.base.handoff import HandoffHandler \ No newline at end of file +# flake8: noqa +# DO NOT ERASE +from tethys_apps.base.handoff import HandoffHandler diff --git a/tethys_sdk/jobs.py b/tethys_sdk/jobs.py index 183358b0c..561a5ce68 100644 --- a/tethys_sdk/jobs.py +++ b/tethys_sdk/jobs.py @@ -7,17 +7,19 @@ * License: BSD 2-Clause ******************************************************************************** """ +# flake8: noqa # DO NOT ERASE -from tethys_compute.job_manager import (JobManager, - BasicJobTemplate, - CondorJobTemplate, - CondorJobDescription, - CondorWorkflowTemplate, - CondorWorkflowJobTemplate, - # CondorWorkflowSubworkflowTemplate, - # CondorWorkflowDataJobTemplate, - # CondorWorkflowFinalTemplate, - ) +from tethys_compute.job_manager import ( + JobManager, + BasicJobTemplate, + CondorJobTemplate, + CondorJobDescription, + CondorWorkflowTemplate, + CondorWorkflowJobTemplate, + # CondorWorkflowSubworkflowTemplate, + # CondorWorkflowDataJobTemplate, + # CondorWorkflowFinalTemplate, +) # Depricated imports -from tethys_compute.job_manager import (JobTemplate, JOB_TYPES) \ No newline at end of file +from tethys_compute.job_manager import (JobTemplate, JOB_TYPES) diff --git a/tethys_sdk/permissions.py b/tethys_sdk/permissions.py index e918cc14f..9510c824b 100644 --- a/tethys_sdk/permissions.py +++ b/tethys_sdk/permissions.py @@ -7,6 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ +# flake8: noqa # DO NOT ERASE from tethys_apps.base import Permission, PermissionGroup, has_permission -from tethys_apps.decorators import permission_required \ No newline at end of file +from tethys_apps.decorators import permission_required diff --git a/tethys_sdk/services.py b/tethys_sdk/services.py index 149412a36..3a40c47ff 100644 --- a/tethys_sdk/services.py +++ b/tethys_sdk/services.py @@ -1,17 +1 @@ -""" -******************************************************************************** -* Name: services.py -* Author: Nathan Swain -* Created On: 7 August 2015 -* Copyright: (c) Brigham Young University 2015 -* License: BSD 2-Clause -******************************************************************************** -""" -# DO NOT ERASE -from tethys_services.utilities import (list_dataset_engines, - get_dataset_engine, - list_spatial_dataset_engines, - get_spatial_dataset_engine, - list_wps_service_engines, - get_wps_service_engine, - ensure_oauth2) +""" ******************************************************************************** * Name: services.py * Author: Nathan Swain * Created On: 7 August 2015 * Copyright: (c) Brigham Young University 2015 * License: BSD 2-Clause ******************************************************************************** """ # flake8: noqa # DO NOT ERASE from tethys_services.utilities import ( list_dataset_engines, get_dataset_engine, list_spatial_dataset_engines, get_spatial_dataset_engine, list_wps_service_engines, get_wps_service_engine, ensure_oauth2 ) \ No newline at end of file diff --git a/tethys_sdk/testing.py b/tethys_sdk/testing.py index 8ff844d41..3e95373c4 100644 --- a/tethys_sdk/testing.py +++ b/tethys_sdk/testing.py @@ -7,6 +7,7 @@ * License: BSD 2-Clause ******************************************************************************** """ +# flake8: noqa # DO NOT ERASE from tethys_apps.base.testing.testing import TethysTestCase -from tethys_apps.base.testing.environment import set_testing_environment, is_testing_environment, get_test_db_name +from tethys_apps.base.testing.environment import set_testing_environment, get_test_db_name, is_testing_environment diff --git a/tethys_sdk/workspaces.py b/tethys_sdk/workspaces.py index 5e11f3e1a..ba9de0b02 100644 --- a/tethys_sdk/workspaces.py +++ b/tethys_sdk/workspaces.py @@ -7,6 +7,6 @@ * License: BSD 2-Clause ******************************************************************************** """ - +# flake8: noqa # DO NOT ERASE -from tethys_apps.base.workspace import TethysWorkspace \ No newline at end of file +from tethys_apps.base.workspace import TethysWorkspace diff --git a/tethys_services/__init__.py b/tethys_services/__init__.py index 9b062cb50..66d698de8 100644 --- a/tethys_services/__init__.py +++ b/tethys_services/__init__.py @@ -8,4 +8,4 @@ ******************************************************************************** """ # Load the custom app config -default_app_config = 'tethys_services.apps.TethysServicesConfig' \ No newline at end of file +default_app_config = 'tethys_services.apps.TethysServicesConfig' diff --git a/tethys_services/backends/hs_restclient_helper.py b/tethys_services/backends/hs_restclient_helper.py index 36220d13f..e53fb21fb 100644 --- a/tethys_services/backends/hs_restclient_helper.py +++ b/tethys_services/backends/hs_restclient_helper.py @@ -2,9 +2,6 @@ import time import hs_restclient as hs_r from django.conf import settings -## tethys 1.4 -#from social.apps.django_app.utils import load_strategy -# tethys 2.0 from social_django.utils import load_strategy logger = logging.getLogger(__name__) @@ -37,7 +34,8 @@ def get_oauth_hs(request): hs = hs_r.HydroShare(auth=auth, hostname=auth_server_hostname) logger.debug("hs object initialized: {0} @ {1}".format(user_id, auth_server_hostname)) else: - raise Exception("Found another hydroshare oauth instance: {0} @ {1}".format(user_id, auth_server_hostname)) + raise Exception("Found another hydroshare oauth instance: {0} @ {1}".format(user_id, + auth_server_hostname)) if hs is None: raise Exception("Not logged in through HydroShare") @@ -45,13 +43,14 @@ def get_oauth_hs(request): return hs except Exception as ex: - logger.exception(error_msg_head + ex.message) - raise HSClientInitException(ex.message) + logger.exception(error_msg_head + str(ex)) + raise HSClientInitException(ex) class HSClientInitException(Exception): def __init__(self, value): self.value = value + def __str__(self): return repr(self.value) @@ -70,13 +69,13 @@ def _send_refresh_request(user_social): # update token_dict for backward compatible data = user_social.extra_data token_dict = { - 'access_token': data['access_token'], - 'token_type': data['token_type'], - 'expires_in': data['expires_in'], - 'expires_at': data['expires_at'], - 'refresh_token': data['refresh_token'], - 'scope': data['scope'] - } + 'access_token': data['access_token'], + 'token_type': data['token_type'], + 'expires_in': data['expires_in'], + 'expires_at': data['expires_at'], + 'refresh_token': data['refresh_token'], + 'scope': data['scope'] + } data["token_dict"] = token_dict user_social.set_extra_data(extra_data=data) user_social.save() @@ -102,5 +101,5 @@ def refresh_user_token(user_social): if current_time >= expires_at: _send_refresh_request(user_social) except Exception as ex: - logger.error("Failed to refresh token: " + ex.message) - raise ex \ No newline at end of file + logger.error("Failed to refresh token: " + str(ex)) + raise ex diff --git a/tethys_services/backends/hydroshare.py b/tethys_services/backends/hydroshare.py index 8748df374..2a941c165 100644 --- a/tethys_services/backends/hydroshare.py +++ b/tethys_services/backends/hydroshare.py @@ -9,11 +9,9 @@ """ from datetime import datetime import time -## tethys 1.4 -#from social.backends.oauth import BaseOAuth2 -# tethys 2.0 from social_core.backends.oauth import BaseOAuth2 + class HydroShareOAuth2(BaseOAuth2): """ HydroShare OAuth2 authentication backend. @@ -107,7 +105,6 @@ def refresh_token(self, token, *args, **kwargs): Args: token (str): valid refresh token """ - response = super(HydroShareOAuth2, self).refresh_token(token, *args, **kwargs) # testing purpose @@ -123,4 +120,4 @@ def refresh_token(self, token, *args, **kwargs): expires_at_str = datetime.fromtimestamp(expires_at).strftime('%Y-%m-%d %H:%M:%S') response["expires_at_str"] = expires_at_str - return response \ No newline at end of file + return response diff --git a/tethys_services/base.py b/tethys_services/base.py index e7f3dd197..a8ac1180e 100644 --- a/tethys_services/base.py +++ b/tethys_services/base.py @@ -8,6 +8,7 @@ ******************************************************************************** """ from tethys_dataset_services.valid_engines import VALID_ENGINES, VALID_SPATIAL_ENGINES +from tethys_apps.cli.cli_colors import pretty_output, FG_WHITE class DatasetService: @@ -44,7 +45,9 @@ def __init__(self, name, type, endpoint, apikey=None, username=None, password=No self.username = username self.password = password - print('DEPRECATION WARNING: Storing connection credentials for Dataset Services in the app.py is a security leak. App configuration for Dataset Services will be deprecated in version 1.2.') + with pretty_output(FG_WHITE) as p: + p.write('DEPRECATION WARNING: Storing connection credentials for Dataset Services in the app.py is a ' + 'security leak. App configuration for Dataset Services will be deprecated in version 1.2.') def __repr__(self): """ @@ -87,9 +90,10 @@ def __init__(self, name, type, endpoint, apikey=None, username=None, password=No self.username = username self.password = password - print('DEPRECATION WARNING: Storing connection credentials for Spatial Dataset Services ' - 'in the app.py is a security leak. App configuration for Spatial Dataset Services ' - 'will be deprecated in version 1.2.') + with pretty_output(FG_WHITE) as p: + p.write('DEPRECATION WARNING: Storing connection credentials for Spatial Dataset Services ' + 'in the app.py is a security leak. App configuration for Spatial Dataset Services ' + 'will be deprecated in version 1.2.') def __repr__(self): """ @@ -112,12 +116,12 @@ def __init__(self, name, endpoint, username=None, password=None): self.username = username self.password = password - print('DEPRECATION WARNING: Storing connection credentials for WPS Services in the app.py is a security leak. App configuration for WPS Services will be deprecated in version 1.2.') + with pretty_output(FG_WHITE) as p: + p.write('DEPRECATION WARNING: Storing connection credentials for WPS Services in the app.py is a security ' + 'leak. App configuration for WPS Services will be deprecated in version 1.2.') def __repr__(self): """ String representation """ return ''.format(self.name, self.endpoint) - - diff --git a/tethys_services/migrations/0001_initial_20.py b/tethys_services/migrations/0001_initial_20.py index e480c1b73..ae84779db 100644 --- a/tethys_services/migrations/0001_initial_20.py +++ b/tethys_services/migrations/0001_initial_20.py @@ -21,7 +21,11 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=30, unique=True)), - ('engine', models.CharField(choices=[('tethys_dataset_services.engines.CkanDatasetEngine', 'CKAN'), ('tethys_dataset_services.engines.HydroShareDatasetEngine', 'HydroShare')], default='tethys_dataset_services.engines.CkanDatasetEngine', max_length=200)), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.CkanDatasetEngine', 'CKAN'), + ('tethys_dataset_services.engines.HydroShareDatasetEngine', + 'HydroShare')], + default='tethys_dataset_services.engines.CkanDatasetEngine', + max_length=200)), ('endpoint', models.CharField(max_length=1024)), ('apikey', models.CharField(blank=True, max_length=100)), ('username', models.CharField(blank=True, max_length=100)), @@ -37,7 +41,10 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=30, unique=True)), - ('engine', models.CharField(choices=[('tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', 'GeoServer')], default='tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', max_length=200)), + ('engine', models.CharField(choices=[('tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', + 'GeoServer')], + default='tethys_dataset_services.engines.GeoServerSpatialDatasetEngine', + max_length=200)), ('endpoint', models.CharField(max_length=1024)), ('apikey', models.CharField(blank=True, max_length=100)), ('username', models.CharField(blank=True, max_length=100)), @@ -65,12 +72,14 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='datasetservice', name='endpoint', - field=models.CharField(max_length=1024, validators=[tethys_services.models.validate_dataset_service_endpoint]), + field=models.CharField(max_length=1024, + validators=[tethys_services.models.validate_dataset_service_endpoint]), ), migrations.AlterField( model_name='spatialdatasetservice', name='endpoint', - field=models.CharField(max_length=1024, validators=[tethys_services.models.validate_spatial_dataset_service_endpoint]), + field=models.CharField(max_length=1024, + validators=[tethys_services.models.validate_spatial_dataset_service_endpoint]), ), migrations.AlterField( model_name='webprocessingservice', @@ -80,17 +89,20 @@ class Migration(migrations.Migration): migrations.AddField( model_name='spatialdatasetservice', name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_spatial_dataset_service_endpoint]), + field=models.CharField(blank=True, max_length=1024, + validators=[tethys_services.models.validate_spatial_dataset_service_endpoint]), ), migrations.AddField( model_name='datasetservice', name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_dataset_service_endpoint]), + field=models.CharField(blank=True, max_length=1024, + validators=[tethys_services.models.validate_dataset_service_endpoint]), ), migrations.AddField( model_name='webprocessingservice', name='public_endpoint', - field=models.CharField(blank=True, max_length=1024, validators=[tethys_services.models.validate_wps_service_endpoint]), + field=models.CharField(blank=True, max_length=1024, + validators=[tethys_services.models.validate_wps_service_endpoint]), ), migrations.CreateModel( name='PersistentStoreService', @@ -98,10 +110,12 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=30, unique=True)), ('host', models.CharField(default='localhost', max_length=255)), - ('port', models.IntegerField(default=5435, validators=[tethys_services.models.validate_persistent_store_port])), + ('port', models.IntegerField(default=5435, + validators=[tethys_services.models.validate_persistent_store_port])), ('username', models.CharField(blank=True, max_length=100)), ('password', models.CharField(blank=True, max_length=100)), - ('engine', models.CharField(choices=[('postgresql', 'PostgreSQL')], default='postgresql', max_length=50)), + ('engine', models.CharField(choices=[('postgresql', 'PostgreSQL')], default='postgresql', + max_length=50)), ], options={ 'verbose_name': 'Persistent Store Service', diff --git a/tethys_services/models.py b/tethys_services/models.py index 3a4e98074..3971fa31c 100644 --- a/tethys_services/models.py +++ b/tethys_services/models.py @@ -47,7 +47,8 @@ def validate_spatial_dataset_service_endpoint(value): validate_url(value) if '/geoserver/rest' not in value: - raise ValidationError('Invalid Endpoint: GeoServer endpoints follow the pattern "http://example.com/geoserver/rest".') + raise ValidationError('Invalid Endpoint: GeoServer endpoints follow the pattern ' + '"http://example.com/geoserver/rest".') def validate_wps_service_endpoint(value): @@ -57,14 +58,15 @@ def validate_wps_service_endpoint(value): validate_url(value) if '/wps/WebProcessingService' not in value: - raise ValidationError('Invalid Endpoint: 52 North WPS endpoints follow the pattern "http://example.com/wps/WebProcessingService".') + raise ValidationError('Invalid Endpoint: 52 North WPS endpoints follow the pattern ' + '"http://example.com/wps/WebProcessingService".') def validate_persistent_store_port(value): """ Validator for persistent store service ports """ - if value < 1024 or value > 65535: + if int(value) < 1024 or int(value) > 65535: raise ValidationError('Invalid Port: Persistent Store ports must be an integer between 1024 and 65535.') @@ -94,7 +96,7 @@ class Meta: verbose_name = 'Dataset Service' verbose_name_plural = 'Dataset Services' - def __unicode__(self): + def __str__(self): return self.name def get_engine(self, request=None): @@ -110,12 +112,12 @@ def get_engine(self, request=None): try: # social = user.social_auth.get(provider='google-oauth2') social = user.social_auth.get(provider=HYDROSHARE_OAUTH_PROVIDER_NAME) - apikey = social.extra_data['access_token'] + apikey = social.extra_data['access_token'] # noqa: F841 except ObjectDoesNotExist: # User is not associated with that provider # Need to prompt for association - raise AuthException("HydroShare authentication required. To automate the authentication prompt decorate " - "your controller function with the @ensure_oauth('hydroshare') decorator.") + raise AuthException("HydroShare authentication required. To automate the authentication prompt " + "decorate your controller function with the @ensure_oauth('hydroshare') decorator.") return HydroShareDatasetEngine(endpoint=self.endpoint, username=self.username, @@ -141,7 +143,8 @@ class SpatialDatasetService(models.Model): name = models.CharField(max_length=30, unique=True) engine = models.CharField(max_length=200, choices=ENGINE_CHOICES, default=GEOSERVER) endpoint = models.CharField(max_length=1024, validators=[validate_spatial_dataset_service_endpoint]) - public_endpoint = models.CharField(max_length=1024, validators=[validate_spatial_dataset_service_endpoint], blank=True) + public_endpoint = models.CharField(max_length=1024, validators=[validate_spatial_dataset_service_endpoint], + blank=True) apikey = models.CharField(max_length=100, blank=True) username = models.CharField(max_length=100, blank=True) password = models.CharField(max_length=100, blank=True) @@ -157,9 +160,11 @@ def get_engine(self): """ Retrives GeoServer engine """ - return GeoServerSpatialDatasetEngine(endpoint=self.endpoint, - username=self.username, - password=self.password) + engine = GeoServerSpatialDatasetEngine(endpoint=self.endpoint, + username=self.username, + password=self.password) + engine.public_endpoint = self.public_endpoint + return engine class WebProcessingService(models.Model): @@ -176,7 +181,10 @@ class Meta: verbose_name = 'Web Processing Service' verbose_name_plural = 'Web Processing Services' - def __unicode__(self): + # def __unicode__(self): + # return self.name + + def __str__(self): return self.name def activate(self, wps): @@ -202,8 +210,6 @@ def activate(self, wps): raise e except URLError as e: return None - except: - raise return wps @@ -242,7 +248,10 @@ class Meta: verbose_name = 'Persistent Store Service' verbose_name_plural = 'Persistent Store Services' - def __unicode__(self): + # def __unicode__(self): + # return self.name + + def __str__(self): return self.name def get_engine(self): diff --git a/tethys_services/templatetags/tethys_services.py b/tethys_services/templatetags/tethys_services.py index b7c74a6c0..cd7b44461 100644 --- a/tethys_services/templatetags/tethys_services.py +++ b/tethys_services/templatetags/tethys_services.py @@ -17,4 +17,4 @@ def is_complex_data(value): if isinstance(value, ComplexData): return True - return False \ No newline at end of file + return False diff --git a/tethys_services/urls.py b/tethys_services/urls.py index 13a0475d4..209646da8 100644 --- a/tethys_services/urls.py +++ b/tethys_services/urls.py @@ -19,4 +19,4 @@ url(r'^datasets/$', tethys_services_views.datasets_home, name='datasets_home'), url(r'^wps/$', tethys_services_views.wps_home, name='wps_home'), url(r'^wps/(?P[\w._-]+)/', include(service_urls)), -] \ No newline at end of file +] diff --git a/tethys_services/utilities.py b/tethys_services/utilities.py index cff7e32d4..2611e1914 100644 --- a/tethys_services/utilities.py +++ b/tethys_services/utilities.py @@ -40,11 +40,10 @@ def controller(request): Note that calling get_dataset_engine for a hydroshare dataset engine will throw an error if it is not called in a function that is decorated with the ensure_oauth2 decorator. """ - def decorator(function): - @wraps(function) + def decorator(func): + @wraps(func) def wrapper(request, *args, **kwargs): user = request.user - # Assemble redirect response redirect_url = reverse('social:begin', args=[provider]) + '?next={0}'.format(request.path) redirect_response = redirect(redirect_url) @@ -58,12 +57,10 @@ def wrapper(request, *args, **kwargs): # Anonymous User needs to be logged in and associated with that provider # return redirect('/login/{0}/?next={1}'.format(provider, request.path)) return redirect_response - except AuthAlreadyAssociated: + except AuthAlreadyAssociated as e: # Another user has already used the account to associate... - raise - except: - raise - return function(request, *args, **kwargs) + raise e + return func(request, *args, **kwargs) return wrapper return decorator @@ -81,8 +78,8 @@ def initialize_engine_object(engine, endpoint, apikey=None, username=None, passw engine_class_string = engine_split[-1] # Import - module = __import__(module_string, fromlist=[engine_class_string]) - EngineClass = getattr(module, engine_class_string) + module_ = __import__(module_string, fromlist=[engine_class_string]) + EngineClass = getattr(module_, engine_class_string) # Get Token for HydroShare interactions if EngineClass is HydroShareDatasetEngine: @@ -102,14 +99,14 @@ def initialize_engine_object(engine, endpoint, apikey=None, username=None, passw raise except AuthAlreadyAssociated: raise - except: - raise # Create Engine Object - engine_instance = EngineClass(endpoint=endpoint, - apikey=apikey, - username=username, - password=password) + engine_instance = EngineClass( + endpoint=endpoint, + apikey=apikey, + username=username, + password=password + ) return engine_instance @@ -124,12 +121,14 @@ def list_dataset_engines(request=None): if site_dataset_services: # Search for match for site_dataset_service in site_dataset_services: - dataset_service_object = initialize_engine_object(engine=site_dataset_service.engine.encode('utf-8'), - endpoint=site_dataset_service.endpoint, - apikey=site_dataset_service.apikey, - username=site_dataset_service.username, - password=site_dataset_service.password, - request=request) + dataset_service_object = initialize_engine_object( + engine=site_dataset_service.engine.encode('utf-8'), + endpoint=site_dataset_service.endpoint, + apikey=site_dataset_service.apikey, + username=site_dataset_service.username, + password=site_dataset_service.password, + request=request + ) dataset_service_object.public_endpoint = site_dataset_service.public_endpoint dataset_service_engines.append(dataset_service_object) @@ -162,12 +161,14 @@ def get_dataset_engine(name, app_class=None, request=None): # If match is found, initiate engine object if app_dataset_service.name == name: - return initialize_engine_object(engine=app_dataset_service.engine, - endpoint=app_dataset_service.endpoint, - apikey=app_dataset_service.apikey, - username=app_dataset_service.username, - password=app_dataset_service.password, - request=request) + return initialize_engine_object( + engine=app_dataset_service.engine, + endpoint=app_dataset_service.endpoint, + apikey=app_dataset_service.apikey, + username=app_dataset_service.username, + password=app_dataset_service.password, + request=request + ) # If the dataset engine cannot be found in the app_class, check database for site-wide dataset engines site_dataset_services = DsModel.objects.all() @@ -178,12 +179,14 @@ def get_dataset_engine(name, app_class=None, request=None): # If match is found initiate engine object if site_dataset_service.name == name: - dataset_service_object = initialize_engine_object(engine=site_dataset_service.engine.encode('utf-8'), - endpoint=site_dataset_service.endpoint, - apikey=site_dataset_service.apikey, - username=site_dataset_service.username, - password=site_dataset_service.password, - request=request) + dataset_service_object = initialize_engine_object( + engine=site_dataset_service.engine.encode('utf-8'), + endpoint=site_dataset_service.endpoint, + apikey=site_dataset_service.apikey, + username=site_dataset_service.username, + password=site_dataset_service.password, + request=request + ) dataset_service_object.public_endpoint = site_dataset_service.public_endpoint return dataset_service_object @@ -203,11 +206,13 @@ def list_spatial_dataset_engines(): if site_spatial_dataset_services: # Search for match for site_spatial_dataset_service in site_spatial_dataset_services: - spatial_dataset_object = initialize_engine_object(engine=site_spatial_dataset_service.engine.encode('utf-8'), - endpoint=site_spatial_dataset_service.endpoint, - apikey=site_spatial_dataset_service.apikey, - username=site_spatial_dataset_service.username, - password=site_spatial_dataset_service.password) + spatial_dataset_object = initialize_engine_object( + engine=site_spatial_dataset_service.engine.encode('utf-8'), + endpoint=site_spatial_dataset_service.endpoint, + apikey=site_spatial_dataset_service.apikey, + username=site_spatial_dataset_service.username, + password=site_spatial_dataset_service.password + ) spatial_dataset_object.public_endpoint = site_spatial_dataset_service.public_endpoint spatial_dataset_service_engines.append(spatial_dataset_object) @@ -239,11 +244,13 @@ def get_spatial_dataset_engine(name, app_class=None): # If match is found, initiate engine object if app_spatial_dataset_service.name == name: - return initialize_engine_object(engine=app_spatial_dataset_service.engine, - endpoint=app_spatial_dataset_service.endpoint, - apikey=app_spatial_dataset_service.apikey, - username=app_spatial_dataset_service.username, - password=app_spatial_dataset_service.password) + return initialize_engine_object( + engine=app_spatial_dataset_service.engine, + endpoint=app_spatial_dataset_service.endpoint, + apikey=app_spatial_dataset_service.apikey, + username=app_spatial_dataset_service.username, + password=app_spatial_dataset_service.password + ) # If the dataset engine cannot be found in the app_class, check database for site-wide dataset engines site_spatial_dataset_services = SdsModel.objects.all() @@ -254,17 +261,19 @@ def get_spatial_dataset_engine(name, app_class=None): # If match is found initiate engine object if site_spatial_dataset_service.name == name: - spatial_dataset_object = initialize_engine_object(engine=site_spatial_dataset_service.engine.encode('utf-8'), - endpoint=site_spatial_dataset_service.endpoint, - apikey=site_spatial_dataset_service.apikey, - username=site_spatial_dataset_service.username, - password=site_spatial_dataset_service.password) + spatial_dataset_object = initialize_engine_object( + engine=site_spatial_dataset_service.engine.encode('utf-8'), + endpoint=site_spatial_dataset_service.endpoint, + apikey=site_spatial_dataset_service.apikey, + username=site_spatial_dataset_service.username, + password=site_spatial_dataset_service.password + ) - spatial_dataset_object.public_endpoint = site_spatial_dataset_service.public_endpoint - return spatial_dataset_object + spatial_dataset_object.public_endpoint = site_spatial_dataset_service.public_endpoint + return spatial_dataset_object - raise NameError('Could not find spatial dataset service with name "{0}". Please check that dataset service with that name ' - 'exists in either the Admin Settings or in your app.py.'.format(name)) + raise NameError('Could not find spatial dataset service with name "{0}". Please check that dataset service with ' + 'that name exists in either the Admin Settings or in your app.py.'.format(name)) def abstract_is_link(process): @@ -312,8 +321,6 @@ def activate_wps(wps, endpoint, name): raise e except URLError as e: return None - except: - raise return wps @@ -399,11 +406,13 @@ def get_wps_service_engine(name, app_class=None): # If match is found, initiate engine object if app_wps_service.name == name: - wps = WebProcessingService(app_wps_service.endpoint, - username=app_wps_service.username, - password=app_wps_service.password, - verbose=False, - skip_caps=True) + wps = WebProcessingService( + app_wps_service.endpoint, + username=app_wps_service.username, + password=app_wps_service.password, + verbose=False, + skip_caps=True + ) return activate_wps(wps=wps, endpoint=app_wps_service.endpoint, name=app_wps_service.name) @@ -417,11 +426,13 @@ def get_wps_service_engine(name, app_class=None): # If match is found initiate engine object if site_wps_service.name == name: # Create OWSLib WebProcessingService engine object - wps = WebProcessingService(site_wps_service.endpoint, - username=site_wps_service.username, - password=site_wps_service.password, - verbose=False, - skip_caps=True) + wps = WebProcessingService( + site_wps_service.endpoint, + username=site_wps_service.username, + password=site_wps_service.password, + verbose=False, + skip_caps=True + ) # Initialize the object with get capabilities call return activate_wps(wps=wps, endpoint=site_wps_service.endpoint, name=site_wps_service.name) diff --git a/tethys_services/views.py b/tethys_services/views.py index 22f4eb4f3..2a7dd4a18 100644 --- a/tethys_services/views.py +++ b/tethys_services/views.py @@ -61,4 +61,4 @@ def wps_process(request, service, identifier): 'service': service, 'is_link': abstract_is_link(wps_process)} - return render(request, 'tethys_services/tethys_wps/process.html', context) \ No newline at end of file + return render(request, 'tethys_services/tethys_wps/process.html', context) diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..52690e83a --- /dev/null +++ b/tox.ini @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +exclude = .git,build,dist,__pycache__,.eggs,*.egg-info, settings.py From e16d249c59174583ba0922c6bdf26d33751e3260 Mon Sep 17 00:00:00 2001 From: Gage Larsen <37311335+gagelarsen@users.noreply.github.com> Date: Mon, 5 Nov 2018 13:26:23 -0700 Subject: [PATCH 178/215] Input fix (#349) * Fix input to use builtins for python 2 and 3 compatibility * remove unintentional commit of str --- tethys_apps/cli/scaffold_commands.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tethys_apps/cli/scaffold_commands.py b/tethys_apps/cli/scaffold_commands.py index e7964938c..30b52902b 100644 --- a/tethys_apps/cli/scaffold_commands.py +++ b/tethys_apps/cli/scaffold_commands.py @@ -3,6 +3,7 @@ import logging import random import shutil +from builtins import input from django.template import Template, Context from tethys_apps.cli.cli_colors import pretty_output, FG_RED, FG_YELLOW, FG_WHITE From 6cd77242df5f2fe7e358abc8657a1043c43ddfc4 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Thu, 24 Aug 2017 16:59:38 -0500 Subject: [PATCH 179/215] Added first additional basemap option (Stamen) --- .../tethys_gizmos/js/tethys_map_view.js | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index 500a7909e..a96a49f0d 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -125,7 +125,8 @@ var TETHYS_MAP_VIEW = (function() { // Constants var OPEN_STREET_MAP = 'OpenStreetMap', BING = 'Bing', - MAP_QUEST = 'MapQuest'; + MAP_QUEST = 'MapQuest', + STAMEN = 'Stamen'; // Declarations var base_map_layer; @@ -165,6 +166,14 @@ var TETHYS_MAP_VIEW = (function() { source: new ol.source.MapQuest({layer: 'sat'}), visible: visible }); + } else if (base_map_option === STAMEN) { + // Initialize default open street map layer + base_map_layer = new ol.layer.Tile({ + source: new ol.source.Stamen({ + layer: 'terrain', + }), + visible: visible + }); } // Add legend attributes base_map_layer.tethys_legend_title = 'Basemap: ' + base_map_option; @@ -216,6 +225,23 @@ var TETHYS_MAP_VIEW = (function() { } // Add legend attributes base_map_layer.tethys_legend_title = 'Basemap: ' + label; + + } else if (STAMEN in base_map_option) { + // Initialize custom map quest layer + base_map_layer = new ol.layer.Tile({ + source: new ol.source.Stamen({ + layer: 'watercolor', + }), + visible: visible + }); + + if (base_map_option[STAMEN].hasOwnProperty('label')) { + label = base_map_option[STAMEN].label; + } else { + label = STAMEN; + } + // Add legend attributes + base_map_layer.tethys_legend_title = 'Basemap: ' + label; } } @@ -289,8 +315,10 @@ var TETHYS_MAP_VIEW = (function() { $($(this).children()[0]).text(' (Current)'); m_map.getLayers().forEach(function (layer) { - if (layer.tethys_legend_title.indexOf('Basemap') !== -1) { + if (layer.hasOwnProperty('tethys_legend_title')) { + if (layer.tethys_legend_title.indexOf('Basemap') !== -1) { layer.setVisible(layer.tethys_legend_title === base_map_label); + } } }); }; From d110bed2a3a7f13d4d17ae74decb72f3dc514dd8 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Mon, 23 Apr 2018 08:28:59 -0500 Subject: [PATCH 180/215] basemap options complete still need docs --- tethys_gizmos/gizmo_options/map_view.py | 2 +- .../tethys_gizmos/js/tethys_map_view.js | 228 ++++++++---------- 2 files changed, 107 insertions(+), 123 deletions(-) diff --git a/tethys_gizmos/gizmo_options/map_view.py b/tethys_gizmos/gizmo_options/map_view.py index 2ee57e080..2b69f36bf 100644 --- a/tethys_gizmos/gizmo_options/map_view.py +++ b/tethys_gizmos/gizmo_options/map_view.py @@ -246,7 +246,7 @@ class MapView(TethysGizmoOptions): """ # noqa: E501 gizmo_name = "map_view" - def __init__(self, height='100%', width='100%', basemap='OpenStreetMap', view={'center': [-100, 40], 'zoom': 2}, + def __init__(self, height='100%', width='100%', basemap=None, view={'center': [-100, 40], 'zoom': 2}, controls=[], layers=[], draw=None, legend=False, attributes={}, classes='', disable_basemap=False, feature_selection=None): """ diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index a96a49f0d..19be78257 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -119,15 +119,79 @@ var TETHYS_MAP_VIEW = (function() { * Initialization Methods ***********************************/ + var base_map_labels = []; // Initialize the background map ol_base_map_init = function() { // Constants - var OPEN_STREET_MAP = 'OpenStreetMap', - BING = 'Bing', - MAP_QUEST = 'MapQuest', - STAMEN = 'Stamen'; + var SUPPORTED_BASE_MAPS = { + 'OpenStreetMap': { + source_class: ol.source.OSM, + default_source_options: {}, + label_property: null, + }, + 'Bing': { + source_class: ol.source.BingMaps, + default_source_options: null, + label_property: 'imagerySet', + }, + 'Stamen': { + source_class: ol.source.Stamen, + default_source_options: { + layer: 'terrain', + }, + label_property: 'layer', + }, + 'ESRI': { + source_class: function(options){ + //ESRI_Imagery_World_2D (MapServer) + //ESRI_StreetMap_World_2D (MapServer) + //NatGeo_World_Map (MapServer) + //NGS_Topo_US_2D (MapServer) + //Ocean_Basemap (MapServer) + //USA_Topo_Maps (MapServer) + //World_Imagery (MapServer) + //World_Physical_Map (MapServer) + //World_Shaded_Relief (MapServer) + //World_Street_Map (MapServer) + //World_Terrain_Base (MapServer) + //World_Topo_Map (MapServer) + + options.url = 'https://server.arcgisonline.com/ArcGIS/rest/services/' + + options.layer + '/MapServer/tile/{z}/{y}/{x}'; + + return new ol.source.XYZ(options); + }, + default_source_options: { + attributions: 'Tiles © ArcGIS', + layer: 'World_Street_Map' + }, + label_property: 'layer', + }, + 'CartoDB': { + source_class: function(options){ + var style, // 'light' or 'dark'. Default is 'light' + labels; // true or false. Default is true. + style = is_defined(options.style) ? options.style : 'light'; + labels = options.labels === false ? '_nolabels': '_all'; + + options.url = 'http://{1-4}.basemaps.cartocdn.com/' + style + labels + '/{z}/{x}/{y}.png' + return new ol.source.XYZ(options) + }, + default_source_options: { + style: 'light', + labels: true, + }, + label_property: 'style', + }, + 'XYZ': { + source_class: ol.source.XYZ, + default_source_options: {}, + label_property: null, + }, + } // Declarations var base_map_layer; @@ -135,11 +199,6 @@ var TETHYS_MAP_VIEW = (function() { return; } - // Default base map - base_map_layer = new ol.layer.Tile({ - source: new ol.source.OSM() - }); - if (is_defined(m_base_map_options)) { var base_map_options = Array.isArray(m_base_map_options) ? m_base_map_options : [m_base_map_options] var first_flag = true; @@ -150,117 +209,64 @@ var TETHYS_MAP_VIEW = (function() { visible = true; first_flag = false; } - if (typeof base_map_option === 'string') { - if (base_map_option === OPEN_STREET_MAP) { - // Initialize default open street map layer - base_map_layer = new ol.layer.Tile({ - source: new ol.source.OSM(), - visible: visible - }); - } else if (base_map_option === BING) { - // Initialize default bing layer - - } else if (base_map_option === MAP_QUEST) { - // Initialize default map quest layer - base_map_layer = new ol.layer.Tile({ - source: new ol.source.MapQuest({layer: 'sat'}), - visible: visible - }); - } else if (base_map_option === STAMEN) { - // Initialize default open street map layer - base_map_layer = new ol.layer.Tile({ - source: new ol.source.Stamen({ - layer: 'terrain', - }), - visible: visible - }); - } - // Add legend attributes - base_map_layer.tethys_legend_title = 'Basemap: ' + base_map_option; - } else if (typeof base_map_option === 'object') { + var base_map_layer_name, + base_map_layer_arguments; - if (OPEN_STREET_MAP in base_map_option) { - // Initialize custom open street map layer - base_map_layer = new ol.layer.Tile({ - source: new ol.source.OSM(base_map_option[OPEN_STREET_MAP]), - visible: visible - }); + if (typeof base_map_option === 'string') { + base_map_layer_name = base_map_option; + base_map_layer_arguments = null; + } + else if (typeof base_map_option === 'object'){ + base_map_layer_name = Object.getOwnPropertyNames(base_map_option)[0]; + base_map_layer_arguments = base_map_option[base_map_layer_name]; + } - if (base_map_option[OPEN_STREET_MAP].hasOwnProperty('label')) { - label = base_map_option[OPEN_STREET_MAP].label; - } else { - label = OPEN_STREET_MAP; - } - // Add legend attributes - base_map_layer.tethys_legend_title = 'Basemap: ' + label; - } else if (BING in base_map_option) { - // Initialize custom bing layer - base_map_layer = new ol.layer.Tile({ - preload: Infinity, - source: new ol.source.BingMaps(base_map_option[BING]), - visible: visible - }); + if (Object.getOwnPropertyNames(SUPPORTED_BASE_MAPS).includes(base_map_layer_name)){ + var base_map_metadata = SUPPORTED_BASE_MAPS[base_map_layer_name]; + var LayerSource = base_map_metadata.source_class; + var source_options = base_map_layer_arguments ? base_map_layer_arguments : base_map_metadata.default_source_options; - if (base_map_option[BING].hasOwnProperty('label')) { - label = base_map_option[BING].label; - } else { - label = BING + '-' + base_map_option[BING]['imagerySet']; + if(source_options){ + base_map_layer = new ol.layer.Tile({ + source: new LayerSource(source_options), + visible: visible + }); } - // Add legend attributes - base_map_layer.tethys_legend_title = 'Basemap: ' + label; - - } else if (MAP_QUEST in base_map_option) { - // Initialize custom map quest layer - base_map_layer = new ol.layer.Tile({ - source: new ol.source.MapQuest(base_map_option[MAP_QUEST]), - visible: visible - }); - if (base_map_option[MAP_QUEST].hasOwnProperty('label')) { - label = base_map_option[MAP_QUEST].label; - } else { - label = MAP_QUEST; + label = base_map_layer_name; + if (source_options && source_options.hasOwnProperty('label')) { + label = source_options.label; } - // Add legend attributes - base_map_layer.tethys_legend_title = 'Basemap: ' + label; - - } else if (STAMEN in base_map_option) { - // Initialize custom map quest layer - base_map_layer = new ol.layer.Tile({ - source: new ol.source.Stamen({ - layer: 'watercolor', - }), - visible: visible - }); - - if (base_map_option[STAMEN].hasOwnProperty('label')) { - label = base_map_option[STAMEN].label; - } else { - label = STAMEN; + else if(base_map_metadata.label_property) { + label += '-' + source_options[base_map_metadata.label_property]; } + // Add legend attributes base_map_layer.tethys_legend_title = 'Basemap: ' + label; - } + base_map_labels.push(label); } // Add the base map to layers m_map.addLayer(base_map_layer); }); } + else{ + // Default base map + base_map_layer = new ol.layer.Tile({ + source: new ol.source.OSM() + }); + // Add the base map to layers + m_map.addLayer(base_map_layer); + } } // Initialize the base map switcher ol_base_map_switcher_init = function () { - // Constants - var OPEN_STREET_MAP = 'OpenStreetMap', - BING = 'Bing', - MAP_QUEST = 'MapQuest'; - - if (is_defined(m_base_map_options)) { - var base_map_options = Array.isArray(m_base_map_options) ? m_base_map_options : [m_base_map_options] - if (base_map_options.length >= 1) { + if (is_defined(base_map_labels)) { +// var base_map_options = Array.isArray(m_base_map_options) ? m_base_map_options : [m_base_map_options] + if (base_map_labels.length >= 1) { var $map_element = $('#' + m_map_target); var html = '' + '
'; } else if (legend_class.LEGEND_TYPE === "mvlegend") { - html += ''; + html += ''; if (legend_class.type === legend_class.POINT_TYPE) { - html += ''; + html += ''; } else if (legend_class.type === legend_class.LINE_TYPE) { - html += ''; + html += ''; } else if (legend_class.type === legend_class.POLYGON_TYPE) { - html += ''; + html += ''; } else if (legend_class.type === legend_class.RASTER_TYPE) { - //TODO: ADD IMPLEMENTATION FOR RASTER + for (var j = 0; j < legend_class.ramp.length; j++) { + html += ''; + } } - html += '' + legend_class.value + ''; + html += '' + legend_class.value + ''; } } From 0dc2406559395885c1cacb1d6486fca0b2ef9962 Mon Sep 17 00:00:00 2001 From: Nathan Swain Date: Mon, 19 Nov 2018 15:00:03 -0700 Subject: [PATCH 182/215] Added missing db migration for python 3 compatibility changes. (#350) --- .../migrations/0003_python3_compatibility.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tethys_apps/migrations/0003_python3_compatibility.py diff --git a/tethys_apps/migrations/0003_python3_compatibility.py b/tethys_apps/migrations/0003_python3_compatibility.py new file mode 100644 index 000000000..63e04a295 --- /dev/null +++ b/tethys_apps/migrations/0003_python3_compatibility.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-11-13 17:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tethys_apps', '0002_tethysextension'), + ] + + operations = [ + migrations.AlterField( + model_name='customsetting', + name='value', + field=models.CharField(blank=True, default='', max_length=1024), + ), + ] From f229434d4033bce2ba0430e52dff5b0b4d960d08 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Thu, 24 Aug 2017 16:59:38 -0500 Subject: [PATCH 183/215] Added first additional basemap option (Stamen) --- .../tethys_gizmos/js/tethys_map_view.js | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index 500a7909e..a96a49f0d 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -125,7 +125,8 @@ var TETHYS_MAP_VIEW = (function() { // Constants var OPEN_STREET_MAP = 'OpenStreetMap', BING = 'Bing', - MAP_QUEST = 'MapQuest'; + MAP_QUEST = 'MapQuest', + STAMEN = 'Stamen'; // Declarations var base_map_layer; @@ -165,6 +166,14 @@ var TETHYS_MAP_VIEW = (function() { source: new ol.source.MapQuest({layer: 'sat'}), visible: visible }); + } else if (base_map_option === STAMEN) { + // Initialize default open street map layer + base_map_layer = new ol.layer.Tile({ + source: new ol.source.Stamen({ + layer: 'terrain', + }), + visible: visible + }); } // Add legend attributes base_map_layer.tethys_legend_title = 'Basemap: ' + base_map_option; @@ -216,6 +225,23 @@ var TETHYS_MAP_VIEW = (function() { } // Add legend attributes base_map_layer.tethys_legend_title = 'Basemap: ' + label; + + } else if (STAMEN in base_map_option) { + // Initialize custom map quest layer + base_map_layer = new ol.layer.Tile({ + source: new ol.source.Stamen({ + layer: 'watercolor', + }), + visible: visible + }); + + if (base_map_option[STAMEN].hasOwnProperty('label')) { + label = base_map_option[STAMEN].label; + } else { + label = STAMEN; + } + // Add legend attributes + base_map_layer.tethys_legend_title = 'Basemap: ' + label; } } @@ -289,8 +315,10 @@ var TETHYS_MAP_VIEW = (function() { $($(this).children()[0]).text(' (Current)'); m_map.getLayers().forEach(function (layer) { - if (layer.tethys_legend_title.indexOf('Basemap') !== -1) { + if (layer.hasOwnProperty('tethys_legend_title')) { + if (layer.tethys_legend_title.indexOf('Basemap') !== -1) { layer.setVisible(layer.tethys_legend_title === base_map_label); + } } }); }; From e4d53ca1e742edd761e031b4f12c3304288063ef Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Mon, 26 Nov 2018 12:57:37 -0600 Subject: [PATCH 184/215] update openlayers --- tethys_gizmos/gizmo_options/map_view.py | 41 +++++++++++++-------- tethys_gizmos/templatetags/tethys_gizmos.py | 2 +- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/tethys_gizmos/gizmo_options/map_view.py b/tethys_gizmos/gizmo_options/map_view.py index 2b69f36bf..58397b9bd 100644 --- a/tethys_gizmos/gizmo_options/map_view.py +++ b/tethys_gizmos/gizmo_options/map_view.py @@ -47,11 +47,13 @@ class MapView(TethysGizmoOptions): **Base Maps** - There are three base maps supported by the Map View gizmo: OpenStreetMap, Bing, and MapQuest. Use the following links to learn about the additional options you can configure the base maps with: + There are several base maps supported by the Map View gizmo. Use the following links to learn about the additional options you can configure the base maps with: - * Bing: `ol.source.BingMaps `_ - * MapQuest: `ol.source.MapQuest `_ - * OpenStreetMap: `ol.source.OSM `_ + * OpenStreetMap: `ol/source/OSM `_ + * Stamen: `ol/source/Stamen `_ + * CartoDB `ol/source/CartoDB `_ + * XYZ `ol/source/XYZ `_ + * Bing: `ol/source/BingMaps `_ :: @@ -245,10 +247,11 @@ class MapView(TethysGizmoOptions): """ # noqa: E501 gizmo_name = "map_view" + ol_version = '5.3.0' def __init__(self, height='100%', width='100%', basemap=None, view={'center': [-100, 40], 'zoom': 2}, controls=[], layers=[], draw=None, legend=False, attributes={}, classes='', disable_basemap=False, - feature_selection=None): + feature_selection=None, ol_version='4.6.5'): """ Constructor """ @@ -265,17 +268,20 @@ def __init__(self, height='100%', width='100%', basemap=None, view={'center': [- self.legend = legend self.disable_basemap = disable_basemap self.feature_selection = feature_selection + self.ol_version = ol_version - @staticmethod - def get_vendor_js(): + @classmethod + def get_vendor_js(cls): """ JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ - openlayers_library = 'tethys_gizmos/vendor/openlayers/ol.js' - if settings.DEBUG: - openlayers_library = 'tethys_gizmos/vendor/openlayers/ol-debug.js' - return (openlayers_library,) + debug = '-debug' if settings.DEBUG else '' + openlayers_library = 'https://cdnjs.cloudflare.com/ajax/libs/openlayers/{}/ol{}.js'.format(cls.ol_version, debug) + # openlayers_library = 'https://cdn.jsdelivr.net/npm/openlayers@{}/dist/ol{}.js'.format(cls.ol_version, debug) + openlayers_library = 'tethys_gizmos/vendor/openlayers/{}/ol.js'.format(cls.ol_version) + + return openlayers_library, @staticmethod def get_gizmo_js(): @@ -286,13 +292,18 @@ def get_gizmo_js(): return ('tethys_gizmos/js/gizmo_utilities.js', 'tethys_gizmos/js/tethys_map_view.js') - @staticmethod - def get_vendor_css(): + @classmethod + def get_vendor_css(cls): """ CSS vendor libraries to be placed in the {% block styles %} block """ - return ('tethys_gizmos/vendor/openlayers/ol.css',) + debug = '-debug' if settings.DEBUG else '' + openlayers_css = 'https://cdnjs.cloudflare.com/ajax/libs/openlayers/{}/ol{}.css'.format(cls.ol_version, debug) + openlayers_css = 'https://cdn.jsdelivr.net/npm/openlayers@{}/dist/ol{}.css'.format(cls.ol_version, debug) + openlayers_css = 'tethys_gizmos/vendor/openlayers/{}/ol.css'.format(cls.ol_version) + + return openlayers_css, @staticmethod def get_gizmo_css(): @@ -300,7 +311,7 @@ def get_gizmo_css(): CSS specific to gizmo to be placed in the {% block content_dependent_styles %} block """ - return ('tethys_gizmos/css/tethys_map_view.min.css',) + return 'tethys_gizmos/css/tethys_map_view.min.css', class MVView(SecondaryGizmoOptions): diff --git a/tethys_gizmos/templatetags/tethys_gizmos.py b/tethys_gizmos/templatetags/tethys_gizmos.py index 19a17053e..179ee0d94 100644 --- a/tethys_gizmos/templatetags/tethys_gizmos.py +++ b/tethys_gizmos/templatetags/tethys_gizmos.py @@ -214,7 +214,7 @@ def render(self, context): if 'OPTIONS' in template_settings \ and 'debug' in template_settings['OPTIONS'] \ and template_settings['OPTIONS']['debug']: - raise e + raise return '' From 6e194200a30c10747c3aaf7bdd27115574c04e39 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Tue, 27 Nov 2018 07:15:48 -0600 Subject: [PATCH 185/215] finish base map options and docs allow OpenLayers version to be specified --- tethys_gizmos/gizmo_options/map_view.py | 109 +++++++++++++++++++----- 1 file changed, 90 insertions(+), 19 deletions(-) diff --git a/tethys_gizmos/gizmo_options/map_view.py b/tethys_gizmos/gizmo_options/map_view.py index 58397b9bd..aabb4a1e7 100644 --- a/tethys_gizmos/gizmo_options/map_view.py +++ b/tethys_gizmos/gizmo_options/map_view.py @@ -18,7 +18,7 @@ class MapView(TethysGizmoOptions): """ - The Map View gizmo can be used to produce interactive maps of spatial data. It is powered by OpenLayers 4, a free and open source pure javascript mapping library. It supports layers in a variety of different formats including WMS, Tiled WMS, GeoJSON, KML, and ArcGIS REST. It includes drawing capabilities and the ability to create a legend for the layers included in the map. + The Map View gizmo can be used to produce interactive maps of spatial data. It is powered by OpenLayers, a free and open source pure javascript mapping library. It supports layers in a variety of different formats including WMS, Tiled WMS, GeoJSON, KML, and ArcGIS REST. It includes drawing capabilities and the ability to create a legend for the layers included in the map. Shapes that are drawn on the map by users can be retrieved from the map via a hidden text field named 'geometry' and it is updated every time the map is changed. The text in the text field is a string representation of JSON. The geometry definition contained in this JSON can be formatted as either GeoJSON or Well Known Text. This can be configured via the output_format option of the MVDraw object. If the Map View is embedded in a form, the geometry that is drawn on the map will automatically be submitted with the rest of the form via the hidden text field. @@ -47,17 +47,36 @@ class MapView(TethysGizmoOptions): **Base Maps** - There are several base maps supported by the Map View gizmo. Use the following links to learn about the additional options you can configure the base maps with: + There are several base maps supported by the Map View gizmo: `OpenStreetMap`, `Bing`, `Stamen`, `CartoDB`, and `ESRI`. All base maps can be specified as a string or as an options dictionary. When using an options dictionary all base maps map services accept the option `label`, which is used to specify the label to be used in the Base Map control. For example:: + + {'Bing': {'key': 'Ap|k3yheRE', 'imagerySet': 'Aerial', 'label': 'Bing Aerial'}} + + For additional options that can be provided to each base map service see the following links: * OpenStreetMap: `ol/source/OSM `_ + * Bing: `ol/source/BingMaps `_ * Stamen: `ol/source/Stamen `_ - * CartoDB `ol/source/CartoDB `_ * XYZ `ol/source/XYZ `_ - * Bing: `ol/source/BingMaps `_ - - :: - - {'Bing': {'key': 'Ap|k3yheRE', 'imagerySet': 'Aerial'}} + + .. note:: + + The CartoDB and ESRI services are just pre-defined instances of the XYZ service. In addition to the standard XYZ options they have the following additional options: + + CartoDB: + * `style`: The style of map. Possibilities are 'light' or 'dark'. + * `labels`: Boolean specifying whether or not to include labels. + + ESRI: + * `layer`: A string specifying which ESRI map to use. Possibilities are: + * NatGeo_World_Map + * Ocean_Basemap + * USA_Topo_Maps + * World_Imagery + * World_Physical_Map + * World_Shaded_Relief + * World_Street_Map + * World_Terrain_Base + * World_Topo_Map **Controls** @@ -77,6 +96,16 @@ class MapView(TethysGizmoOptions): * multiselect: Set to True to allow multiple features to be selected while holding the shift key on the keyboard. Defaults to False. * sensitivity: Integer value that adjust the feature selection sensitivity. Defaults to 2. + .. tip:: + + **OpenLayers Version** + + Currently, OpenLayers version 5.3.0 is used by default with the Map View gizmo. If you need a specific version of OpenLayers you can specify the version number using the `ol_version` class attribute on the `MapView` class:: + + MapView.ol_version = '4.6.5' + + Any versions that are provided by https://www.jsdelivr.com/package/npm/openlayers can be specified. + Controller Example @@ -221,6 +250,36 @@ class MapView(TethysGizmoOptions): legend_extent=[-173, 17, -65, 72] ) + # Define base map options + + esri_layer_names = [ + 'NatGeo_World_Map', + 'Ocean_Basemap', + 'USA_Topo_Maps', + 'World_Imagery', + 'World_Physical_Map', + 'World_Shaded_Relief', + 'World_Street_Map', + 'World_Terrain_Base', + 'World_Topo_Map', + ] + esri_layers = [{'ESRI': {'layer': l}} for l in esri_layer_names] + basemaps = [ + 'Stamen', + {'Stamen': {'layer': 'toner', 'label': 'Black and White'}}, + {'Stamen': {'layer': 'watercolor'}}, + 'OpenStreetMap', + 'CartoDB', + {'CartoDB': {'style': 'dark'}}, + {'CartoDB': {'style': 'light', 'labels': False, 'label': 'CartoDB-light-no-labels'}}, + {'XYZ': {'url': 'https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png', 'label': 'Wikimedia'}} + 'ESRI', + ] + basemaps.extend(esri_layers) + + # Specify OpenLayers version + MapView.ol_version = '5.3.0' + # Define map view options map_view_options = MapView( height='600px', @@ -230,7 +289,7 @@ class MapView(TethysGizmoOptions): {'ZoomToExtent': {'projection': 'EPSG:4326', 'extent': [-130, 22, -65, 54]}}], layers=[geojson_layer, geojson_point_layer, geoserver_layer, kml_layer, arc_gis_layer], view=view_options, - basemap='OpenStreetMap', + basemap=basemaps, draw=drawing_options, legend=True ) @@ -248,10 +307,13 @@ class MapView(TethysGizmoOptions): """ # noqa: E501 gizmo_name = "map_view" ol_version = '5.3.0' + cdn = 'https://cdn.jsdelivr.net/npm/openlayers@{version}/dist/ol{debug}.{ext}' + alternate_cdn = 'https://cdnjs.cloudflare.com/ajax/libs/openlayers/{version}/ol{debug}.{ext}' + local_url = 'tethys_gizmos/vendor/openlayers/{version}/ol.{ext}' def __init__(self, height='100%', width='100%', basemap=None, view={'center': [-100, 40], 'zoom': 2}, controls=[], layers=[], draw=None, legend=False, attributes={}, classes='', disable_basemap=False, - feature_selection=None, ol_version='4.6.5'): + feature_selection=None): """ Constructor """ @@ -268,7 +330,14 @@ def __init__(self, height='100%', width='100%', basemap=None, view={'center': [- self.legend = legend self.disable_basemap = disable_basemap self.feature_selection = feature_selection - self.ol_version = ol_version + + @classmethod + def static_url(cls): + return cls.cdn if cls.ol_version != '5.3.0' else cls.local_url + + @classmethod + def debug(cls): + return '-debug' if settings.DEBUG else '' @classmethod def get_vendor_js(cls): @@ -276,10 +345,11 @@ def get_vendor_js(cls): JavaScript vendor libraries to be placed in the {% block global_scripts %} block """ - debug = '-debug' if settings.DEBUG else '' - openlayers_library = 'https://cdnjs.cloudflare.com/ajax/libs/openlayers/{}/ol{}.js'.format(cls.ol_version, debug) - # openlayers_library = 'https://cdn.jsdelivr.net/npm/openlayers@{}/dist/ol{}.js'.format(cls.ol_version, debug) - openlayers_library = 'tethys_gizmos/vendor/openlayers/{}/ol.js'.format(cls.ol_version) + openlayers_library = cls.static_url().format( + version=cls.ol_version, + debug=cls.debug(), + ext='js' + ) return openlayers_library, @@ -298,10 +368,11 @@ def get_vendor_css(cls): CSS vendor libraries to be placed in the {% block styles %} block """ - debug = '-debug' if settings.DEBUG else '' - openlayers_css = 'https://cdnjs.cloudflare.com/ajax/libs/openlayers/{}/ol{}.css'.format(cls.ol_version, debug) - openlayers_css = 'https://cdn.jsdelivr.net/npm/openlayers@{}/dist/ol{}.css'.format(cls.ol_version, debug) - openlayers_css = 'tethys_gizmos/vendor/openlayers/{}/ol.css'.format(cls.ol_version) + openlayers_css = cls.static_url().format( + version=cls.ol_version, + debug=cls.debug(), + ext='css' + ) return openlayers_css, From 46bd8d57df59e20add554a91185e264d62a1c505 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Tue, 27 Nov 2018 09:47:12 -0600 Subject: [PATCH 186/215] stickler fix debug map view test --- .../test_gizmo_options/test_map_view.py | 2 +- tethys_gizmos/gizmo_options/map_view.py | 26 +++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_map_view.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_map_view.py index 5a892aa06..d13fb176d 100644 --- a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_map_view.py +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_map_view.py @@ -38,7 +38,7 @@ def test_MapView(self): def test_MapView_debug(self, mock_settings): ms = mock_settings() ms.return_value = MockObject() - + gizmo_map_view.MapView.ol_version = '4.6.5' self.assertIn('-debug.js', gizmo_map_view.MapView.get_vendor_js()[0]) def test_MVView(self): diff --git a/tethys_gizmos/gizmo_options/map_view.py b/tethys_gizmos/gizmo_options/map_view.py index aabb4a1e7..d8683a9ef 100644 --- a/tethys_gizmos/gizmo_options/map_view.py +++ b/tethys_gizmos/gizmo_options/map_view.py @@ -47,25 +47,25 @@ class MapView(TethysGizmoOptions): **Base Maps** - There are several base maps supported by the Map View gizmo: `OpenStreetMap`, `Bing`, `Stamen`, `CartoDB`, and `ESRI`. All base maps can be specified as a string or as an options dictionary. When using an options dictionary all base maps map services accept the option `label`, which is used to specify the label to be used in the Base Map control. For example:: + There are several base maps supported by the Map View gizmo: `OpenStreetMap`, `Bing`, `Stamen`, `CartoDB`, and `ESRI`. All base maps can be specified as a string or as an options dictionary. When using an options dictionary all base maps map services accept the option `label`, which is used to specify the label to be used in the Base Map control. For example:: {'Bing': {'key': 'Ap|k3yheRE', 'imagerySet': 'Aerial', 'label': 'Bing Aerial'}} - + For additional options that can be provided to each base map service see the following links: * OpenStreetMap: `ol/source/OSM `_ * Bing: `ol/source/BingMaps `_ * Stamen: `ol/source/Stamen `_ * XYZ `ol/source/XYZ `_ - + .. note:: - + The CartoDB and ESRI services are just pre-defined instances of the XYZ service. In addition to the standard XYZ options they have the following additional options: - + CartoDB: * `style`: The style of map. Possibilities are 'light' or 'dark'. * `labels`: Boolean specifying whether or not to include labels. - + ESRI: * `layer`: A string specifying which ESRI map to use. Possibilities are: * NatGeo_World_Map @@ -97,15 +97,14 @@ class MapView(TethysGizmoOptions): * sensitivity: Integer value that adjust the feature selection sensitivity. Defaults to 2. .. tip:: - + **OpenLayers Version** - + Currently, OpenLayers version 5.3.0 is used by default with the Map View gizmo. If you need a specific version of OpenLayers you can specify the version number using the `ol_version` class attribute on the `MapView` class:: - + MapView.ol_version = '4.6.5' - + Any versions that are provided by https://www.jsdelivr.com/package/npm/openlayers can be specified. - Controller Example @@ -251,7 +250,6 @@ class MapView(TethysGizmoOptions): ) # Define base map options - esri_layer_names = [ 'NatGeo_World_Map', 'Ocean_Basemap', @@ -276,10 +274,10 @@ class MapView(TethysGizmoOptions): 'ESRI', ] basemaps.extend(esri_layers) - + # Specify OpenLayers version MapView.ol_version = '5.3.0' - + # Define map view options map_view_options = MapView( height='600px', From 533dbdfa7fd67f14bdc30075ce708f793fd50393 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Tue, 27 Nov 2018 09:49:11 -0600 Subject: [PATCH 187/215] stickler --- tethys_gizmos/gizmo_options/map_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tethys_gizmos/gizmo_options/map_view.py b/tethys_gizmos/gizmo_options/map_view.py index d8683a9ef..730cdec9c 100644 --- a/tethys_gizmos/gizmo_options/map_view.py +++ b/tethys_gizmos/gizmo_options/map_view.py @@ -97,7 +97,7 @@ class MapView(TethysGizmoOptions): * sensitivity: Integer value that adjust the feature selection sensitivity. Defaults to 2. .. tip:: - + **OpenLayers Version** Currently, OpenLayers version 5.3.0 is used by default with the Map View gizmo. If you need a specific version of OpenLayers you can specify the version number using the `ol_version` class attribute on the `MapView` class:: From b58548151709db1d7a37e0feea1f88ab739eb7d4 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Tue, 27 Nov 2018 10:00:12 -0600 Subject: [PATCH 188/215] stickler --- tethys_gizmos/gizmo_options/map_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tethys_gizmos/gizmo_options/map_view.py b/tethys_gizmos/gizmo_options/map_view.py index 730cdec9c..1201624cb 100644 --- a/tethys_gizmos/gizmo_options/map_view.py +++ b/tethys_gizmos/gizmo_options/map_view.py @@ -62,7 +62,7 @@ class MapView(TethysGizmoOptions): The CartoDB and ESRI services are just pre-defined instances of the XYZ service. In addition to the standard XYZ options they have the following additional options: - CartoDB: + CartoDB: * `style`: The style of map. Possibilities are 'light' or 'dark'. * `labels`: Boolean specifying whether or not to include labels. From 10538567ee01a68bf27029b445bd67cbea1ad7fb Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Thu, 29 Nov 2018 16:06:25 -0600 Subject: [PATCH 189/215] change `label` to `control_label` and handle debug for ol > v5 --- tethys_gizmos/gizmo_options/map_view.py | 13 +++++++------ .../static/tethys_gizmos/js/tethys_map_view.js | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tethys_gizmos/gizmo_options/map_view.py b/tethys_gizmos/gizmo_options/map_view.py index 1201624cb..7a9c06c7f 100644 --- a/tethys_gizmos/gizmo_options/map_view.py +++ b/tethys_gizmos/gizmo_options/map_view.py @@ -47,9 +47,9 @@ class MapView(TethysGizmoOptions): **Base Maps** - There are several base maps supported by the Map View gizmo: `OpenStreetMap`, `Bing`, `Stamen`, `CartoDB`, and `ESRI`. All base maps can be specified as a string or as an options dictionary. When using an options dictionary all base maps map services accept the option `label`, which is used to specify the label to be used in the Base Map control. For example:: + There are several base maps supported by the Map View gizmo: `OpenStreetMap`, `Bing`, `Stamen`, `CartoDB`, and `ESRI`. All base maps can be specified as a string or as an options dictionary. When using an options dictionary all base maps map services accept the option `control_label`, which is used to specify the label to be used in the Base Map control. For example:: - {'Bing': {'key': 'Ap|k3yheRE', 'imagerySet': 'Aerial', 'label': 'Bing Aerial'}} + {'Bing': {'key': 'Ap|k3yheRE', 'imagerySet': 'Aerial', 'control_label': 'Bing Aerial'}} For additional options that can be provided to each base map service see the following links: @@ -264,13 +264,13 @@ class MapView(TethysGizmoOptions): esri_layers = [{'ESRI': {'layer': l}} for l in esri_layer_names] basemaps = [ 'Stamen', - {'Stamen': {'layer': 'toner', 'label': 'Black and White'}}, + {'Stamen': {'layer': 'toner', 'control_label': 'Black and White'}}, {'Stamen': {'layer': 'watercolor'}}, 'OpenStreetMap', 'CartoDB', {'CartoDB': {'style': 'dark'}}, - {'CartoDB': {'style': 'light', 'labels': False, 'label': 'CartoDB-light-no-labels'}}, - {'XYZ': {'url': 'https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png', 'label': 'Wikimedia'}} + {'CartoDB': {'style': 'light', 'labels': False, 'control_label': 'CartoDB-light-no-labels'}}, + {'XYZ': {'url': 'https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png', 'control_label': 'Wikimedia'}} 'ESRI', ] basemaps.extend(esri_layers) @@ -335,7 +335,8 @@ def static_url(cls): @classmethod def debug(cls): - return '-debug' if settings.DEBUG else '' + # Note: Since version 5 OpenLayers now uses source maps instead of a '-debug' version of the code + return '-debug' if settings.DEBUG and int(cls.ol_version[0]) < 5 else '' @classmethod def get_vendor_js(cls): diff --git a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js index 1fca0e430..18b5b5fb7 100644 --- a/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js +++ b/tethys_gizmos/static/tethys_gizmos/js/tethys_map_view.js @@ -236,8 +236,8 @@ var TETHYS_MAP_VIEW = (function() { } label = base_map_layer_name; - if (source_options && source_options.hasOwnProperty('label')) { - label = source_options.label; + if (source_options && source_options.hasOwnProperty('control_label')) { + label = source_options.control_label; } else if(base_map_metadata.label_property) { label += '-' + source_options[base_map_metadata.label_property]; From 7f610022fa2d0ed6ab00a05272274ad4e855aad3 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Fri, 30 Nov 2018 12:36:48 -0600 Subject: [PATCH 190/215] Deprecate Python 2 fixes #353 fixes #309 fixes #267 --- README.rst | 8 + docs/installation/linux_and_mac.rst | 20 +- docs/installation/windows.rst | 8 +- environment_py2.yml | 2 +- environment_py3.yml | 2 +- readthedocs.yml | 4 +- scripts/install_tethys.bat | 4 +- scripts/install_tethys.sh | 51 +++-- tethys_apps/harvester.py | 5 + tethys_compute/job_manager.py | 1 + tethys_portal/settings_.py | 306 ++++++++++++++++++++++++++++ 11 files changed, 382 insertions(+), 29 deletions(-) create mode 100644 tethys_portal/settings_.py diff --git a/README.rst b/README.rst index 6fb0960fe..6d747f845 100644 --- a/README.rst +++ b/README.rst @@ -3,6 +3,14 @@ Tethys Platform .. image:: https://travis-ci.org/tethysplatform/tethys.svg?branch=master :target: https://travis-ci.org/tethysplatform/tethys +.. image:: https://coveralls.io/repos/github/tethysplatform/tethys/badge.svg + :target: https://coveralls.io/github/tethysplatform/tethys + + +.. image:: https://readthedocs.org/projects/tethys-platform/badge/?version=stable + :target: http://docs.tethysplatform.org/en/stable/?badge=stable + :alt: Documentation Status + Tethys Platform provides both a development environment and a hosting environment for water resources web apps. Documentation can be found here: ``_ diff --git a/docs/installation/linux_and_mac.rst b/docs/installation/linux_and_mac.rst index 61c177e90..833bb4d38 100644 --- a/docs/installation/linux_and_mac.rst +++ b/docs/installation/linux_and_mac.rst @@ -55,8 +55,12 @@ Install Script Options * `-n, --conda-env-name `: Name for tethys conda environment. Default is 'tethys'. - * `--python-version `: - Main python version to install tethys environment into (2 or 3). Default is 2. + * `--python-version ` (deprecated): + Main python version to install tethys environment into (2-deprecated or 3). Default is 3. + .. note:: + + Support for Python 2 is deprecated and will be dropped in Tethys version 3.0. + * `--db-username `: Username that the tethys database server will use. Default is 'tethys_default'. * `--db-password `: @@ -91,19 +95,21 @@ Install Script Options * `e` - Create Conda environment * `s` - Create `settings.py` file * `d` - Setup local database server + * `i` - Initialize database server with Tethys database and superuser * `a` - Create activation/deactivation scripts for the Tethys Conda environment * `t` - Create the `t` alias to activate the Tethys Conda environment For example, if you already have Miniconda installed and you have the repository cloned and have generated a `settings.py` file, but you want to use the install script to: - * create a conda environment, - * setup the database, - * create the conda activation/deactivation scripts, and - * create the `t` shortcut, + * create a conda environment, + * setup a local database server, + * initialize the database + * create the conda activation/deactivation scripts, and + * create the `t` shortcut, then you can run the following command:: - bash install_tethys.sh --partial-tethys-install edat + bash install_tethys.sh --partial-tethys-install ediat .. warning:: diff --git a/docs/installation/windows.rst b/docs/installation/windows.rst index 039fa77cf..9bf5a7337 100644 --- a/docs/installation/windows.rst +++ b/docs/installation/windows.rst @@ -52,8 +52,12 @@ As long as the :file:`install_tethys.bat` and the :file:`Miniconda3-latest-Windo Path to Miniconda installer executable. Default is '.\Miniconda3-latest-Windows-x86_64.exe'. * `-n, --conda-env-name `: Name for tethys conda environment. Default is 'tethys'. - * `--python-version `: - Main python version to install tethys environment into (2 or 3). Default is 2. + * `--python-version ` (deprecated): + Main python version to install tethys environment into (2-deprecated or 3). Default is 3. + .. note:: + + Support for Python 2 is deprecated and will be dropped in Tethys version 3.0. + * `--db-username `: Username that the tethys database server will use. Default is 'tethys_default'. * `--db-password `: diff --git a/environment_py2.yml b/environment_py2.yml index 5e6204913..3139c6e54 100644 --- a/environment_py2.yml +++ b/environment_py2.yml @@ -29,7 +29,7 @@ dependencies: - owslib=0.14* - pip=9.0* - pillow=4.1* -- plotly=1.12* +- plotly - postgresql=9.5* - pycrypto=2.6* - pyopenssl=16.2* diff --git a/environment_py3.yml b/environment_py3.yml index cef0e2e63..8b2c75863 100644 --- a/environment_py3.yml +++ b/environment_py3.yml @@ -28,7 +28,7 @@ dependencies: - owslib - pip - pillow -- plotly=1.12* +- plotly - postgresql - pycrypto - pyopenssl diff --git a/readthedocs.yml b/readthedocs.yml index 979fd7aab..1462dfeec 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -1,7 +1,7 @@ conda: - file: environment_py2.yml + file: environment_py3.yml python: - version: 2 + version: 3 pip_install: true extra_requirements: - docs \ No newline at end of file diff --git a/scripts/install_tethys.bat b/scripts/install_tethys.bat index b488af7ed..156cb0bd1 100644 --- a/scripts/install_tethys.bat +++ b/scripts/install_tethys.bat @@ -13,7 +13,7 @@ SET TETHYS_DB_PORT=5436 SET CONDA_HOME= SET CONDA_EXE=Miniconda3-latest-Windows-x86_64.exe SET CONDA_ENV_NAME=tethys -SET PYTHON_VERSION=2 +SET PYTHON_VERSION=3 SET BRANCH=release SET TETHYS_SUPER_USER=admin @@ -350,7 +350,7 @@ ECHO -b, --branch [BRANCH_NAME] Branch to checkout from version con ECHO -c, --conda-home [PATH] Path to conda home directory where Miniconda will be installed. Default is %%TETHYS_HOME%%\miniconda. ECHO -C, --conda-exe [PATH] Path to Miniconda installer executable. Default is '.\Miniconda3-latest-Windows-x86_64.exe'. ECHO -n, --conda-env-name [NAME] Name for tethys conda environment. Default is 'tethys'. -ECHO --python-version [PYTHON_VERSION] Main python version to install tethys environment into (2 or 3). Default is 2. +ECHO --python-version [PYTHON_VERSION] Main python version to install tethys environment into (2-deprecated or 3). Default is 3. ECHO --db-username [USERNAME] Username that the tethys database server will use. Default is 'tethys_default'. ECHO --db-password [PASSWORD] Password that the tethys database server will use. Default is 'pass'. ECHO --db-port [PORT] Port that the tethys database server will use. Default is 5436. diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index eea1787ac..2c4c55291 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -1,5 +1,10 @@ #!/bin/bash +RED=`tput setaf 1` +GREEN=`tput setaf 2` +YELLOW=`tput setaf 3` +RESET_COLOR=`tput sgr0` + USAGE="USAGE: . install_tethys.sh [options]\n \n OPTIONS:\n @@ -9,7 +14,7 @@ OPTIONS:\n \t -b, --branch \t\t Branch to checkout from version control. Default is 'release'.\n \t -c, --conda-home \t\t Path where Miniconda will be installed, or to an existing installation of Miniconda. Default is \${TETHYS_HOME}/miniconda.\n \t -n, --conda-env-name \t\t Name for tethys conda environment. Default is 'tethys'.\n -\t --python-version \t\t Main python version to install tethys environment into (2 or 3). Default is 2.\n +\t --python-version \t Main python version to install tethys environment into (2-deprecated or 3). Default is 3.\n \t --db-username \t\t Username that the tethys database server will use. Default is 'tethys_default'.\n \t --db-password \t\t Password that the tethys database server will use. Default is 'pass'.\n \t --db-super-username \t Username for super user on the tethys database server. Default is 'tethys_super'.\n @@ -28,6 +33,7 @@ OPTIONS:\n \t \t\t e - Create Conda environment\n \t \t\t s - Create 'settings.py' file\n \t \t\t d - Setup local database server\n +\t \t\t i - Initialize database server with Tethys database and superuser\n \t \t\t a - Create activation/deactivation scripts for the Tethys Conda environment\n \t \t\t t - Create the 't' alias t\n\n @@ -85,7 +91,7 @@ TETHYS_DB_SUPER_USERNAME='tethys_super' TETHYS_DB_SUPER_PASSWORD='pass' TETHYS_DB_PORT=5436 CONDA_ENV_NAME='tethys' -PYTHON_VERSION='2' +PYTHON_VERSION='3' BRANCH='release' TETHYS_SUPER_USER='admin' @@ -192,6 +198,7 @@ case $key in CREATE_ENV= CREATE_SETTINGS= SETUP_DB= + INITIALIZE_DB= CREATE_ENV_SCRIPTS= CREATE_SHORTCUTS= @@ -210,6 +217,9 @@ case $key in if [[ "$2" = *"d"* ]]; then SETUP_DB="true" fi + if [[ "$2" = *"i"* ]]; then + INITIALIZE_DB="true" + fi if [[ "$2" = *"a"* ]]; then CREATE_ENV_SCRIPTS="true" fi @@ -264,7 +274,7 @@ done resolve_relative_path TETHYS_HOME ${TETHYS_HOME} # set CONDA_HOME relative to TETHYS_HOME if not already set -if [ -z ${CONDA_HOME} ] +if [ -z "${CONDA_HOME}" ] then CONDA_HOME="${TETHYS_HOME}/miniconda" else @@ -272,7 +282,7 @@ else fi # set TETHYS_DB_DIR relative to TETHYS_HOME if not already set -if [ -z ${TETHYS_DB_DIR} ] +if [ -z "${TETHYS_DB_DIR}" ] then TETHYS_DB_DIR="${TETHYS_HOME}/psql" else @@ -314,7 +324,8 @@ then popd fi fi - export PATH="${CONDA_HOME}/bin:$PATH" + + source "${CONDA_HOME}/etc/profile.d/conda.sh" if [ -n "${CLONE_REPO}" ] then @@ -330,11 +341,16 @@ then then # create conda env and install Tethys echo "Setting up the ${CONDA_ENV_NAME} environment..." + if [ "${PYTHON_VERSION}" == "2" ] + then + echo "${YELLOW}WARNING: Support for Python 2 is deprecated and will be removed in Tethys version 3.${RESET_COLOR}" + fi conda env create -n ${CONDA_ENV_NAME} -f "${TETHYS_HOME}/src/environment_py${PYTHON_VERSION}.yml" - source activate ${CONDA_ENV_NAME} + conda activate ${CONDA_ENV_NAME} python "${TETHYS_HOME}/src/setup.py" develop else - source activate ${CONDA_ENV_NAME} + echo "Activating the ${CONDA_ENV_NAME} environment..." + conda activate ${CONDA_ENV_NAME} fi if [ -n "${CREATE_SETTINGS}" ] @@ -351,6 +367,7 @@ then then # Setup local database export TETHYS_DB_PORT="${TETHYS_DB_PORT}" + echo ${TETHYS_DB_PORT} echo "Setting up the Tethys database..." initdb -U postgres -D "${TETHYS_DB_DIR}/data" pg_ctl -U postgres -D "${TETHYS_DB_DIR}/data" -l "${TETHYS_DB_DIR}/logfile" start -o "-p ${TETHYS_DB_PORT}" @@ -359,14 +376,19 @@ then createdb -U postgres -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_USERNAME} ${TETHYS_DB_USERNAME} -E utf-8 -T template0 psql -U postgres -p ${TETHYS_DB_PORT} --command "CREATE USER ${TETHYS_DB_SUPER_USERNAME} WITH CREATEDB NOCREATEROLE SUPERUSER PASSWORD '${TETHYS_DB_SUPER_PASSWORD}';" createdb -U postgres -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_SUPER_USERNAME} ${TETHYS_DB_SUPER_USERNAME} -E utf-8 -T template0 + fi - # Initialze Tethys database + if [ -n "${INITIALIZE_DB}" ] + then + # Initialize Tethys database tethys manage syncdb echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python "${TETHYS_HOME}/src/manage.py" shell pg_ctl -U postgres -D "${TETHYS_DB_DIR}/data" stop - . deactivate fi + echo "Deactivating the ${CONDA_ENV_NAME} environment..." + conda deactivate + if [ -n "${CREATE_ENV_SCRIPTS}" ] then # Create environment activate/deactivate scripts @@ -489,8 +511,8 @@ then ;; esac - - . ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME} + source "${CONDA_HOME}/etc/profile.d/conda.sh" + conda activate ${CONDA_ENV_NAME} pg_ctl -U postgres -D "${TETHYS_DB_DIR}/data" -l "${TETHYS_DB_DIR}/logfile" start -o "-p ${TETHYS_DB_PORT}" echo "Waiting for databases to startup..."; sleep 5 conda install -c conda-forge uwsgi -y @@ -519,7 +541,7 @@ then sudo systemctl start tethys.uwsgi.service sudo systemctl restart nginx set +x - . deactivate + conda deactivate echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" @@ -550,9 +572,10 @@ installation_warning(){ finalize_docker_install(){ sudo groupadd docker sudo gpasswd -a ${USER} docker - . ${TETHYS_CONDA_HOME}/bin/activate ${TETHYS_CONDA_ENV_NAME} + source "${CONDA_HOME}/etc/profile.d/conda.sh" + conda activate ${CONDA_ENV_NAME} sg docker -c "tethys docker init ${DOCKER_OPTIONS}" - . deactivate + conda deactivate echo "Docker installation finished!" echo "You must re-login for Docker permissions to be activated." echo "(Alternatively you can run 'newgrp docker')" diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index 31bba73d7..802c23455 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -12,6 +12,7 @@ from builtins import * # noqa: F401, F403 import os +import sys import inspect import logging import pkgutil @@ -42,6 +43,10 @@ def harvest(self): """ Harvest apps and extensions. """ + if sys.version_info.major == 2: + print(self.WARNING + 'WARNING: Support for Python 2 is deprecated ' + 'and will be dropped in Tethys version 3.0.' + self.ENDC) + self.harvest_extensions() self.harvest_apps() diff --git a/tethys_compute/job_manager.py b/tethys_compute/job_manager.py index 6e3e40b75..235cd567c 100644 --- a/tethys_compute/job_manager.py +++ b/tethys_compute/job_manager.py @@ -300,6 +300,7 @@ class CondorWorkflowTemplate(JobTemplate): """ # noqa: E501 def __init__(self, name, parameters=None, jobs=None, max_jobs=None, config='', **kwargs): parameters = parameters or dict() + jobs = jobs or list() self.node_templates = set(jobs) parameters['max_jobs'] = max_jobs parameters['config'] = config diff --git a/tethys_portal/settings_.py b/tethys_portal/settings_.py new file mode 100644 index 000000000..5dc2c17f6 --- /dev/null +++ b/tethys_portal/settings_.py @@ -0,0 +1,306 @@ +""" +Settings for Tethys Platform + +This file contains default Django and other settings for the Tethys Platform. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.9/ref/settings/ +""" + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +import os + +from django.contrib.messages import constants as message_constants + +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'mVh5qHkJQgNPSIbCmq40pUHyaUbamapzdi8YKZ7UoDqTtKG1Uv' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +# List those who should be notified of an error when DEBUG = False as a tuple of (name, email address). +# i.e.: ADMINS = (('John', 'john@example.com'), ('Mary', 'mary@example.com')) +ADMINS = () + +# Force user logout once the browser has been closed. +# If changed, delete all django_session table entries from the tethys_default database to ensure updated behavior +SESSION_EXPIRE_AT_BROWSER_CLOSE = True + +# Warn user of forced logout after indicated number of seconds +SESSION_SECURITY_WARN_AFTER = 840 + +# Force user logout after a certain number of seconds +SESSION_SECURITY_EXPIRE_AFTER = 100000 + +# See https://docs.djangoproject.com/en/1.8/topics/logging/#configuring-logging for more logging configuration options. +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '%(levelname)s:%(name)s:%(message)s' + }, + 'simple': { + 'format': '%(levelname)s %(message)s' + }, + }, + 'handlers': { + 'console_simple': { + 'class': 'logging.StreamHandler', + 'formatter': 'simple' + }, + 'console_verbose': { + 'class': 'logging.StreamHandler', + 'formatter': 'verbose' + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console_simple'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'WARNING'), + }, + 'tethys': { + 'handlers': ['console_verbose'], + 'level': 'INFO', + } + }, +} + +# Application definition + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django_gravatar', + 'bootstrap3', + 'termsandconditions', + 'tethys_config', + 'tethys_apps', + 'tethys_gizmos', + 'tethys_services', + 'tethys_compute', + 'social_django', + 'guardian', + 'session_security', + 'captcha', + 'rest_framework', + 'rest_framework.authtoken', + 'datetimewidget', + 'django_select2', +) + +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'tethys_portal.middleware.TethysSocialAuthExceptionMiddleware', + 'session_security.middleware.SessionSecurityMiddleware', +) + +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.TokenAuthentication', + ) +} + +AUTHENTICATION_BACKENDS = ( +# 'tethys_services.backends.hydroshare.HydroShareOAuth2', +# 'social_core.backends.linkedin.LinkedinOAuth2', +# 'social_core.backends.google.GoogleOAuth2', +# 'social_core.backends.facebook.FacebookOAuth2', + 'django.contrib.auth.backends.ModelBackend', + 'guardian.backends.ObjectPermissionBackend', +) + +# Password validation +# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators + +# AUTH_PASSWORD_VALIDATORS = [ +# { +# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', +# }, +# { +# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', +# }, +# { +# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', +# }, +# { +# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', +# }, +# ] + +# Terms and conditions settings +ACCEPT_TERMS_PATH = '/terms/accept/' +TERMS_EXCLUDE_URL_PREFIX_LIST = {'/admin/', '/oauth2/', '/handoff/', '/accounts/', '/terms/'} +TERMS_EXCLUDE_URL_LIST = {'/'} +TERMS_BASE_TEMPLATE = 'page.html' + +ROOT_URLCONF = 'tethys_portal.urls' + +WSGI_APPLICATION = 'tethys_portal.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/1.9/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'tethys_default', + 'USER': 'tethys_default', + 'PASSWORD': 'pass', + 'HOST': '127.0.0.1', + 'PORT': '54323' + } +} + +# Internationalization +# https://docs.djangoproject.com/en/1.9/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Templates +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(BASE_DIR, 'templates'), + ], + 'OPTIONS': { + 'context_processors': [ + 'django.contrib.auth.context_processors.auth', + 'django.template.context_processors.debug', + 'django.template.context_processors.i18n', + 'django.template.context_processors.media', + 'django.template.context_processors.static', + 'django.template.context_processors.tz', + 'django.template.context_processors.request', + 'django.contrib.messages.context_processors.messages', + 'social_django.context_processors.backends', + 'social_django.context_processors.login_redirect', + 'tethys_config.context_processors.tethys_global_settings_context', + 'tethys_apps.context_processors.tethys_apps_context', + 'tethys_gizmos.context_processors.tethys_gizmos_context' + ], + 'loaders': [ + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + 'tethys_apps.template_loaders.TethysTemplateLoader' + ], + 'debug': DEBUG + } + } +] + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.9/howto/static-files/ + +STATIC_URL = '/static/' + +STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'), ) + +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + 'tethys_apps.static_finders.TethysStaticFinder' +) + +# Uncomment the next line for production installation +#STATIC_ROOT = '/Volumes/SDC-DRIVE/Workspace/tethys/static' + +# Tethys Workspaces +#TETHYS_WORKSPACES_ROOT = '/Volumes/SDC-DRIVE/Workspace/tethys/workspaces' + +# Messaging settings +MESSAGE_TAGS = {message_constants.DEBUG: 'alert-danger', + message_constants.INFO: 'alert-info', + message_constants.SUCCESS: 'alert-success', + message_constants.WARNING: 'alert-warning', + message_constants.ERROR: 'alert-danger'} + +# Gravatar Settings +GRAVATAR_URL = 'http://www.gravatar.com/' +GRAVATAR_SECURE_URL = 'https://secure.gravatar.com/' +GRAVATAR_DEFAULT_SIZE = '80' +GRAVATAR_DEFAULT_IMAGE = 'retro' +GRAVATAR_DEFAULT_RATING = 'g' +GRAVATAR_DFFAULT_SECURE = True + + + +# Use this setting to bypass the home page +BYPASS_TETHYS_HOME_PAGE = False + +# Use this setting to disable open account signup +ENABLE_OPEN_SIGNUP = False + +# Uncomment the following lines and adjust to match your setup to enable emailing capabilities +#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +#EMAIL_HOST = 'localhost' +#EMAIL_PORT = 25 +#EMAIL_HOST_USER = '' +#EMAIL_HOST_PASSWORD = '' +#EMAIL_USE_TLS = False +#DEFAULT_FROM_EMAIL = 'Example ' + + +# OAuth Settings +# http://psa.matiasaguirre.net/docs/configuration/index.html +SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS = ['username', 'first_name', 'email'] +SOCIAL_AUTH_SLUGIFY_USERNAMES = True +SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/apps/' +SOCIAL_AUTH_LOGIN_ERROR_URL = '/accounts/login/' + +# OAuth Providers +## Google +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '' +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '' + +## Facebook +SOCIAL_AUTH_FACEBOOK_KEY = '' +SOCIAL_AUTH_FACEBOOK_SECRET = '' +SOCIAL_AUTH_FACEBOOK_SCOPE = ['email'] + +## LinkedIn +SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY = '' +SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET = '' + +## HydroShare +SOCIAL_AUTH_HYDROSHARE_KEY = '' +SOCIAL_AUTH_HYDROSHARE_SECRET = '' + +# Django Guardian Settings +ANONYMOUS_USER_ID = -1 +# GUARDIAN_RAISE_403 = False # Mutually exclusive with GUARDIAN_RENDER_403 +# GUARDIAN_RENDER_403 = False # Mutually exclusive with GUARDIAN_RAISE_403 +# GUARDIAN_TEMPLATE_403 = '' +# ANONYMOUS_DEFAULT_USERNAME_VALUE = 'anonymous' From 7527c4585634d9aa1455e55f65c2add5217c91b3 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Mon, 3 Dec 2018 08:47:29 -0600 Subject: [PATCH 191/215] remove erroneously committed settings file --- tethys_portal/settings_.py | 306 ------------------------------------- 1 file changed, 306 deletions(-) delete mode 100644 tethys_portal/settings_.py diff --git a/tethys_portal/settings_.py b/tethys_portal/settings_.py deleted file mode 100644 index 5dc2c17f6..000000000 --- a/tethys_portal/settings_.py +++ /dev/null @@ -1,306 +0,0 @@ -""" -Settings for Tethys Platform - -This file contains default Django and other settings for the Tethys Platform. - -For more information on this file, see -https://docs.djangoproject.com/en/1.9/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.9/ref/settings/ -""" - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -import os - -from django.contrib.messages import constants as message_constants - -BASE_DIR = os.path.dirname(os.path.dirname(__file__)) - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'mVh5qHkJQgNPSIbCmq40pUHyaUbamapzdi8YKZ7UoDqTtKG1Uv' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - -# List those who should be notified of an error when DEBUG = False as a tuple of (name, email address). -# i.e.: ADMINS = (('John', 'john@example.com'), ('Mary', 'mary@example.com')) -ADMINS = () - -# Force user logout once the browser has been closed. -# If changed, delete all django_session table entries from the tethys_default database to ensure updated behavior -SESSION_EXPIRE_AT_BROWSER_CLOSE = True - -# Warn user of forced logout after indicated number of seconds -SESSION_SECURITY_WARN_AFTER = 840 - -# Force user logout after a certain number of seconds -SESSION_SECURITY_EXPIRE_AFTER = 100000 - -# See https://docs.djangoproject.com/en/1.8/topics/logging/#configuring-logging for more logging configuration options. -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s:%(name)s:%(message)s' - }, - 'simple': { - 'format': '%(levelname)s %(message)s' - }, - }, - 'handlers': { - 'console_simple': { - 'class': 'logging.StreamHandler', - 'formatter': 'simple' - }, - 'console_verbose': { - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' - }, - }, - 'loggers': { - 'django': { - 'handlers': ['console_simple'], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'WARNING'), - }, - 'tethys': { - 'handlers': ['console_verbose'], - 'level': 'INFO', - } - }, -} - -# Application definition - -INSTALLED_APPS = ( - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django_gravatar', - 'bootstrap3', - 'termsandconditions', - 'tethys_config', - 'tethys_apps', - 'tethys_gizmos', - 'tethys_services', - 'tethys_compute', - 'social_django', - 'guardian', - 'session_security', - 'captcha', - 'rest_framework', - 'rest_framework.authtoken', - 'datetimewidget', - 'django_select2', -) - -MIDDLEWARE_CLASSES = ( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'tethys_portal.middleware.TethysSocialAuthExceptionMiddleware', - 'session_security.middleware.SessionSecurityMiddleware', -) - -REST_FRAMEWORK = { - 'DEFAULT_PERMISSION_CLASSES': ( - 'rest_framework.permissions.IsAuthenticated', - ), - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.TokenAuthentication', - ) -} - -AUTHENTICATION_BACKENDS = ( -# 'tethys_services.backends.hydroshare.HydroShareOAuth2', -# 'social_core.backends.linkedin.LinkedinOAuth2', -# 'social_core.backends.google.GoogleOAuth2', -# 'social_core.backends.facebook.FacebookOAuth2', - 'django.contrib.auth.backends.ModelBackend', - 'guardian.backends.ObjectPermissionBackend', -) - -# Password validation -# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators - -# AUTH_PASSWORD_VALIDATORS = [ -# { -# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', -# }, -# { -# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', -# }, -# { -# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', -# }, -# { -# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', -# }, -# ] - -# Terms and conditions settings -ACCEPT_TERMS_PATH = '/terms/accept/' -TERMS_EXCLUDE_URL_PREFIX_LIST = {'/admin/', '/oauth2/', '/handoff/', '/accounts/', '/terms/'} -TERMS_EXCLUDE_URL_LIST = {'/'} -TERMS_BASE_TEMPLATE = 'page.html' - -ROOT_URLCONF = 'tethys_portal.urls' - -WSGI_APPLICATION = 'tethys_portal.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/1.9/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'tethys_default', - 'USER': 'tethys_default', - 'PASSWORD': 'pass', - 'HOST': '127.0.0.1', - 'PORT': '54323' - } -} - -# Internationalization -# https://docs.djangoproject.com/en/1.9/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - -# Templates -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - os.path.join(BASE_DIR, 'templates'), - ], - 'OPTIONS': { - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.debug', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.template.context_processors.request', - 'django.contrib.messages.context_processors.messages', - 'social_django.context_processors.backends', - 'social_django.context_processors.login_redirect', - 'tethys_config.context_processors.tethys_global_settings_context', - 'tethys_apps.context_processors.tethys_apps_context', - 'tethys_gizmos.context_processors.tethys_gizmos_context' - ], - 'loaders': [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - 'tethys_apps.template_loaders.TethysTemplateLoader' - ], - 'debug': DEBUG - } - } -] - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.9/howto/static-files/ - -STATIC_URL = '/static/' - -STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'), ) - -STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', - 'tethys_apps.static_finders.TethysStaticFinder' -) - -# Uncomment the next line for production installation -#STATIC_ROOT = '/Volumes/SDC-DRIVE/Workspace/tethys/static' - -# Tethys Workspaces -#TETHYS_WORKSPACES_ROOT = '/Volumes/SDC-DRIVE/Workspace/tethys/workspaces' - -# Messaging settings -MESSAGE_TAGS = {message_constants.DEBUG: 'alert-danger', - message_constants.INFO: 'alert-info', - message_constants.SUCCESS: 'alert-success', - message_constants.WARNING: 'alert-warning', - message_constants.ERROR: 'alert-danger'} - -# Gravatar Settings -GRAVATAR_URL = 'http://www.gravatar.com/' -GRAVATAR_SECURE_URL = 'https://secure.gravatar.com/' -GRAVATAR_DEFAULT_SIZE = '80' -GRAVATAR_DEFAULT_IMAGE = 'retro' -GRAVATAR_DEFAULT_RATING = 'g' -GRAVATAR_DFFAULT_SECURE = True - - - -# Use this setting to bypass the home page -BYPASS_TETHYS_HOME_PAGE = False - -# Use this setting to disable open account signup -ENABLE_OPEN_SIGNUP = False - -# Uncomment the following lines and adjust to match your setup to enable emailing capabilities -#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -#EMAIL_HOST = 'localhost' -#EMAIL_PORT = 25 -#EMAIL_HOST_USER = '' -#EMAIL_HOST_PASSWORD = '' -#EMAIL_USE_TLS = False -#DEFAULT_FROM_EMAIL = 'Example ' - - -# OAuth Settings -# http://psa.matiasaguirre.net/docs/configuration/index.html -SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS = ['username', 'first_name', 'email'] -SOCIAL_AUTH_SLUGIFY_USERNAMES = True -SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/apps/' -SOCIAL_AUTH_LOGIN_ERROR_URL = '/accounts/login/' - -# OAuth Providers -## Google -SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '' -SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '' - -## Facebook -SOCIAL_AUTH_FACEBOOK_KEY = '' -SOCIAL_AUTH_FACEBOOK_SECRET = '' -SOCIAL_AUTH_FACEBOOK_SCOPE = ['email'] - -## LinkedIn -SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY = '' -SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET = '' - -## HydroShare -SOCIAL_AUTH_HYDROSHARE_KEY = '' -SOCIAL_AUTH_HYDROSHARE_SECRET = '' - -# Django Guardian Settings -ANONYMOUS_USER_ID = -1 -# GUARDIAN_RAISE_403 = False # Mutually exclusive with GUARDIAN_RENDER_403 -# GUARDIAN_RENDER_403 = False # Mutually exclusive with GUARDIAN_RAISE_403 -# GUARDIAN_TEMPLATE_403 = '' -# ANONYMOUS_DEFAULT_USERNAME_VALUE = 'anonymous' From 5771be3ad752aa379c4815c27bc6469e1706efd2 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Mon, 3 Dec 2018 15:29:27 -0600 Subject: [PATCH 192/215] update travis build for new install script options --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3da3588a3..aeb94c97f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,7 @@ install: - cd .. - mv tethys src - bash ./src/scripts/install_tethys.sh -h - - bash ./src/scripts/install_tethys.sh --partial-tethys-install mesdat -t $PWD --python-version $PYTHON_VERSION + - bash ./src/scripts/install_tethys.sh --partial-tethys-install mesdiat -t $PWD --python-version $PYTHON_VERSION # activate conda environment - export PATH="$PWD/miniconda/bin:$PATH" From f680259dc622eb4d892e4f0560b95f2c774a0a31 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Mon, 3 Dec 2018 16:24:12 -0600 Subject: [PATCH 193/215] add postgis as a dependency --- environment_py2.yml | 5 +++-- environment_py3.yml | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/environment_py2.yml b/environment_py2.yml index 3139c6e54..7156c7c5a 100644 --- a/environment_py2.yml +++ b/environment_py2.yml @@ -28,9 +28,10 @@ dependencies: - geoalchemy2=0.4* - owslib=0.14* - pip=9.0* -- pillow=4.1* +- pillow - plotly -- postgresql=9.5* +- postgresql +- postgis - pycrypto=2.6* - pyopenssl=16.2* - mock diff --git a/environment_py3.yml b/environment_py3.yml index 8b2c75863..319851d7d 100644 --- a/environment_py3.yml +++ b/environment_py3.yml @@ -30,6 +30,7 @@ dependencies: - pillow - plotly - postgresql +- postgis - pycrypto - pyopenssl - mock From 44df4e9c8be68ca4a73073234e7c92ac313fce24 Mon Sep 17 00:00:00 2001 From: Nathan Swain Date: Thu, 6 Dec 2018 15:12:43 -0700 Subject: [PATCH 194/215] Mimic Python 2 behavior (show help text) in Python 3 when "tethys" command called with no arguments. (#359) Python 3 was throwing and error. --- .../test_tethys_apps/test_cli/test__init__.py | 44 +++++++++++++++++++ tethys_apps/cli/__init__.py | 6 ++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test__init__.py b/tests/unit_tests/test_tethys_apps/test_cli/test__init__.py index 2a1ff640b..9cf95c3fd 100644 --- a/tests/unit_tests/test_tethys_apps/test_cli/test__init__.py +++ b/tests/unit_tests/test_tethys_apps/test_cli/test__init__.py @@ -17,6 +17,50 @@ def setUp(self): def tearDown(self): pass + def assert_returns_help(self, stdout): + self.assertIn('usage: tethys', stdout) + self.assertIn('scaffold', stdout) + self.assertIn('gen', stdout) + self.assertIn('manage', stdout) + self.assertIn('schedulers', stdout) + self.assertIn('services', stdout) + self.assertIn('app_settings', stdout) + self.assertIn('link', stdout) + self.assertIn('test', stdout) + self.assertIn('uninstall', stdout) + self.assertIn('list', stdout) + self.assertIn('syncstores', stdout) + self.assertIn('docker', stdout) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stderr', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + def test_tethys_with_no_subcommand(self, mock_exit, mock_stderr, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + if mock_stdout.getvalue(): + # Python 3 + self.assert_returns_help(mock_stdout.getvalue()) + else: + # Python 2 + self.assert_returns_help(mock_stderr.getvalue()) + + @mock.patch('sys.stdout', new_callable=StringIO) + @mock.patch('tethys_apps.cli.argparse._sys.exit') + def test_tethys_help(self, mock_exit, mock_stdout): + mock_exit.side_effect = SystemExit + testargs = ['tethys', '-h'] + + with mock.patch.object(sys, 'argv', testargs): + self.assertRaises(SystemExit, tethys_command) + + mock_exit.assert_called_with(0) + self.assert_returns_help(mock_stdout.getvalue()) + @mock.patch('tethys_apps.cli.scaffold_command') def test_scaffold_subcommand(self, mock_scaffold_command): testargs = ['tethys', 'scaffold', 'foo'] diff --git a/tethys_apps/cli/__init__.py b/tethys_apps/cli/__init__.py index d2260a189..ce4e7c990 100644 --- a/tethys_apps/cli/__init__.py +++ b/tethys_apps/cli/__init__.py @@ -308,4 +308,8 @@ def tethys_command(): # Parse the args and call the default function args = parser.parse_args() - args.func(args) + try: + args.func(args) + except AttributeError: + parser.print_help() + exit(2) From f9ad49d3c6644701311957d18c1e7a765ee6614c Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 13 Dec 2018 09:17:01 -0700 Subject: [PATCH 195/215] Fix for #277 - GeoServer port is now correct for tethys docker ip command. --- tethys_apps/cli/docker_commands.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tethys_apps/cli/docker_commands.py b/tethys_apps/cli/docker_commands.py index c67dc38fd..18821cbb5 100644 --- a/tethys_apps/cli/docker_commands.py +++ b/tethys_apps/cli/docker_commands.py @@ -1160,6 +1160,9 @@ def docker_ip(): p.write('\nPostGIS/Database:') p.write(' Host: {0}'.format(docker_host)) p.write(' Port: {0}'.format(postgis_port)) + p.write(' Endpoint: postgresql://:@{}:{}/'.format( + docker_host, postgis_port + )) else: with pretty_output(FG_WHITE) as p: @@ -1173,12 +1176,18 @@ def docker_ip(): try: if container_status[GEOSERVER_CONTAINER]: geoserver_container = containers[GEOSERVER_CONTAINER] - geoserver_port = geoserver_container['Ports'][0]['PublicPort'] + + node_ports = [] + for port in geoserver_container['Ports']: + if port['PublicPort'] != 8181: + node_ports.append(str(port['PublicPort'])) + with pretty_output(FG_WHITE) as p: p.write('\nGeoServer:') - p.write(' Host: {0}'.format(docker_host)) - p.write(' Port: {0}'.format(geoserver_port)) - p.write(' Endpoint: http://{0}:{1}/geoserver/rest'.format(docker_host, geoserver_port)) + p.write(' Host: {}'.format(docker_host)) + p.write(' Primary Port: 8181') + p.write(' Node Ports: {}'.format(', '.join(node_ports))) + p.write(' Endpoint: http://{}:8181/geoserver/rest'.format(docker_host)) else: with pretty_output(FG_WHITE) as p: @@ -1195,9 +1204,9 @@ def docker_ip(): n52wps_port = n52wps_container['Ports'][0]['PublicPort'] with pretty_output(FG_WHITE) as p: p.write('\n52 North WPS:') - p.write(' Host: {0}'.format(docker_host)) - p.write(' Port: {0}'.format(n52wps_port)) - p.write(' Endpoint: http://{0}:{1}/wps/WebProcessingService\n'.format(docker_host, n52wps_port)) + p.write(' Host: {}'.format(docker_host)) + p.write(' Port: {}'.format(n52wps_port)) + p.write(' Endpoint: http://{}:{}/wps/WebProcessingService\n'.format(docker_host, n52wps_port)) else: with pretty_output(FG_WHITE) as p: From c0cc6d8dd9392dbc9d13516e48813bffd42039b3 Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 13 Dec 2018 10:57:31 -0700 Subject: [PATCH 196/215] Updated Tethys Portal admin pages. --- docs/images/site_admin/app_settings_top.png | Bin 0 -> 76009 bytes docs/images/site_admin/auth_token.png | Bin 0 -> 77039 bytes docs/images/site_admin/custom_settings.png | Bin 0 -> 111345 bytes docs/images/site_admin/home.png | Bin 285383 -> 99731 bytes docs/images/site_admin/service_settings.png | Bin 0 -> 124710 bytes .../tethys_compute/tethys_compute_admin.png | Bin 140299 -> 69251 bytes docs/tethys_portal/admin_pages.rst | 138 +++++++++++++++--- docs/tethys_portal/customize.rst | 5 - .../tethys_compute_admin_pages.rst | 3 +- 9 files changed, 117 insertions(+), 29 deletions(-) create mode 100644 docs/images/site_admin/app_settings_top.png create mode 100644 docs/images/site_admin/auth_token.png create mode 100644 docs/images/site_admin/custom_settings.png create mode 100644 docs/images/site_admin/service_settings.png diff --git a/docs/images/site_admin/app_settings_top.png b/docs/images/site_admin/app_settings_top.png new file mode 100644 index 0000000000000000000000000000000000000000..bd9b860ccbc6910c26ccebf233c074e9a4a2bf2b GIT binary patch literal 76009 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

{Z1Ji?Frsh1{Z{0D^aJyVYK!seRV| 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±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

)Z$xPQ#} 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 RW*8mJvoyCd!(P4@_aB3LA}0U< literal 0 HcmV?d00001 diff --git a/docs/images/site_admin/custom_settings.png b/docs/images/site_admin/custom_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..29279883e344dfd2f54150153f5c3d8274161c12 GIT binary patch literal 111345 zcmY&<1ymf((l$YZyITnE?!nz165QPv*CaSWg1bX-cPF?Lg1fsezQDr2$$Q`L-ha;7 zIcIxns(YrYtE--VCPGzN1{H}22?`1dRZdn?9SRCI2MP+N;VnF*1z9ZR&jnb3xSZx& z$l>?aJRI_$&`s*2n}(C6o2QAZ1(cPelY<2_z|7Uc!VzHY6&0Ogz*oq$D%ti^i~sGr-$Jxfdk5%sdCi;0%*ePiTdJ|UcaWFc;A#Kw@PaY6 zu@3GsjkJp4ocXg_4C-c;|JzLi3-f377n%PygwGc`UO@d%2YU+05Q+aj21Y)vcgh-l zE?s4U|M5yS`OZ*uv0ihP+sS%XZf>r2sm|MJw}F8HlBv=6C;ct~A3M_}!q6H$W&a&c zfQ4oPjgtB}>DS@$acdU~&kcWcPRnuYkeXl&hpjQz?%nQJa7kz1zdZsOO_1K&cA^e# z_+2xbnwjcRvr$N{wCpA8$r2>?1fjpE_=dgz&v>_W#IZ?9!3{Y-KM$DNcu;$;defQ> zML7)o(M?T71^YSCpZ<&_;y=2>$FkNpb=a>`k}{lDcbW6wJu_88v2S>hj!rhMYz$kaCaSVjRLDc`VvrMlITN;FFnud%;s zGQdfx8F<{jva#etXnBvFrBMiF1Hr=8C+W0B1D$kwIuDJmJ`VjE@&xIfrTFk z+4d0g_Ai+$;nF>|MKvNBHxaYNbBz5h>W_hA&XNNvzUY0E3d)-|Z=O5Otn^vnv?`yKiD!$| zfrmBgQAb$euIF6mKF2dkPhrU^YO36`*c6fp+_!DjK4*CDhcYfheEPO9x&v=Y zoJ-vfSBu>E62!x{Z>~68&yc2BwN>A(^?g*A z|KoR$P2;`Yf~E>5j~q5>_upH>I=fFP$})sz0-yxKwKUe)-MO;rvTNcPquezk2H|i# zoRzdE2LXo0P`xS?Gp}l6XcQu-NCg9X`Q}T|By@6d_SS%n5aP{3<4)K|#v{ArItku% zezW(>dEh%slZ#%s)3@1!opKXLuhss~Wv&{!ZbTXLip8S-z6cT;RZo#8Q>Ayb%`3OL z+SvLn$TO`wC2Jl~tF|JOaJM~J0>Rsx0m0-(U*KsYw{42;7GzfwiCaf&BVy-CoWkx_ z^bkTPTBFOE#=HPhcK6-PCwN_QcXEFs#J0K_*W*mN4!XR8!bw)1riM*vhT(K4UvgZ; zgl+kqp7F%ve5KxovB83Yr$f+f$iqoXs~yyNyfjrd1K()s67zTRn4GPK3AGESQd_

NwV;Q?rY=Rur23`uP3BE3G-;H8BLTsvmFHbS`KO^%balEq z0UK*nlN0CfIbA88Y4b!=da^4m@ytibJdWL!a|28(fnv0EZNG`aMUjd)o1n+Ra49KYYlF%`5N6JS_*UoI6 zQnZ2Vpt8UyX+lmvOL=m0ZKv-EH_%D*Ty!`Z+G_`|Ne^?pNGVM_?4nk9LhlZ9s$~#N zv8oCKTS`$)Z713?R=SaaZ{O9uiaxp{i_e|cdXYD%b*j9A)v|rA;yf;LKI~GUdk;&H zU40+gdk`(kw_B*<%W~9b!FOzKS3^%33mH3tw8^TL5>=pYh7wm*U%z>%xM-WCakJ3= zaL4fhO^_@s%j%BQ-0A1hX!Si_=6;$#0%P~x;@Gd1DfTLWXyaqqEvatTfrduw8rk~C zlz{?lUk-*r{j*eQ1Hsmt;r{HKv-l}J;ySOZ$93ToBj8P?U$)_f8s`pq;5WgYW5Mi! z(dJjVo$wZsJ7yhfIc2`2i zrt>}7E;w50-uI@m1OU$Z1h>kSc{mf3ftkSFYqwvvjfc07S7B(K>z~Co;E{W>Q}o zZwVHLLS`I`$IoqVUX=|n9558RyZo0c9RGy-a3jG+U8$j%pL|(&qZ2*E3ThwRjtNe) zVl-abb^UKYF-hId?|8HmRj>-HhOBs?eK??1SjAaLv1+EimYsUrV2jI2GO-Udk+|fG zoYFaU#Ud{;Gf8^1y*dX4PfdkXT59Q1Fy0-Q&C>hTKfw|;E(?N@*a*4kJ~xhs=vEFE z(0Sp6yc1#m=5fGTCuN zt1wwz_nWk+6Edi`LQXy9N2igpsr~ zW@>MKIJ+Lbt*KxodsXz2kzTCJnmoVvuJJcJ&`Hj#tnRW{*Q<#^;{mM_fsB5)@bmRt zbGoLHURMhBE!Cg3V4BBGfSE;S=2c^62beo^!eE8Rt!XrXQqrbLQeNGq%y+psCd!{P z9ywt z!x4J(WZ)ZUD~ww)n`^`hq?_)%mI8Z6)8k5@Nz}lCfOOE^nu!`IRoU{EjvvW-EJ4Ll zROGf znIR2Ha+frGW%|~^DL?XX%)TFRa#lcp&SUu0qnL_V!GC1`vQH_zs{Zz{{!#HWPCN}&Ai}jR~r#w!3a`i4u=WDFr_eLFN z@o*%+k^{QSssm{eiC<;N+FjnoS!VHeooxVL0(s+(DlqjX76jVJYiWpAUcq=q=OujJ z#B(|kPh{7}?oR?6vaj^b>~>$9Uq2!(zRY!dnMr`bc9R4;{mxRf+{xo$`^QIQyfc0n zSPVfMhJ``5A3stFVVx+=v9BHSg3jSUxrO|h;4^-xR}YBB+jeRy-Be8|{dZ~JlX%j4&(>tk(~imt~GERf8p zBH2$}KGHcy=$m{cJe)pgsfC*xOP_(;F{w@3e@-h6GWmFNwj2-D{Eg5l83m4+hylu2+j2pe4q zO$Om`+(eT~<*kzOaw0|W(PVs=B_{Gl0RHuu3Olu;ikmn|TP1?**q6ibtES(vn^CFa zQMbP9FJ1_3g}z?z*>Ni1aE>I1$(z(-G3lIrMBZ7lc*l7wR8T2)Cy2v((+Cw6gd2}B|GhR59-Wob35}4C0(SH4P zs#M$NxrQ6xpcgzIeqEbby$&qEWdArK{wZjB-@@xquVC37lo;ii% zvi%DQe&nRbXKE)_YUUv%i3zSdq0Nj0Y~uav+b$^h=#2hF4DMp|Q{#(Rk!=x&_x{iw zRv;M^b5+fEM7Y*B@VOrZD{g6XCEf`%ejVED*>lbZa;grE&e;A?$&t3Pp%;0s`Ek`s z9`Ld60Vt=KBV1YgQ^$7Hz1ro|k*n!6&}u9MfF+!mf5$zfdRg08iFs_ilbK=kn%G zoGrxI18;U3zeu+~NhS-;NnNg3LQG-Y={04jiZ{OP8QD<8p4xtq%kd>g260AY1ev%a zB4)AWi{!_ygRIy)lROw21s0^WlOys>{<@@Ukw?sLa}o5mT=kBv(ND-Rm(JeX(xvS2 zNv^KLfEd%B@Hok$D$0WSGHxR?!+J-fgOfwxij3DOuO%AmYx;r)@;g497@S-Tb*I<# z4})5!4}5wG&2~=a#~0_h53PZ<3{$b^o%x9T&1nN;GxWfR0ufL6q8ZUaee5sp8&me< zAHrl#!qzPqouo?dn)7*QrccAbr#ABP23|N9jhiQHovw34&){bQK@W3_8?&Jpb8x6q zg?{6p%+d1lmDOFyqrd3i>SAFaXhK_F^mDCi+UjDh84;73z8~J@ddQ?D^X+-6Ca7Ka z>@nAcGv~ORwR>F3sxzakxl&N|c$0id>iMk9BRpf-M;FiX(!910-eAK9_{G%efw>=ie%D$`JEx-9)fqS3tvvV?MWBp7-97Nwh-iI}M67Kr_MKwuabclxUh@LE+ z(BPBhHyiq-_TdLVbS{o3O`q>Mp1R>J=h{_g7ZY6Wh-xnTC*&jJ!ZJBml^U}?fzV2W zx&w0p*ffDb_sE?@zG*8Nm5%+STb2`TaJd|BxC8vu_n1m(?0VfVrVr^+I@9BoVVtd_ za)0M)XTpB`?Q;0_vN@}wS-2Bye?9M_<>$mAz9f<4J}^6-(GLwI%q>czRQ&?jcJYF(uME-e9EAXmx z`=kHu86)slcLB84v7WZ(=#E5HVRa=2PRLN0{z!Zf0>Nf6zfrCHKsJ_2Bcu+Y$zd_R zJF0hRr6Yo&bG&R3{Zemn@L*j0rJKs5$A0#zx-m`hoLt-s0fwkCvIC2@orC$=x#s&* zb+m234qTe~Qs!>pvy5e+KKOgo6lt+W-ocr91%#Z!5&hNxuw2%SN=x7*ZIaQk_!Ilxi(o2gaoD)qZwJdq zYDq2B(3W7jhY#NCeOL;^0$(sA6tvOgfLjJ($Y}tJ$jG7@rSq85F$Txh!@wF#E=rC&_*UR|`njSTL zSKpb7B4j0l+(?ouPJq;;8BA`pp-oxE28g~sIne-JTOkiOi&vDw7{AfuSW$O6cqtFX zxI5#xl6c~H_rUaYZ{CZEprC=>vcvlN&h=g!;&fwe4POPYX!qQQ#<#OT^$a=gyW|#Y zzB!ja$q*!EW@gNHF0&Oglh}1-l_o+~+OX|3ZO4Vn%E`H(iYe_ECsqS?Fdl=`*iHLN z7xF|ZBx1#HiHql}SgppwK6LEZnTAI1x7mdOrW9scK*i`@TN61W_x$_ZJRbM%0g?vb z`?+p`!M-5^o#>xu9y`u_HG-Kq9!Rj0+NI_4g5C(S{7WHX1vDn(k0CcVM#BtD0~!vi zqhpC#Mg8lDuV|Jy<}RRoP&qLoXh zoG%{r9IrEKCs@(Z$Py$P1Rt$Cu*88+?I@&1ntu7E%TPLW{Thd}pj@P!GfcgfsnX?U zJ@{L}wpmF;o*CA|Lt`b?fr{n$mrnC^tOq47QP>^w7koq;y-)S7)Z`+a@kwSc{WJGe zvqE}wwj6*9!WcqQX#kzce)9U>WLRcV$KA4gW<-6fr?QB+J6=5gbAl7G#yp8~D(2k} ztF3nxTasGb+=JDh*k?=n5GNxKh-QEJ=F&fhr#-w9;36_4wfV(zaO;{u8QbV;DS6rH zoC3K5{IHb6>RsVA0hS`%duyA)!oi&Eye|9pJVc&^ zbV!bsehC*pPypSV~JyTX7;+*}NUa(l3QQp$pspTo|_z^p6q(U!--a;q=*E~_;!24t`Ile*P4(BJEmQ#RJ*<7Og? z=u)2RCzlUq!)q=5+;2Op1fasx2m8=tB$~l?FV|aduCi#VUe=6ST4SzmW4f=I!kU?O zE|V?%gzi{79>UsN1>MP~te%LjF9jZd^?*zyj!y%Fz~v{Y5U>j?Xd6sXL5CE1%Z2pQ zJ+}IZ>q&>1hO&c}UC4Jj&`YO{d}Sbc){jIp&;e&a%wv z+5@l_Vn_!bQ8Rwoy6X#i;6N#)1YXqJXPHeys|bU|D-XIpJrF|iPIgqzv;PJ9r#T|? zH=X(TT;7LWUNREtlj$T$7i>KM?N2>q8iTbAZVmiz=TPU}?<$IZK1OCNrFzv2&Pje`&=19a34L5hWR zSCeDB05ZZ+Q5pEaYp(uwas$qknQ-VZMmITxYJ`W z)nICl$Cs-%rJ*$;EI+@^i*tp0|>UDI-j(VDSZ7l4hOD zY_oCdH8EMp?TcDUbF-?=KM)XMkSVuJiu>rI_0QGoBu(9mQo@DJNVO+)JaY)3M3uCB zFZ-3N6*M!~plAJ9=XO*#+OIKZK7%e&Ayzy$*M^+Us?>T8R)_(xxV?+)+wYVpl7}l1 ztCUu=QKffGtTV-t3Nbz0tlZdxO38v6+oEYK@(N;9A%j{z|x++m8lDoeU0|onb};rUXx2H8T(1Io%VjZ*<08IDrJ6 zR{3JeyA}7JY?q3!JSspWg7Jo}EyaX5I_-ISS{N|Ny32Jj* zCl@&^`NWuQigoK+$%7CQMDJToq=;CMo_%e{70vwppZU)EJ9pdxx=jwb);HYAGJnX&WaXK zi%Q%AN`U^%N@=P6jb<{nj$oQ(7NbvuQ;`?H~LJr+9e zSNdh?xbY?=VnmL3Cuc%aBFZ5KW6Eo`U@Ba_#~W0lRQ62vGjJN5U*{<$n~su;+z+EB zuNk&^zm*rTzAOD=7{Iql_5M8$HeQUmt|)#;DL-Seq6A1u9Y^Jl=81Yj@*>K>3w1oI z6GETB_}I0=<0B(YK9f_6o&0FYsEFP|fVw|!K4Sf+u)Q8-k6lUjisd$8Aw3m=YM=^woErBA16SR4qLZd3(%q4XDnm&HJ-GNmvpD@ofM;=?95?_&$hLpp4JDKfPo02R}Xs^fnpS->nRjg@z*0Q4F7#}&`6C9yQF(c&p zVZa@|R4RbRKknv1w)rF!U9sW)`C`G!bq&=MP(L|Z#e3(Cjc+26Dj+N4A^qmy#_~}z zwMVz7Rp6?o>!~I|svO{b_xO}%XV8C7s4ReSFh9TOI=7Z^dRA~_9Tpq20=D;`Nk93wx;jJKExZobF&KJl zw*JcnsL$+r@gQ`J&G;FFou29UH>bczp$VHAh`?S-Oisqc`d#Zvs6P-0r41HfWzFgB zo#D&8-S}D6cv3HD&;eK$O@?tqYuMJQl~NoCR83yc2@MUg0)P5sZ(Y-cgzwh}Ecb;2 ztjuBhW%|K)mcyp#0pY8!&?4pcvf%c93i8=DB89PhMv(FNVdQ0uthazIbl|d|j$h+E z0ljW$(^txgjhP^+s19vxx_IhK@Hz>t-EsF`!Ob$Cc0JFWCN)ToLyogQt>4 zF7q?pX)_RjzuvFI{0*y9!`AE(TiUoJ3U5laF=qmw1INfk_0|qGjGWiIyvHaC0Q9}^ zE6%h+a-GY``>o8&`e9=jsdDo}^)mw#`3(#7@F`?oQXbgV{d z->UCL&5jP!-57a27klPsl`59dZC~FB$e>YGsKDkPLMF4{+mXoQ|H%J-IYZ(=^Z1|`9_Pf?KS@2pQcmRXyR;LHZg=pWnLbESoD??s-Tl1{s}0;-xSOx7G}>2i^yx6_H^4>2tzV-xaExstn{ z;4&&vXSH4*d1Cy4&{HcHn=x|tm%)S;pd)*qdpKc3BQrnS0X>yhVS~|f@?h7CS;BGB zt>|gnT4(zVxG-qdp5T3hbocM#wz^`(YgDtiw_tb;-D(k!)R)-g&t+Fs}WKq(P8ZKSP90;X z?2eWWj7qOTPAL_eE7XicEcz);&uPrqIe(cF%Tf7si)YG6SI9LVnPB1JO>gcc3a)=x ztt#X5=+RRjOl~>zxwPtYaj|zYA2Zeqf+h>B>XoKsyVckmbbgsfc`$>_te#Z);vKrG;}@(zq)<^@EEFO@8X@HoO^|NmzGJ5idZ% z7K>qp@RzmP^EhngvfM+hR4!d~Yn+kb$>ga{&i?3`%5@5BM%`-$2L60-;dF)JwORRs z;6)lOl-G7?Tf*vJjz+$FsTKnl5jR64krqh02MH;1C}`#E=S=?@2Fv`TxP+k5Lz)T$ zF6_h6!|p5UCP*vIuWe2&Z7dq;Vt-pV8Yxy@B27l=^GK|(wAT%u z_&_NjzI7j4`y;mZJ2@Hv`NlOtw z!T1wvbZw*wpx?^gp>6@S_Mefl;)j!mo zGdXUX-&6y}zqoyu)>JYAZTG$hW8ja5$5Ac|!a{g1N+cqoS|axL7i@q1J=i+2riO)C z&tsw1Y-m1O_N|MUJIeWR8mHCrA6ksYNI&i0tlkCDX1cEZFf~;h9U`+ODRXbRa(8w{ z8j6vR_&e5exK8jNF*zVj4i1sKT}QepHHfO8y3+H)I$)h30AQ5+Ey6QEn5t)V&F;BWMD`LqF{#9Zz#LjoYAwCkN8#f z_*Z+Swuak}5;Zu-?lX^U|GOm~v_IOFG6bUQ>NxI-Pn|AQ|1%zOeQ9WzpbpNQ+?RHq zj0sDhyHmN?5;_`{Y(;(jRX8LS$bDeo7ZDDhS?jLX7hjJ0IzFEpItbnCj5PTl%ZP_7 zWa8!8NfLOCLOuBXTsR~84&nZw5U_oI0|VdSN$_`0K~5|t0Lu-1(1p4$^0^1?zx_iX z0M_{bEZcu29i-z`{=Fp_BY#5n>Ms-JpQZ-F$_tJDB9)MQ^)K>!ot1g=bL(#q|9SPn z7-te{qI+PyB04V-x^GQXBgJE>`RYMsgS$Kf=$uP3nW`?HAsF5gRV)VvlhEUK$F{5c=oeJdksnjgZKrg=I?_7jD zf4G7arAQxxEh^pl6*yPdC?Md?Mc^d``f8vElvDRL+SZm11~Ksp&6xoT*}6#e zKd||qey!P)c;Czsapnf_dqnv^-Q{^Dj#jt3pN-Dx7{T>i?Tvx%R;VRqWPCHU%L(|L z-*x)j*}JUZf8$XR&f*7z7Uy{51^w({WbK}vpO~{5o3CuiWC=wjj_mG!g|oR{#z-OrxsWN0~V?7TKc#VOz)-$CXxDmy@ zz2c`HzIr@!@0!P?K7FDW^q|tv)m`2{J6oy`>wdniW-@4sf{@cPg?#bK=j3g5iN?B~ zVqU>xMoX;#{2bPgJ@G{fdaK#_>NB(4&hr56WdG*)=5-KntndrYpPy6bwbP$Az&v0g z{C&zTt5C0GRh|XtnOB$hQ;Y&iue~9lR)$m80OS`k zq;YR>7egkroZsaCsl^$y{$O=f$B5Z}#v>OsK}8E&g@S_O19*00rHno&6pb|U=ccC^ zma1Np<6ctW3pq74wSFKNXWehd9nViV2QFvoV1n1s3PKiyB)3=9Iz|=)kx7*^*4Vt@ z^_=uU0BcwOvik|}{@QGISW-G#v43MKw((^Cdi9!BXRL)snMF_AVe|5c2G6c8{cN|+ z!>7b^Ap26S!(=_|1A#B9l98H(GBo z%bke$CD?;}+!|s#yxEp{4zhXg1hMrb<6RcRtEBg^+M)`M3?QqPxOs`uKy- zMC!osufe2NO#@|r%s&&k#kyoRcUDNSv=uVuG!AbLC`RonDnY?^trMrHLKiuvPi={X zE<4K|mD)KVO;uG@yFm?KnrsG5s@!E)_}Tl7>fR$a*Ey{|Tk32EzlUj=$`mi?L>j8> zNCo+a?QbD&C-X@NuLoVD{WC%6;3Nd{M@Fc)ILr?ZBKOmkdOBSXrW6AMb(xg`JioQ8 zw-2@_qgq|y0YA{FON^1nc+6GdKYvTm)2Y28xFO<=EA8u}UvH1JNa769^~tHQSCmgA)*Xl%P{aP}!Fv9|&nl&@rPA z`PC}~m6(^^;W*I}K4t7NWgqxhm$kbLa}>uH7M9cTEssJgrkf{rA?2K^zF)MMOvBse zr4NO>ClBO#ec-7v%>1RN#OsIf4HKXmwYr!TAxxw#%g$(Wk1H!&DYFlIYE8;dHOb>R zx*#|a6lk>`rpM<;1l<>Q8RogMHBYwg!~4M%7l!-|K`-`+h;isEMHk2wx#`PH3wK_y zk#cJUQHrcRJ~Kw|VX%Z>9dFosALo!0*mm^fUwdVG1lZWHZf4?YEJo)_JLbLN$>i0C z*^IG$L!`lmOSstIy8-MQ=K|3aZ$f(}`=dz4S`7Dswiu`^&U^xdwS_ep`#(<@umeg{ z8?@|XCu>#h0h{v=Gt@q8E}i{5Q%TT&C8Fp-mB zPPtKc;VW+ zwl?$>YSg&l@nv-UdOhdMTRkoHL8t7*?f8?l{9Z`iDo9(qleZP?o?qW*c%D{eb434i zA5SbTdjYc0I7|@5$EL)Ft>^qY2S$RTV7Ab9Fa@O`;%b=)__@;|#s zDo`OIApK&h#zxs&B^ z`tkrQryP@-Chh$0=()L*vW4>BBBGYwJrh@h>*rhZ@+fXjF{NymzgW`b$jb(tJHB)< zKr%Zc3;SYy?amkY`wg95tLs-ua)|+;^6GPOidTME_Dl$T@0f^e2zpvyU$u~IcwTT+ zNHyEVh)y{96$AE3nmIOsAjF}=scWc0Vhsa=!VF{fp1dOn{08EHWGY>Lsf)DhTT2h3 z@~!~HQ%$wtai4Z4Oeun4XE&Zn>z>_V4)gQXO4q$j{qW2#YP=$izWDoTlbj zdFfc{JWi&!gOif$1Uf@uD_2*TW@*bJ81l;BJKO~~9NzA)M+i*@)|cW{dtO%!+c}!M zd7@h1z{0&*99gYNoa4_TWVa;JvA&jFuL-dr9)ET;Z60m2O}XZ9kFb(_RVFy4m0kA} z4js;>u;wA5UBQ*9z;oV;n@!IRYM-_Yal?O~x$@30{n0PGu{g5;#jmO){duIC4U5pcJ#M|U9TF=}u5DsV_9%W|%4L2fcQ!hPLB zUBzn~wu%%J8inHd2L4B2%NHCh!ed4>f5hV3tZ)?)@tn80gAcGCk@+&Q3>wIa{w=_J ze}--n0ZWV}#`((;e1ixs-C>5KiQNs!9Ry>>fza$+hJi-uKolHxd%KXGy`>!NBf>=q zcn$W}k=Y`I&atU((~9W6p(d9)sO_(ui&=XHy}UGodN!KhLmDb}w0tUlVk}3O$Wls% zvT+FokwArG)lIb9kk1XqtSzQ3kY3!w<#iMo1=H zFjowaL-=u81ldczy@`~swR?^@U5{q`3PsMD%`hvt9=BKPb|j|hN%<>!JTt$xQ)p{ZK}c1+awvQ!J$PGbH&qA^iCp0F`{f$Qkk+Jk zhl$hJdRD5&l0)~6DUD&HGl-GfPfbiLXN_@Wpq^3y$9e9iAX@^;^MMgc3EU( zq}N4fAfys?hAhZ)w(MMz<#YL5TaiFIGOl#XE-4OmK1Cc))KZQy2Gh3^y=V5aBf&ja zJHSS46=T|+Ctfd50V>HPbAKLs|M};L=#D%CNiV*{%pZm9E~~s@?WPy|Nog}n{I@Lo z&yLU%5=4P1biQ(!JQ7x>Pcl6&$T&Ig_Pp5D{8?vq4R7e!31Kh&NQBuiFxj`>k#&jdB{9miX?LC$yP z{2~EBxHSJA!gP$4aHSSvf{Qk{Q!(>RGLCDeUt>c+6V!n}79@_*s{TG0E62?}inHfB z=wRtAxx;f(_44AV`&yP%Y2EBAboS^&n&Im3x9AM_D5gWY=(uJ<#%F<^tj=%y#j+c^ zQhRU+=E?$M0H= zA>?Gg<5~99)y#xwVG-T~sw@HcW)sOnfmXUa%>h_v8=Tlv_;12dBpv5G+&^)n#RedC7~ zayvb5Z!rGfay)t_lJQ4RJ2VpyPqJ`rZONSNDVm^- zjSYlt)MP8z1*uwtAQ+JT_aF=fX>G8lpXAPrG9k>k_>Th&@)|TX)!a=?S7P|adB}`LK;#7 z#*zX1<3}bF8GN1h3TrqQMD6`>Zo-RXCU*8BInfe`Q->?Ni52{E$ZjHHX$uGWV|0s- zw;0i&ppO7&1|1!TjM?E+7SqpJi|od~B;+J~!;^N^cG2dPh`k?QHdxiy8-)_;8WEWwh_m;P*$PR_Q|wY?(qZ zAN>%=!(L+c`E*t}2{#5FXDWTYC7VEDFw9d7Z)%Q>bH|B#=8#-QXbi=^G+DLaA2U@t ze9&%$L^9hX#B;^(gW{ITGu3f4-S}llNEG2%bt-e5oiht3A9alWDGpC;dZW9DurM%s zE-pG#0=w^7RD^-Nmg6mH8IEmnOZAq(=ML`=FNfqA3) zeJe%{*>(oHi22K;_}e2f{2dz)r}c?xjgVcd;(C`8HGAB#*aic_Ro?Gk&xjZOcKOl} z7V25%q*H?VF0zgxH5nv$E*%oA6}YBF8#%wDkwF{Lo8fmU`~KXEb&89fq}WMFIVNjL(EQwy`+-v~uiIC+`~ZNgUVj zx---iI}{10ZQowjjOMt?Gimz)Ql%;Lnx{<{&`7J7k} z=rx&`1dsxeoemXXlwd43TX$pVB>LbtiHwF`j8>M}wgRce=n$jbjO+aQ)H}{6J{fe& z?;GKSrd=DzsBe#z{CFQMt{Hg9`6nYPB%b})E}lswa;nV$kF0;4ho~}%Mp)?Xupo*| z2q~fToYd{H1cn(nOL!%v+&fK)Bk!8K^wepjb0bDC%EbO zy=gssKV?9wX(}}G7%`_!FuJfY=n9v~s4IV-U%DV}1ANX>)%tjh*SwG@lqmoU$8I;T zC=9lLQ{4?jcHpprI?N5=uM%9aMU+WHAT00G=n8 zb_g!@96*BhasY`>*@= z{FwAlWd>|)jy!dTUQ(>yFH44cRH`>N5o7;|F|D7?i6Y7;hp0^jd zg`H+>G&R`BxLAO=8~K8rmZ@5sSQ|2=Qgp2lk@k?Sl`QSinm$75P_feZrP{MqtYCPO27|e?`cz5vF<=$EbEx}b@XKOs>vF#D z*&6V9FlRGdOp$Tr<+e~T8XK&Kg`^~UuipfnZNG0`fb=$va(|NtGCi?&EdphpnC|3j_#IGy zr}*{n`jJ1a*)XXz;Bymhn*`7u3t4s6|FiE>vZCKmJbjV87KQ#2`)7Qgv7ptB} zI$KnwG=$W;KoR6F{on~(Gf{mK%(!**MaakA=0>9dQ8bkGPHAG#pWjsC66%{K$oXa3|q)iNol6f~Dd1Wo`p9I}t;9vEIsY`4UE$trM>`mMIte zrH|ZJiY#R=6+!N=royYZy-|WPDN}A08w2b3e}Lq0g{;w;!U^^APaO}$c!AfbP!K5D zK{G7~p|e@2>u8X;_2>t2##$3ABy}+z`CX)#A<#INpz<${Pdw>YhCxI$y5iKiG(W*r z!tVLg&f(3QA3Ree+N$0-IPNrC zGH9x~N0KrLcsed~?+JlvRn1Avg-0O$MNbz)mj7g*JQc4|qY_ZGAe#smI{2;pd&7m5z z?UU*6{ju}@kbDNC0NP+{A&*JCMAZYKVJ;s550rS6u79>%Qc9DVnx7imH~)%$9zu=( zjFpc|c!*(Zeeq|fDg1S6y>m!MYXkHWc-DExywtkEx(_`KtEMg%ZTk5=bBssBGU4SR zPPvwfL%Bwy&>Vkp!AyaBKtUFNBk9>JnagryCtbE}(EuhA&VYhw4=g((e7vZ*qu_{g zo#&nZ>x(Qj1?=1O@5mPiJ3lGS4>B6N?9Q^xEAL+dM2s*o`TK;=+ywcPz3T+u?F>oc zXqZ>n`Njsh=sK!P{yoflqk&}eIH=b)#VaGVIKmTrL2oo~P^ctmCdUK*5s?&*w!WFQ zh%cD_QtHt*Me2!^V_A48s_sh@ikLoVxU2skP3oD)>)b?yBtR-`G432oHte{cP@|Hw z=0p!B-|~B8|B;$Blqkr}S5us(E2HNG;s|Hq!UyK&%LwCQ{yq48kGdYB{vTy;9Trs+ z{*9|hC`c+@(nvSL0-}WEN=kP~cb7;>OUJ^lfUqLX(hW=3Dob}Gu(b4o?>_oG-|zdo zet*4lalyr$IcMg~+%t2}eSbdp8LN@}`!5Me7XL%Yzs-=d>f#6_{r5uB0}rDSXwwZY zTLA;pzex~_jzT)^cfg+2uUG%wQ}huo)8s#2S7_(+KgG-+OGkhFoBG2wE--kfzlls? zCFS4zkE)ar!2j|E|L+?z7yNh2pc5wlTQqLq&7r5n-PA7j{~GkT)jHlP?#V zAE9>{WV&eZfc4MAPJa6)GsMq{4_JEr(d=SaHN;`}2JuUQ8vTfn}Pq@}LN*_VW0WvAS z(X$(=BkCKMPN(yir~tH^&&iiFPHK$7Ap2qmv9vMeB360WBCPxSe29}z9WoyL-}DRA zwB4v_KJg&E`0))p2SkN%S#nyw6*(Zwd|5IaG(X#SkPlo8Uj^+uY;^ z^oCgV!nQUl?d!1aoOjwx?oT1AF{0D2P3$1#I!&O;;hBuI70u$OVJj7|xx#wNL+gFd zR_9{9#_(ch&zhTaU|Ht2CGnv3*WH!Zp4H7>FBe~A20%g>>cu*Yhep>140$)}Y!`bd z#$zwF21d6+f(kQ9-}fpnYG1I}zOWg%M6YjZ-=VH^+^oHkuE}}-Pu1(y)HW85wi8ZO zs-G!A5oY2Nthf!Tb+Aq7u+@iu1dh9%KUU_zY30Z(0P1-t+)Y7n(%qEU5A-;mqS!c; zxxESuJ{$x;SUT8pF&OkY?zJ`Cmr9#&YSR*%)kB&CK-Z zq?D8P=}(H{e$nld>571&q|xj{I}JC95Dz8#z1HAcJ116SaEFzT z-LS+J8c?;*%$t?g^^NP{Om%%)z5N`!Cj{G%iaSe4dd_~&Ee;WBbx)iFoYI+0772vs z65an={B3K=HU z_d$S{jYr;@2QJ=Ofd8fw>p?0Ev{(e+ww7G2#82k^FJ@2 z6BiSUxUc1)kWvl(k^7|H?Cr?V>Nmd`z^MYyE*BBd+efA1Kd+UpT=b+;LjN+&-N4x8&}>Bb`I3HP=`)o=?w`zd)z^P# zr8{}n(Tdi~xHN`E00YQ;bFux|ieZQjV8-}LTQ!q^n62%%*vWKDeOj>2SQN@A9jfNd zZc`b|rxUPKJgbG+;n_Nx3)qCQkW{j{Zc*U-T}55n<)EY!WN#z%Q`NoM|1QU)FNLH7 zoBYi0&L|0>3!CC|PjktdD)13XtAmYH=;INrU%!AxQaP6q1KiKlZLRgC+x>St!uM>O zINJCf%r+#lT=#FT8&1WjP*?uEO79CeAiJbzC!PqabCfnMMtYye{!D_Swg-X)Xls6K{O2GgteL~IR{NGBB=SOZHgyJV5nvg4KryS7-5&oI^gzR9J1djPdbKj?>6)5Dzf+Wp9hv{i1E4$@W;gU2h(A%HaSLJ<6>HQwv#vXBx z>qU6v4*53)bwf!K74FzA)+D_5iGvzvNttP*$I05fBZ=%nlaXQk&J~H1a4BtCM}{@G z_=-AX-09^JYin+^c&P9aL4dlz=_u*Y>ZQJsL8_`!ewdsJrB7mFcCV-?%ARWNp}!)c z_S#j_rH(def!)TsDj$$hEzITJao}CG3B2}&*_tguObrGXJmQ$=8a;yv+eo%%r+$p~ zl96kOr6gr2&!AWatI1x+(e3X3QKzhInwXqrtVjJ{=HYp(a7Rwa@>6!(*PDw-VtPIN zZV1PFbIR#GfpECS%TD6Vg8N}lxykWYTr>4lQUM+eupBSP-Qi^?pHV9Ya2$+^PMU{9 zHIG9GTsGl^(P6R=Z{!QGB1T^(aU}ihej*jHwMJNXaIl`N&uOHa+8d&yhLF%bE2X7x zw3GH3^`MtwDs1_9K>XRpR^(jTREn61AjNkD5bUQyGK6%5)7Mr7_AsR<&QGow@c!;w z(2k#sX1w4RI*7SMTggAQj_!R|)-eZn2=Rxwl(|5ix-*nzOnVVqkA4+w0$nPXH>x6X zqEEI?<~m8_Xv?j)%SV}!Q|kWC3ELS_WdYKwD-+iH4m~KO>^Rsb-VFwLcm(xzHZiu~Dy)?YCxjOe-51tF2qtb@PIQI0S zWIt6)_Oy_1@bk+EfJYE7YZbiBN^JvN!M6$~0m)R4tPrt%Io#mOLFAqwIVCr1ML*QX zW|#y)0>r$*NBn=(v31oUn71bV+r*j5m*OAIDKkfwiLNSp^&Tj`lFK)P&ESQt&Nlisi{w@E+)?XPEut*r8x{C6Cbyg&+GJ^ zb^e|^8C^@e#@&7PXs>53agL_bwl+EIoh0?^9tlpB+7)SJfpa9bQXAPLa^?QjzH`&j z+)FqF%D=F9=k~MgYSP-ryc$R8is8}aQ0SuhX{T1=2W3^);}t?T`_}x;Lh#z*PP`*T?NkKzn~2{2_9|5;IXu zXgkx6#2E`S0Rr?Z+0-60C3hMJLaxAUB^yVKMxoEarEG1g!f+6Tk~kGp6#|{rxqsiV zmNkE0Efx#YEq@eXo2-)b-28$*}Rl zI6y;f!mMBO^cW|HkEo_~t=V*y-Q&)c{z-w1`&4n!`$ri;^oZf;PLf}hUPFrLlk_ln zkw?}*|Ih}woo8d;Po0)BmAmz$;S*@_h}h?#N6J2)gs?C4I>z zL8PE8g+m=d89C)pu2>o&4$z$Xo4w!ihP@~)0U^F{={iE)_7ZKRI}Ge;%}bcAVmN}@ zMi*YZ=HDzZuVLU*Azf;N=1894!l^1^MJj^(+npTpyx$q*_jDGXAl4+OD2SvHjp%i- zpaDZbh8Ohf(~Z}0TeG08!|oQ!&yX0W2|#OEM2rGMn;tH9^c$Mvs-q~rYU++;IA3th zJF{MGJEDCgSqr!)r+v@EIAFyKT zwSe2zjTtU=Uqxm#Iv!c@HU>V`ZGMzuPK*h7HRq^Tri+=0GYNK{AzYS%mT>5}uOvn{ z_p?Fvkx5`XSKoh0a@)`x{o7mrE~gukGtj+qwaCv%jMAiLztn6SdCIJ-g0I;O%?5#g zu23u)b`J`>J|0>5ajf8cF(f`UrX2T_a~ci4 zac?Tk_VC3><+gsFH@NKQ>L?!zmVnXqj1lQMEnxds3A#FP5J!>da)Pd&Br zVWA@{63#*2>{GWrWvS1_VE!ROC8oFCinr0MqmZ7}ydQEV6fUCl3^h;q+A|Lx(M2^! zS4bTZ^2L=dJvpul3V0&6XSbSkopG4UM1v8@6cCNyN!Dfx&~%7*mHkqb)`a5s*|;u! zIh82-o6PGu-;EI-FLeFE6moWz#!EP*xiBjV@-X@0hO1GC=Htk6)1|KNZ)^W4bBxxI zM8z$VrkKI5+2|+3SF7%Adw)39I0^Ak!gqa7`l{Cf78y}GjmOgpJ?cfx513nI+oA?IME5V-WrA(!FSKZ|l0L<6sGM+u-}8!Sqc%g=K0sIe{k$#lUxk2# zPR0^<6(`dDpW&QDe({Si3aFNN-R*8X^L^0auBfA=Z%u-BzK84J(+i2F;#>>)(!Jsc z0F3Fp-?K9^MnW_WRlpj1z|A1h7Ho1oa@~gugrE8xA9^s*KM%<|L2M~30|GCr`sMBJRB61&RjmCe@7e(!x&Dav9BnGoomgL8h-x0nbD#1>6`NZLWru1Q9u6<*aucGT?_SfPW+)M!wj~>riXxeeU4R_?DSZe?xQWaZBG_c86_aR+Su)zX5 z{*$8mD{msqiDZ+41=4i%Si=acxNX)j_C{mK0h$ROuPZrv%^~Ye9t))22_&T#b+nVT zj;9aFjb%=Qljc5-qZRro(GQyl4gGB>q^IMZymYL8St=p;>}!Nbb7Ta&GtojAL?Y%` zPe8t06IIMv+OX-4@jAurj^Qw%#5=Gm>KJ_XQ69&7(!x9y1zAv^_!ZmALNK%>a@6!0 z*AS5ItM}WWek{wGGpaXpKdryAM^_c->QZ2AUPsF0R+Sv1gE?cWyw5wSd4;ljl%12) z7K^-1=4VD@B_(IZQE&zd#JAnit@i!wk<;Rbf$PKX?0&A5MLM6Uf4@&E?&K`S`pP9$t;WA5d0frG2;@=Rrz#z(V_rjA6@a5t~9&Q}d)&r{^L;g?Ey+wKLFqPdC+s)9z;dYPvW3owd;*xTw; zKP z09P9I8Ojn(6RV2)nwwAYgX~#X=%6lmE(BEt-P}5cr_G?3i}2_WlQ5?y?cq;$B^4|D zzo+@#=IS$t^-zT9B1X&zM8Rc4Tp09JzgT@CWswWg!b+f|i4MJw8r1{*Al_Sa-pSs5 z&dwK=9qDg@5>=fHy)#sq`LR~wBWbVwa~d*KmQ(K{TW-b^AjSpoc#hPyx6^Vb)(_J=7G_KAvL%ewZ``sa&&Mkbo*wVonJ|@zz9F=C||^1IJpEoGXjXVQz&foNlVV%2|a6=;ToJ zEo5D^FE&2;`Ap6)&IkU}O?%ArCe4t&f-|_P(&Uec6>92$-j-ufb72M!&e_3hm7?x@ zPV9ls0Bu?T(krcWeT!8idxJ&jhjRXetY)bI@t8GhJn8Eg%+XuQ5hkRcK90Cr5<}*s zc8QUHAvWF3_wg+l*QPgcw7zlJyRYogxb{KZ9rb;8e`7)ntd$QA`50;)%HU{(gb4_!>_<;z^U{pwwR$WxVa#IO$07goR$ zWl%o>!C?bRf!BguAW<(_B^<8NMAjkFLKrMc#&LJK%p@#!)4DQWJe^ze*Qu}X&4RAU zfw+HSwxA@)g(B+WbaxMh-O(9Sf;2yWDoVvUFDet-_yFhP6{^f(i=tU7ZY+gNkN^ud z@sj1?k9Vo>gkwfl7)5v@YHA)!%o@D=xnpr2a8TtqBbbaFE1qu44LUuwTHnNw-aaNK zGJHL;sx=cF#wg6gHbUgF*p^H#^^b(=T&;dV9>C>jHLi*}cb%KEs#dftO4AZd7}}Pr zDrQfrS1(KaW{dGsY2w-88N__n5buk2YPE6XGA)He9D?A*u5di)WEr}NvB8*N>>^_lqE z(&D3p=hIXme(8e_Jo*nSp-#AAHBCXn4gSSn?JX9{%}XZY2Nf((Z#pP@_oU+O+?)|V zwwNXd2l`I1-GJyf{2hZWDqF6IF{5+})=$M$UKkrs0l!`yV`xpfw}~U+_v_H8>vH9- zD>({>NQ`+}k`rUxPj2vZ&y`-BJ=naXs~n5G>~wmNiZ04#r-nlpCeu^;@aw-=I_#{U zwmSRMif&r?%2SJPq%y!W$AYWOPQu`{Q7o~sAh=^X`v5@+6(&S6H-nK!I$ZaAlL z8+}+OhY43n0u)tzzAWH2VC$}@Gt>Dh9lP6HbWs~l;MOrG7`^x^7jC1=TRTn6$qLSkN-h>FPS%93)G zXN@!}@DK$G|8o8oVf_{|1ZO>TiwCJada9Ycb}{?t2un7~cA9OkDm9J8)%oQ^{q*3) zZwG}xoS~&Gu?$vyhf!yg>8#g&C3zVSOO3R1dSyy0;T1c6L9RhX4UgGlm0vo}eji=r zH=E8h%(O8@4jfssLGzH{2r$j|})%IQ){Ie{!y z%18mXm6Vm`17{8^`LFTUlU`%uXc3#~%hg&~!^qeuRz<^wuR+UgQ6d$Mbk8gSX1jQq zgvUk}=&g>kblG*uEhV$o@P6BBn2pRk_Ca$GW^+-H%}!CVg}Ng*u+b;+kQHDv=yFO% z2L`+KbYDFpNs!wIX;qbhzmYlhPjtb7w@FYN3U`FaR+YWU@ry(A_&iTp0U7-_ z-RCN z-JB*m*{}tDrE030n;j_dA@>+M<~A{sqQt1|bI&147P_XmrpCzrglMTT;DBNw*FAZ{ zP0BY6kvZ7d^BFW|r)J;pzmUfr@iFQ6RwC$kM8P9G)i6DoF`SQmG)*0l5Q!DdX#{a7 z^50$l_;r|;zO74kgC-yfTdmBo`RrwI9ZTZw`Tm*?!Y%jS=SoNNrQt`%t0xlx|8?h< z=n2e0w~+7g;qsyh{Z}b}c4wf+^3_e$e6yJ6m%WpW^vbhuM)chK>x;AeX0E5lfbu1d+l{#<;A?+rA|AV~okxn2RzT%7&~vBxYe(ie_3wyc&aR?UhAK_~H|6|F(Lwr& zo`6_rL!!hsmSdy-yp~0!&s<-iN8fkD{ylV)IK;Lh`nhP}y^g`g8rQ9gxkT>ozjh-7 zg9D72OuKu6+U;%5ziU;C^;fjzbW+6fw6x6xrDMJ?@b?=5*Xv@c`kW--tA)Fhgk>48 zB%i#cI0;LBJXq)N>ktxa|6mstdskU zJuB#zt4^Gl+tHYBA4SgcC=!HkH%h~5RpN3MV+=bs$ z6M)-{-(kD^9^*6s?~XTQSFNPXSTTt#jylEKo}|gc^xkB;TS+YVR#DrOw_nhf->MQG z1kKB8y_ErXWEq0Fsp&GQ-$}&tJ5^ss?y#+_RvrA#`Yoih7(>lh?mww*+>wke`g+)6 zB9Lphj;QEaI)Uy80|Cbe-bvkGU|-3w*|VvWL*yxdHl@3ELw>#$kjQE$+QA6>`VkVs zm&=N9Tq)<&^rh+QyRVMb`IB;~?js$!I5r$gp_)*i=@}gO`{n*H{YAl+M}5`iggtm+ zLSceKq!0C$+op$hYaQ1HvlXO#t`yGI1OVE6_{ooaPArT+LbzW;f8~a0C%iK?p79q} zy?1zC=?~4INoTF*u8Q88hfdWMFnFq%{(13 zlIVSvFtb%RXd)q<(p7ClR*_&3HPl(%STzlzzO-Ab@|hiJHt@ZPAuZ=q>(HI^`Za&n z=vTEleGR_KEps$*=iTsFtD5j8t7LZzN{ZNtNw;(`YLwJpN=3jgl^(BBk&EK!w_h#` z>bjz=TVTb&qsxn?g_`=h6=BCO)44{jm=MvqZY@4$GFe&MFYdz$$l>7= z_D_d98f!Ay%$$VbjLy?v@>07x@7T9y#Flf?5`ZIT4L%Cpi2$zIC`18Gn=WXb+gjdJ z&UGgcR2vR+XQg!Z5*c1ZE$!QE=Mb3q{U&a0i^k7)Bo|UaAj}4G2#SDb>je||-55vN zYoRcQV+YPy(|=hYwBu>S7e<6|Six+x^i^wtx4eeBV<#t0QCS<9&p%z5p)bWU%e zMTKuU`iVTZl2Au>$G15?8~Ax$)!Ab%Trib6wb9KB0<%w9duF8tL#XJ{0*YoS?Hg4C;1rP2Yu@_dlpFMq+$k|4h zKNEyrFoF#!5*-aIM--eK_D$CL*H(5fZQack}B)y|8n?07<#Ki~?BNNWTa6+P|6*@=d~wxq{_eYxVrd3k1!)m; zzKI1_wd8o;jx_2aZv1Wvit3`}>7ob4FZz|K3Gg9-sCU^#@;gq1O>VVB_j}C>2bdsG^7!2-xwm$y7)5r_i$A`E?o>NRN9^-0M0wHzTBcQ;|24 z+v@}~r_{?QAI_|talFqXn=dWXY4!!& z{$7tgW%xbW(u7dCek(z9fw<7LeYX5w_YOE>O4Q6yqa)^5XqX!3@CLL#DjLQhb^2#roQ3h>R+8Y%l8~Fy18nxi)Y(F4)z8YM_3zJ%pMjnJ&~o(*>=^4A3&04Gyz9x zeFUWj85zniX!e*J-T z$bg95^7(Upzn*^1pXBL0uh*k*7W&BP8TQ6R9zT8mK$4Bo-`@#-(^69xqxQUbUNe34 zbZB&=m-VJ}=}n)Eu6kh-1}<5Yoj0fGMae%r&GUWjxU!(PuYV~1G`yHLJ8gV^beAWq z^qCq{a7FCmuxGk;#Pr#9L2V{xqFn&AMH}p>-Sivm_=F)}-X|~IyVYhEDAKqP)LVn)b9XVCWyZv z7_9eMFH0%C>z%MDYx})Cms{v4&~gbmh$Qu27kc+8Apu%gWCGr}E1lG87*jzAhg=B9 zN3mt-iYwXm`GiIEi;N`|%TxMQEiFGkIOO&4Xps^3tid9kZN@;Abz5gYt;@Hl{kMt zB4J~E_b8Xj31d!Y4b$w7! zNc~U;$VvdB_ zIW)}W_UiD3xyzm5=}iHYv!pUqhTws&=5l7Srx}5&56A}EZ%;>=w@wuCAnMio2PGp3 zB5XJTflRr$pnf0_+Eh&ncVy$?ixPdy9w|g#$UiEl8*X9S)F2$nMbWg#*Gb9 zKsH4Lvhi;7S`a&oLXX~2y)txU>CHilSVN?u0yb44`M)uH(pqm;S3D~@p>_=e1>mms zKI%&ocS@RvQ%h6OPv=opJ2${l8eH)e=7f5W{jANojxNPDM}5v5&{{e(JMgWd=#BfZ zaN7VGB&8>@Lg{H)MQq;^z5mIo&xHl61AF zj*_I_m`!Tw6#_eYUeB?z;LJ0!m*Cf0dh+A+nP%Uh3wc9((kUz1(;~1Xbe**a#{J}3 zl+?Q+U=e`xrB{#H`weec>l&>yw_1T~z)9C&MCNZQ#`IqEQ1}Sr zV$_}+Q<0q=*9#hS6ilLbVV9E5WLfSx6D+t zq;_g(_o7xa-Ywp$;*saReJM!j$lZRa9Na022_$%~RDn+ltJcez9%;%>DtN;*-T-)U z&#bbc^wVlP3e?SM7-od4Z>%$j(WPnS8v~&-5usHAQZ035H{G(Tst>t%!^`F*fzmvP zdg&_{ov1;q$RT3$3uQaZd^W2=L!DvXDu`6<1>9l-MghT zXDb&|6%S9}%-qG2FHF|g2X`U)e89_oKl7@Y?_6RsMakslGNE4RCX z{d>FXM}v7Lr!apH_Nmh06($pWC)xowbEW0G=d_J}8q zCb!>7*?6VEPd4<8gqg&*OWlqpt+4~${OIFBv%V!#aXdW(F|1TMl1i<=h4X)Ku)*Wt zB(XCV%*00|53)Z}3xpq49=u=%m-8OOkkLT(d?+-dMISbEqrt;d&YOI!g{MGpk3`6( z1k2Xw9yth`%uH|Cwy6Bb?9Rkn`k_Uqy@z17>9|J_ZxPQbbXV!Xey^KqQC{jaqc`ot zJjH@TbL3-RN!wv2#}F?`B815x#_wt7Rej9$w#v$mtU`x&bA+6)%%rlpbBZ=LUYh?>fx6QSUsS8c%CYQp6y#1s?^It@yIC zC0Ex|ove8Q)MycOZe(YPfl_qgKvlqF`-!}f#CZLiPYHy4AW;#RjV*-;GoSnX-u!he z9+7bdTO)}VNyo=tbJ0CiXS-lTO**_~8gM*x12_j!-toYCo%XP_|zUC-~aA?Nb!)ITATG zz`RRHi=5PHhVZqg_7SB+PvC~tiGclcKmGRGIQB3y#_nBEcEitE+O|3iLQ^a}?61BD z#OD(>ho}~FH7qIg5SJPnuA?6qAWynPZ{NI2{Cf;M1m8|^zXQn}p?}3e`jl3*GSf+} zfQ^X+FXBUkRB+2JF%KPYaMdO@xk=M|Gk(>+@@MuthWtIOB#sBH0#b?7(~rBJ;+vRK z!X4p`B9=@s=9w$1+K%|X7pI%s4c)81gfPneIgux6v_fKWyf9e=vxfA$pAug1VwUj{ zq|EGxgK~GU9)Beu$R?QAXLHMai+yJC*8aAUDZ@1V`Q5N&>_W7&?Dmg}08qo}tv5B| z5MUqTMaM@6?vYZw9gQrW))gzFew)yJKRuPT@=NK(m%TtbwZPphqAY*pPi9Bc_B0U3 zRtA?~e>VotV-Z}v+}(#XxFM;R){!#r(`+tG7THk2iK$t$x}+elM&rY&wGYfDi304f z<^#T6Ox9@!>x188g?;erK?g3r3nJ2jm3^QI-dGC&T4^PVEC2D=5A>y!_#Cct8ZmaFj z8;`55khgX3hk9PMyB>X7cxj%4{qkw{LY{_8Ggj?2X9$Yr!0Jk>)X$pFfgmi0g-ev_ zmp88KBN^L~c%z%?BbOl@gE_yS6ng;s`cGuGdG&~>UAY511186%mc){JLR3^r&dKI% z*t|&qF$mKlxM~N*7dV;p=Ecs^%utJudZ3<`{^t^qBKTnQclVr9mE2F%gE0ym@eUnk zUNjb6F5q}q{g!o7IX&0h$)UmC7oKk8e09|CNpk4zzLY4L7y_8@W0sNhwvCsRw%I<+ zb6x?j%@S;1|Gc9jBe9W3KV-p7if3Lu+d=w@V;orDoVYnjze6bOp)O0p>85M+=z6+! zi}|JbN2S-SPgz0W&soyI%yb(uQ5ANW?FJbGB*8p^I3YaRZT3NwjCH(GmisW&Sx{>4 z`LtTS&t|*XoPS{8{G`~-f_PA#v#v}lI06{dt>OhvNgX6a1~tTIh@zGkA1$!ITeIBT zDVuL9jGz1gJ~s1pS9Xh%&~J6pe|q2a4h zEzTaR;6$SCOZ+~ZTK_p*Weg&1qLo8-6Akie)U

X;0I$ai(!_tfqb_OpkuLV=TP`BnITByJp&CB>GQ%T(%>)7j`3Vekj~8?I>gU zdRrm6mz+J#m*aHvW&z84eUtkcq|U%JCg4tt)kUrx+0Ra;$uMNF3~O<>66 z#+hZcL7psh4agUjtm35lrO2&knbHrEqJRqJHe7hNv~i^A)H*KRK8+&uT0H^b-5>US zW$V4+pXDp$y^c}IJ4Ub6rNzW{hleS0@BiRHJzp;>IZ*w5l~8%qIOQsRzFn`?ToNXL)K5hbx!zfrIE9v5KR&K@yYUt+Uy^oJKgLBU zp9{^X@#P`_RM0}u}M(mbqs~3p$`v>uxRGe1_S9brJdsgS%vdB+@>h5MC z>kRK(D`>vvw^L%XAl3W`9AJAaT4}(2Q6Tyfe8=smm@=Kx!eSKzZU)@YOV3{mxK#;Q zSr?J24?s|_Qr$!?;p{+0IgAUP3xVj)%-hxyC*8QYQg3QO{%*wz3E`VN|5bg1bpE&#!iD~c}Mt|2!(IzO*&wV@py&=!aQ)3leQ#FuMCawk4}y>?4Z z4b&}B`3Duf&V4)|AXPey>ZKJS6VVp{p)TMg+`70&N>rJld(G18A3P9GIfWQ1*k4#U zk_Md7v5YOL*mYiZ_p(|D65PAX5IGYn)Tty)y}6txx_BXOQ%$7NJA=__UaoK)pmsmE z_5pR1EvuV_o;J1IeYPWkr&zT8eKm`>;-h2?PV8VaNKZ@T1R9EQlz+_mRFc~lvJ_V0 zMKDzkMe2@z=AWyZJTku8&P0{>f3ZjT9IE?JXR!XlcyRa)%gi2wt;Zx3yDX^lvCW+V z1clXP<5p5m^2(}QKlqfHV*>ad3tX@(svJse`cveJe`|6q7oY2AXMN^{q&tvr+j<1*=C>R=&X*KG38**E25BM`cm`PXTqg&?f!_;P$ z$uI=rGe5xROsxtGgC=_LMT=@R}~_0yEy5dqH%zo=g9=olJfO$?q~z2Wzg1 z{fEnPyEJU+tj@%S?A{lmjvylRKDo)@@(VD65Ia?1R3cc*1gmajJZ>g6?JZ z6=>io=3_?itHe*6zPl82_H#3CpHrkE3KIYI#c<)__G5nxZfACGa57so*}{>fZ$?~9 z2z-e>fPu0x!N%75Rb7cK#ro9}Hi{x5Fl$QEraEbf>wBJl zSmDfC4qj6K>|B_X6#eJ!Rg+xiA~BxQm1?`4uC&VzOAn7<1H=C?HV z{}M$1KOEElXHx3_`wdlbfuA=+OfeJ=7D@urhY@&Da5*Lt437E7J5RwYj{nAgZ5ZQ@ z5CsWcGACS`S63Fh*@rv_pQ=$1T5 z83^yOcrLhO=X?0&j`F`RZsjkOrK%`Ri2WwgoR%t17@RPdjd`@AD1+FlQXcYRXMZ1z z;{7Y=fVH1DZywmtj5?<9B1yZ*2LOZd$(9@V?>Wb=OZUoY9>o>=+3_~-T3f7>4178H~d z1^$*15fTSrad*<|H+WaOm%FKpl0eh~V8!0_N{JKkNY z2giXqDIS6l#P!K-%Mt{5oCP%T3t41VN|tU2M5!mUC}Do|*ZSH2`J2pQ9lw(DCo8$S z_wa9)lD_4|->fgC0)c;T(43@{{(F_vfdKONYN|*i@bA^Z?2bm~_c-VK%?Fwo7DGUQ zq+%jHU8SnJItJk@ubL$oLBg!ig5XL0bECv__{*bik^is&Q1ktGvBl;5SYC^(U)y2n z*QXUBBQ!280FlSpQPbeWL5X8@`Yv%{`5dDGlr*Vq^O9im_WN-4@gr!YQLTOz1k6qH6QZ(25{WZ z?CI%Akk`xf%z<2xhk^0+*DrYQJq6X1`LS0Rw|%+F9`y6^7JUZBq$bplQL7{qQfO!z z@ps^{Gvn=?US_xu zX}1`3zF;zxKquL2nJA@TXt>2+MC05LiWz}V>O|JsZ$4J{3l}{(HzKzkiV4t1Qk(03 z$>lMe>%SAuyAio@4*SZDS5tZUL$TON)yw4xrA(OQzP_@KEaKaa{>>DEIR5UsmI*9p z!$TPduQ*xQ$*42tG@eD!mutT;syPcFL*q}+XpP} zzK)kDbw+u8wzsx)ySH>PQ4x&Yk;+cP9={!fbWv>rPt$Ugjl6$7ywy=l}dm=U}1IS7oe3IElM!gVKwLGuhjS<+B^W`(m zyhQyX%=#lQ)Ht-4p7j63(gp&K?zf{i`1}q#aho$jF5nEC36lHbBeR;Q=*cSF_r#r4 zu3qP4Gi>#L5OUW0lc$z8~%rD%hAXf&9nJ2 z@mNc~@+hQQVph)6C)r64h8g*fq%Y+mb?DS9iDqZhjmIXHIw zU+_NOKmBUdgqb)}ZP8N2#PS*BDG>Efc^(!=CS-9@Cj$rQNHm)N6JrYMTHGvKyTt>b zV|Zf#E|F;iyuf?UQmkzo5EIj+yOH>m;oq4rfuNMm#dUXg*Q;}+YHVz5x!Nf)3OwqW z_nI=s1pB!vJ#biN_$d4%9yF8k@J-u=!@P&u-R-^K{TaGqzxb#R0s*|LIg@(#@JJq> z=N@ss4wkj$*D*Oc#}t!=obrUfe8HsdU~B#99qSSOHQOKQUJUtXf-!#gi{Wkh$&}7( ztjzJ)V!pvG@K*uD-dj<2c3V6V@6pTgvFF_%(RTQa#XFB7uW|2xkY$uFtpZ=UM(*tv zEHFd-?dArNB_HHI#J7y$g%1`#J1`9yh_ml)KPHXmD_y=G&g0Hu`7!?5Vc?~6TT}SA z7$(u$+M~g5I`=Dhnd*X?3&4L@Frw!x7yM%O;~a~ATe`W%4{iAHAa|Ko>?(r*xlhnIR4cr;+M&wR~jLOYxP*? zhYz=jXMY;`3shG-$|(d4{>iDRoBzBi9#T_gy2qaf(3UA zBse6vLvVMugCw}SpWyE98r+@W9Nb+GoO_br`@J)F*34ZqcjkWg57uJs1$%dOS5-gt zbahql|Iv-<|FhT0Z|LxBe015L%ywFZ|0~b90JIkEUJEk6e30T^NGH=jcKUI{$nEcM z(!>9sjpcuR3xBoO0!y^gde$N9<0Eu(azY~H>D@5|>f!{9yu+H55-*YqQ)SW*Mdox#rQV=lWdWrRjJ1OCOYF6+n&6 z_LeQAuy!(3EMJ<&u8ukdN#^lADk>^oeYPgHUfakx?0vDOVDLq%8qtw9LopFtpJD=Pf%Je;9W6y? zQ%ZmV&~w^BOB*{+{1Z+z@=P;7)QYG{(UjkuYQ;sw%ZY!`5#7sqG2#Cax&ns5T!2T$ zw>Q-0AIS%m)7i;Zy3$xg|B|A9*V*))ZSxeD8YGQ`{|2KVw6P)XXUNtT$mXoI6Hg2K zU6@S&n^RRl#{4|Nfr0R+Pew?X&_wO)Qb`lt=fhf{bKH1A5OM*iuAu-a5l8uhIRt@U z5BBmZP_(MHB5TJp6Z@}9)=ZpRq{tS|v^|}+wkJJbL<6=%>9P#Y*4knIw7`fUU<+{e zjCsd(frCsDqc66DiyM(0z*zE#Z>0@+1F8Ln$>29hZL8sn89p2!8dnfms#z`kTj(Q$ z^8uP|hZDmLitFWyJ_OE1#c7Yq?F+7T&Moo!b|L|et{XW3piFr1Solzi>jH1)ohq56 zc5O|dt+q0f)=yc{dUn=aTz2GQ^<6X8wZYovh@=>|C4o3;eSI!zE$&9~DpFND)FnD& zxUkKXZ)7jwjhv*%W2M^U7s0@zp09eAaxh<1JOuP#z?}Ug>i}gwjtT{3$gr)Fr~#Pi zv~AC{g5GZ)2&Rf&;La!#ac1CIxuOV1@NurN>r^OPs=erAJFN%aU|uYw2dvn8eed7n zt`<-o-wHna_WM-q(al?=T9YXi+ep_Yq3>RE6xHq<=#(PSO}J;TG@$Fki9L&$?EaDA zH_M+Xo?ZeKu4d9f^{NMow)&y{Fx@wQ;JY7{H^QW_o1g$6XA2c{#WcB8A7YktM|fUb zaf#P$3jr#)MtfP5yUdzBJyKYCuC-8DL+yV(uBBEM9)~2^^zGs;-b5O_SHtD?;aDBw zNYQ&tQA?gFyr1>41sWk>Fids-Y8{;XO=KR@S3TW%yGl42iKS_ko&XwI4Gm5BVxd)= z14bRau`*4rm6u;nO=si8DL7C>?Jz?wD=|6B;-7yO zCrzi~V(N6lpjizJeIx^x40gp`4!6ox5h0_ihnqCPLW)bNdpX_Rv zo$Z4$7t8(%J(82F;VtGT3-p9)gU@j;>A9T!BNi5NqCewGU_Y~#lOLT3+1h*o_rqECcLuE>aF`ACo?zmr8T|Z990F^r5y8X{f22p zG0H~=RP=TCPz`0Oauwwa%)Z}6>rIJU9#<)D$_@~2G|lMegu1sUYZIYgSa39l;YJEG z%^wY(k8GvgM22sO6r~+f`b2wkUp=f|k>GB-aZ_f>#qGM^a#PvZo=D4j`|PQ(n%cG+jVp_HFbl zTKPjP#@AG%X=)OyjFHyGfb^Uo0(i>N5_ZqOuDiX{`}kBaCO*Em#$+Ug&xsZWU*DU} zZPf)o7aC)F(hM0dP+5uf>##)&;H$lbF=Rez4@1xRj4;GX55wN4)k;gJIA9(r=7z*Y z`|-hju+Fxyg6q@QA?h$$N;#B#^QD$dwV!$KKXx`ZBC_Qf*WKW9bN3D6j2S*q>F>Qf z*%h>y`#Gi(R96%Xv~(cv-J#WQ^%?3GE0iS?h!%x-Q)mcArk?@6OOk2yM99MB-M00$ zoH&e+hCb(MN1qk?f$x5I`Ua?smY51M5b<=1aw238qLnD@!At#JQ#{^FixgCNe#NOz z__}@5`Lf7AK1LApL`C~v=RObbZf|GQW!>N6f^SfzhlfW9fWU(CcQQdRCOhKkFh$mC zTp<0WzO2YnkF@uf=?zshu>O~XDp9cX1@D#m3X)xCQjoqh7r_Te56k|nu;8(0P>p4x zCiky^VOWx>>&ViWG1apmF50l=uJBEB_ek4k^zx&mRHc~|3Rel*ZD zeRa`gq4pc}A*|3CcEnDxy2TaRx9^if17vlpRb^^JSU{0OFtzErm&VrV)Qet9W)=R5uLPb6~? zkSs+#?!K&z2WWYEn#Gb7M)!iESh-C8L^)ax^X7~UlFv~j9>b3Tn*lRvZWdkqC~ID% zX3JlmaMAG5$XZ_a!Ub8Hftt`w{(Q&t$qw*DH~T5q+9N+!&fu-nE`i`*95Rj$rC)5J zofrylLS5gA=4`&6Z;$f>OOLiZxbEJ+`wNisoS=clHEc5N5*ZyKnzDVIB>K#S>(&9q zCfjTB4CLk+=`HEC8BpTiSw>BcUX>QdqZTwB{h6&)Wq?NBt;ZIqGHS zmV65o9EbR3CvJ&6v5!;|ia+g2KCBZPtyWHCQ7^0e1pZ!q7;V1t-A&B+Rq}UaL_vQ7 zgxg!!0!83Bv@$FLGmpomF_aVJmI_tV^LQj*oSpYNxIZG>IO56t0Z;wkH42Rmsqx{7j#C;7tlGvQ~DAAQdmyC)G#cON;pNuPR?fKm+52 zsj2Q*D#;+7+u5+9GR?s{Z$FCcI7*o5AztYoobQ2opz?o(AHobu{A)5+Ow#;164DOc zFjN_S&T+T%1gT)duqU-i^_t8ijaMIkB}5YR1H17CCC!m)n-5;Na z{FVg?m>2O!Ii>gLpvKISsc;2ldoh}ii+|9E%Nc1oOqe({lr<%Dm1CKH`n^qg&y8ZUY4#Z=kk zNv5uk^2T7=MYhT3fJr89*~1npXh8;X$34v5st4(>A^Q5#`5DLINA1P?724apR}qzZ zgIj4b)8#hL#K?7AG$FH0*#(}0{9l#_@vw56ZFQE-5rn^VejX-8Zy~Ben9vkI^nOZFX=!1#`Uf0}8Uu!_ijFi7)Dl8W z<1OU3x>n&(dQ%IFTFdAS8WzmkP+@2ZVy7ZaXD;;#?cp9N7$FKxj)1v4sP_j zrRk59ohdmm)M+`oo8t~*{VH;~ZADj4TZ40QrSGX$nljB%=-(IcPJOhhN!gkx&>b4V`hj!eBTX!gj$|`)%b}Ih-qHO7etkpGCIYfG|Z#z`&_skcPJgbjaFK^3F@)&4SqNdVdkmAV|u**r0-Qkgc2;7ld zMfUm`rM-#(e(jwLbtwxM9yp9NS>dQ$9P0n;G|74Ya)68=xmxGK6R zt#{UDV_|P0QgENp@){+4(cR5~a3Q!%g zNXXL;X-IUqx;(6S`&zXetV1f?#%8lKf@Zt_mdFL69 zGQDRst9hHkM3`>ndHvChd@hO0cX&RbF6l-I6Qu=3>k45-S<0WSk7)HQi1XwWXvIpkK5wn*2Mm5HZA9eiW!^6XO zCxDOv>16qz`T2x?VT0%u^!5!b3KF~qDHK4?|Aao%9 z4Vva)*bNc8G|4|b;R%tP5TG!x%~?4+ajq%0fPWwp8fffcq+#&T8BR z+d1vu8uCAT^!>jxkpKE5eJ@;;CI6R4!Y<;v(I!g0nf`6Y5Wz&N^M`;H z=Kx@b&cr2TS;pz^WuWHrtYRlAfZ)#8g=0PPrkLYHY$18p5D@>^>;YeIcW74nQD3JA z1T5X+7@e}#`j1?Icu7(I;FXQ9zE7p}vSbgq%l-}vqo#M|phxQ)L^Y8%|4zm%uC5jk z#>Ku!9s6B4pl3i)X)Doefek_@`7o->b9vJ@k#ebs$ouMxBaJp>SMh#wM8vfJ!bDf5 z64;xr?0UyXE>_X@2m+rJ%G_^%Cy^4j{SX}G9&z?O!?^Nn>M8ZlJRW!(-*aZ*0uli2 zcOaeHQef@&nl6~$7422CT&p7vX=_xbo^JMAGRTVL#yViM4;?YoiS0EFCH74K-Q4W!bMJHXu$d|6UF zuiOMWoQTxlBIzoDxrM{DO{2G3A$QQ5AIcw0-qub7eliPb+KN%OsAKORQzA#+sa`uOYv2{6({?w;qnUvx=ZH>i zyc2IMrk$tgPv%}*A{sBHxvMT2&h`e6^vOPk@AU!CB29LC276nE!Vd>a;1O&0O+*o+ z;R%e%4A@uS3UoCkPAfo>WLo-vF#!Mi{o-m>;>Z{<>il9Cc(va7bTqz!Ofl1a%cJeV z=#(}WN%+g8ySMGUHc)qSl5LB*99&eKTe@aMGDHHBqLr5<+Mp+09pS%w&JGjVEG0kM zXk~1?*dC9nz!DiS_kB`dxt_HfeC|tJQCo2;9B_X=c}`GxK$`LVmC@|Ii=F#$vi#KA zTMql0WfNqyakBvE)z+oTjiSbY*7oi^;`)uWA$2IB9c>)t(s*)z6a^wwY;gm&j1)#s z!#d|h-xhyPxI=jSU}ZTGW^nz!*4P)XvApVFDZoDPrwp%{ku zoH{@HYc`C|XOaQpprs-^{fNw=uPc|u)fvZPb;=L-3DpsHw%=T2Auf`xTvvvIz}M>E zy_lmrpNaL*04d8N^B~FcrtjJ^fPuR;IZR=FmE8}OX9B}?X2~4P&OwpmNfd1_fuvvx z-qZoe@_zg+Xjp`~z+0aqx?_pW-KV#?@{jMQZbyW|FV${(7StF2{&_K=EOE=I-l>lz zJ}H1doTrm7J@HDm>9X!)R5+o2f~+JH+qrrE=r^L}Z{Y8;o``HiY8`jnqt6m-uXV#K zzCobytepO(f}{UY^6AVIJ_KJ`OKtQ<-PtE`+m)Hz@Lw7O zO&-t{z3tSUsK0wblzn-2!vPMu9tU{!=T$Ch)8; z+ag|yF~02O%uG+eJK3I!b-@kh?2HgoGGbw&27$p>6uy3RkY{y4Pw){PWN3WGhv@?C z*Lua?+JdD0f*F+#pXkOM-&(QRAa{V`czawhBqwYj*(cx(&~h)K!IRJ-^Jhw(v!M8) zVk_7J^maHLQREY8AVvhg45ANh?>Nb>q)71|Ysak}_-?_eX@`|koP@?XqCo?(`O`xL zxw#8P^d;N*vQO~S#K3G_IC&ku!$`sh^2bHv-Ra>&{mk+22Nr9dR|_l8Cy}G>moUj= zCWTo+djbA6s;GaiFKX@=}Ae@!}yXm&`3A-9$opF$pD@q=d8~&igF5)%-;RcOB{`Q<4B+72Q#2i-c3@-n-7`aQr~>&CLlM<=?oX zK1D?-m4I8S2+t#)GLaVx%TGql9ZcR^SQVmVz*ODQ`&~G3jDV6)WK(i^LpUv z?O--VhEvXl#kJ}`kW0Fu&}81r-q=hCa#D>!1_@_ex?fb0BKxs{B*k5OT)KQOh9Xs0 zbsVar=4;DuH-74k#>(TK(9e^*a(!&qLN4+&nSy!Ms87EK+RO#BcqFVCVA!wuJHGl9#&t0eo-PC*m0!uXMH~s#m5(Hh1V=@&^yk)&Syfsit*yu~+9@Bb*+^uun>!_^+d zVcG=S+`@O)JD31G5u9F!A?m2~+JHgFNXf`L(TX0dP zEhzyxvIOMcqwdMg721>ScMbb-b$jUf?`@b??Xn7Mi);1yVvc`aQ2U68T=Txr@kNto z{Iy0USMcUgn$zlc)Q^-?d~dhLc-BcSxuJ|A?8cu&6ul(=t-->p{#cc`{#k}$>Nrje zewkf@i^a`spWzh-QX|0e`4fBfUGbAk2k*@VWaD4Ur!f8>A;5nI!21Vj3azJkRisn= zwbcX{Xm-dT^ti|QzdZR*=5_NhBRCIK!mb&H^OO#h69`$x3mc&R@8I~#_dG+kP&$92 zpKxC=xyAl9l$dsA_gXqXIjdo|7jpC2)arL(Rm9f4Lbsej^)Qge?IzRCbDO{1uN_la zyTRR6Jg(k4=T`Hz5*qVRcUVxj&D(nqgV8aJy}s1tABuWETywDPa`H3>=YxvpI9F3? zZ*-t}vj4hl!T|$*kdZ0h`%!0Ck(Fk)o|n`o$ga$SddBQXbm`-1XL;J2;HCQPB$s6( zzDZd4&+OTb!d$4OsqSDz>UQ9xOMRK0`Q{xV#TTieS&vz!B14@RY!Rwe0pu8M-q^6z zD^2@0W-p%e@x|!{4Tl-gu)-G<8PKTn>auemf>T`E(e_bo`t>S$Ryfqi%xm?9rkET=Uj{0}e+T>;5 zE)O!%q|efOt+gMR=XvhhOGtVAAKb=F&T6g9JHXth9o2OMUaMjaLJy?edAp8x_@<8L zC*p$jxdW=SF|uomLpoe;unTI=ddjXh%ue2{S7)239Flk1!fp8>6%Tmz!Ie9wCL9NI zkJl{o!jDX2i6i&Z7wLW~LKA{@QN2!Qrwa>zMz;i4+q!n=wG08-U9FPhGCd80_3qFP z_Bp(BxuwS7!b9QP)nhngIu?Rp{M$atumZ;d9tO^Kg6y9OdY(5XD^vx%)~zshvKsYM zS(>qIeeBsxd`|u0ntw!Ol5GC=SimMErEXPlI7XSs1HdJ~@;1ezAI(>nU%bf#7$$t- zbHhR{R6O?7CmU`?P#SA%v1MlEKF)=saAQiID9<)EjrKVRFo2?K6L#n0C#?EPwc8F0 zIkWwJX!DH%2gwBSR1BiSwAA&Z3RdwJD)E9`e|5L9LQ}`H@U69lhNY4lmC_|yT(}I|oyKGxvEWEjtcKQKPj9*-`AmPUy6YcaB3h5vX zJjqH!P+>!Us>czWjtF1$=ouD zf$M6#AaPSVRB4W1kDi zxrz}V@QK{1_0<(jv&TD(36haqV(u!?ZxcC@Ecr9f)MR2f31K(fTz|M!oYZQL=A-#k z9E5k7!lNDP3rbJN@y;tZG*H8B(YvOfVZQVB7DxT6$48h|p1ZjrS8?KDHCY$9pDkFz zs0c=O<$WZIBqcRIB;w`0`mOg(l{P+yDwEKjQ>=hWT+-B!>i~_ERLB;;?T>cpEd+4} z-*H!E-vTw49KapWmU-CxmE_`W<%S0Gh1QbDN@RtOc$&z}}tTP9-l~26U zWCPoW#F`Dp?~gSm!dpE%QV|B3k9}A!KZ;SvJJqGi9mrJxGbG<2$(NaKwmF(dxxSDb z0}|2E#R^^?fXYH_@bE*7nd3Q2Ce9LWle0!V*^~R@Et1hjm)Fpu;wL{Z@W%qtTu!Gd zra<)NdV>K-oO90sf!$xCZ+G9qylVDYULRN^uf4z*D`3$Zy`B~f=zfz+H(`9SRf_at zNX2>!?k3Ze`spBalw16ABSkbHkPTW0nA-mF>J3sX&*Oc~8!>}xRUUS(%Ftx}l{pu5 z{y{5Z*O>R|6W}nig=kSSs@4WlAy3L!Vth6(JMyWPcDSU2m*HEH!dhw#;l#l~A5PBW zvuaNHnW#*#Z;wjF;dmAX_Ue?}+D+?R<0WnC}DQm9|y zYchfpP{P7v?noNS6^9S>NKjj_Ke{op@V$_fn1QP|`RUZ2Z0>}JT`*B#===tWjitu# zL0SAb^0cS*9yJ_#s+R2b@M<^O#F5HXn)RA z%nis_O^6nhxT+vyu-;T+$hVW6zz?DS1TVA}f|W$5ZbTIue6{sCKvo zI_a~(s9sX&d?a-8DjfNqm+{7Xt#v@1hAJVa6{qzoRJul^>9eK3UE9&DH-zgLpEZlc{QR$>-~pd_KcDqZEs z78^zDdR=@o$vS_z?GH-+4wF5ogQs}LW0Wx7B8YNDF*n6wyD~B|A?&e5@|u>t)>Ihx zT?%h!(CeqHZ>zcM8*`UPvBSkWs(h)S=!`=CY2#PT+qXWA$dRUom3(~c$I-UAr}i-H zq$K7gxu^H_3Zr2)k z#yw!73QL`#7Ifuiv)EceX>v{vRn(N{OE>om$z|TUPFTwD)YUT^dU@Ja7t&#p0*?WN zLyU<08q?!YO2mc>aJ)dPo#(3`%9>S0hY=OZdwMZLGpvQ@_4ER_<7O`sQatn39LmE1 zgP~+6c11?|-@bps9*EWE@<(4jToeHKd62JDo#9fWQ@i~1Z)s^44T@_2s<(=@QSyt! zYt+c=@@U{-6*DU|d*#Y?@_H*?@Zm7jCwWY_r#^7h41aWlil;Vty^vzLcz9!BE&JN8 ziaq#X&=_eOeKPb*2n++iC%kna@Mua$wKhgqu^c3T+5TDc6HTIWp^_%s_{PC2Xs!Vn zQdIg-PAa;$8&olqo;@J1f^(0}$7vjW7{mf33X-aAS25D2aaFESNcxq8+7rlatPG%? zmkHgP{1?(Li`q&fx<;N$$CxwZsFmEeZX|$1LT(@BA|&>GJ5h0z+`)}J8pER|cTNu# z2t=-&lU17xdTU>X189IoyFrUYMd5G`WaAX6D-uc7k131L<6j zuXbwfOs48V2+Pn~3Q6pMO%>mZS$=Mn^|AW12|XINX znpkK-uf-e6uEA;(m9YDw*9sX5JBz@nFOA6$Ja})*tpYB8 zNVKdM+|?Kjz@kuv3-lI@l%`Z|svf4<2_9kldsR|cP>>?JPEAe{O66f3@7NbbgnG0? zLVZsw$imDU!TdHJ|Vi6IFXX5kHtMPnpafjR=gVy zpXeA3LI!TkFWVYldOVw!&6wOf>mjjUJbu!_Jt5h2@B!8%5+9lmiyrQnd0d)MIE9Ex zatIL3s7M5~9}bnagphqgK*TdO5P8H<9|r+fOmLoiSu*|>0VYC}I`> z+xqsG-guuoe$`q5BtG3NSp?^8QfaWIA65%2ayzi7qjRbh8^hlDeB0f~P5G=);P`lb z<(xMo+V3zoi`@38MLM>SRFjMR-qI7^$?M3RH|AZtm5`z^a?I8p$rirL*op@K^|eKp zBtBgGy3AjU(KCYYupBv1iH^9`aQPM5#a1kDfX+JW_|c)|E>OH#?S>n=-N1k-XHxYX zYWN>z-AD?k-ca88AgLg9-eTkf0uvdidEQXO6KyQF_d)(BECyZr0!nP1X45ZFhKVNrA4O3zVeQ8N0_kGeXD?W25 z2+S=mW1+F%fbmj(^PbtnT9RYRr+vLg8QtoBd^nO5Xw7}mhC&J2n9PO?l;ibvjbzD` z`Ud@@4s7Yu$Z2PD>im4T=enRGjjE8#-pZQOIi|59#sdBU7OC0FPl3m>MpDJDk=`D#!SIE%lkYu+c1|XS4epn4o!+n;!haj zrw;;}t$9u+oI5*4LnOaPva=J&ww^aMTh%fXz1Dgq8o9c^qYMWvn`dQTSE{E{V2||o ztm$7r8c_NLMt;TER`|J<2PnAaTX@okrY$PozMPk}Ak$!oIOEOUAg(4dgSb-M1SCzf6pZl4`8ASF5R`a`JjIC zQIU(Tg~&0wFW<)EG~1$Vj+%Rl_98{g@+ST88DSzeW03E#acVBb^ZxTQQ3Sp;GFH1e zh9exC_=uEg{CJTUQ6~WHEuahLeZ>AzwVGPemC@PV3wZJPIcs9C)`}hgf;lOk*>`;? z-gh<~lX^FK`>X5Md&)qmftD{kGwZec&SY(^nV6WQbo|fVl?6-MkfA4+6YAYj1Fbz5 zF$~NdA*roY&l1+=`28FmrVs_7D9V6`mn1qVfZH50(YSQ+0&H>u2kLlFWLv$)I>7Bk zn9om6T+I^c(ZPH<7{Azd*1^2p(ZSVQSZM1u*r>Sz<&z`nrXwO{ynOFnhR_KR84jlM zeG~OvJX>yV#HC*iS6AS3%5WS^X~&bH;~a_(#i~e@H;@ul`vVW^=!V- ziLjD2PMHuj;C&DnF>-g@hOV)n}2s)-=ptu>X;jVj+V z0TD+=UB=nvthhyTW(2FQHnQKQ2ad?in}c=DLASNF?o)k{35=kR9|kA;p6#5?!8tckMl`Se5eUVcMnvw^g| z2X&a`6Y90(i5~F9H%N-4**3??B3U{UN&2DCR_08!2PCOFqu@`yu-K2Pt9oUXPvcM)o5f-!K{nbK8UQW zPdq`tH_)c?cD<8-%zW0lbQ51dp`0e3a>Fp%#|NGdk#DAxa&)$c0GxeUU!kM(b64+_4|;Hb&j{%kjP)5DLu+u!wC#1(lr%*@t?*U zAAqc<)V#Bw&m$r??{=;*IXa55#u`(W3*^&Xg&&A}Ei6$Yt-P%kvXFwEt?t2B6I7(o z^F*dsCuT^$a}EwSak8V~&FwG^!=w6rbAcy_%4L+4JPeGJ3SN_+o$CsO!ld9eV1G}* z6B3a|!e&%>1Of!ohTX_ouO+23OCKk07?-20p0XGAe!oDkZZ_RUN=3`fz;9pz?!_xR zogmM5a33JmOPQ6g$1S(x2j%;^)@HU{dljw?G`FojK&{F#p8a@Ai&#m5a-d4T#&xDD zVn8EhYHq-};*fQr()vWgDr=Ql-s;~Cq@_B)UP}M#V!Om+WZb_yIZxXq{Fi3czPa1~ zamzg9nwaL_9j)`$=^y^xe){?I(7!uKAMnWhk3PXX4ZBh@4iq6jc=PY?X2vOEy~W1H z{;wYutp2OuLCF8fG4|sf!2J1a-rabnfTWcbBQ)1J1DdHny%{nHL&)z;&&I~q*WdpZ z7Z>-yUP9tg;;&4xVp}WCPMn;a8_xXD`~2OujgtJ-ScDY*qJ9kx+(bk~Qqt02U&%-y z;Qli=2%+=yY8eW2&|9t{&h(V*UB|9oW+ zSfsGf(CqSZ=IL)CA=jb*EJI*lDu)^8oWeGqS>tm3T6zc?|Nn4S|L+S&q@q=8ruB=X1~gk~31zN$KXFX+gE zxw$JwJ;``1N;^EXEGZS)%6(L^M;i%U^ZuFG_(j)V1 zj8jdIx>{mjVQhX^3og}~+VP>hw(35;XSLUUw5~Spi9}xP7Z_bdM1u)BF($#w+f8&1|BVZIpOs18|dCwVBUq+_g1ZtxF#Tv9HVwobPU#4|wxMvpx$dpE-tc<&)%MKF{E-)V+-f_c(ZM{ zC@(FIoPNwmKkWJgQnDUw(JZad+}}5YD|h(*y?Mh9x;@lFZAovA-<9CMlcUJ++yRNc(0VK=)H_Y27EB$=!C{|nvEQOFa&m|aX92{e~nT9g^G)K zVZ4Whc^_qMGDD1Lajw-AS39KtA)4v->*XZ|Q2;EFOw!g6An4ap#py`B3pow6W1|m5 zqii2F+ zd#}|W(eOqc9jodxhT~r*JMp)tzRulSHq;ag;&f8I%WU&_z3tlO!F3{_v#Q0FFQ~q` zy}50=wxk~%%{zUC%8-#kz>feBYLy*X)Lr=4Wgnl$6+EzK^2Up6U{1Atze6F4OEMJi?meZ`-vZ-3n_%;^?2_77@|C8?!TnvToR(hKqS)hIPGimt z%Dr^=nTPci3lTXB1+sZL7?*M0I`^^GNrFy(^8yI|K5%+MfIp`ve=x0@pawvYR(8MC z{%S2C9J`<hA-6E-n>#0+DTAzw>QQ;AI;#jzC}Yx z!ewJ1^G-}>c%GDgsi`m1)|CjV3>XtQtgfm8)2@v(sQZ~1runh}pSOouwo}14*5gcg zOpLUR>3!IKA{#r#x}*(+zsmGl<2;7QToXM;;S*8@0O4||il4e)?5A)6LmMzC3{3D);- z%wE{%E3|)Q-184}pY`IWkL7F;QvcpcsWT`1vBcu}UL80gxly_+K6%#bc7Y0$$5!eN zyJSnuz>)P$Q>@>F3WLeJ1Ml#VqSBqKH0;WLKC!l7o8sb1t;*BnU2#@s!pUh7+=Q(R zw{M8oIQx7@Lt4%;jDW06@ukDui2&0`-@=#Pp*}t)?Pt`ay#dFzwhS44`dca!;ziO? zlc7Twa1Jkii3~&KrT(geiz=&6sb#Eoa_o3ssKVj!e3gS6I=V1wh{^FblM)F6;Z@St zm~R6~!qFxpX0h{Al2#>YgYIo1Ju4Db0rzcsuY8*(gRLxI$uVjD`mG)|cz(7Qsf#W; zu-^54klFV~JtK;g4ew~Mo`}Ca+nPk2l5(h}paPXBh#0&t>E!^~Kwm5O4vsrGv3{Al zYMVi^Pa5WueTd{q=dx=b83#P#MRr1jAOlaIBtZDF-8*jRG^?~(FC&89KA>(Uz7rkZe#){>Yq4Y*z@YO1*(>qiHmkJ|_s!BY>AEMKMu8sqrP{hkko zLun}yt1~z}TtE zwR#0paZ*q3c1@YkEK8AyWZ@+HGn!ChD|bmfYdd0-@)~laq?~rmtjuBoXtMf(hbBUP zb%`0;3S*PH99LfGia(wDQ$>bqUhtwJUTA`(yJc&6RH}?n7iyr{C>I!TRKl6p?x>ZG zwVOAaO)aI#9f|dWh^VWWZyO(2GD58LCUP5v)$)WlldZGjF2J5X?7^d&1tks2<2PYG*>scS~={_5mK#)M-*Bz zZ1CD@f+vek3US}S^1Qa626|PHEpyXXxzV=R?16 z)Zg{Nnji?YrB1O+ywrrwl8Kh<7!w2X1?vi1;Stqde zq_X#40NsLjbiTS*@D^-Ke~hFH#hQ*M8u!D^n`cC?-(U?Y0$%;FgRHJYRMmn6ZN)Uyg4NZoh;3)3GSG3Qi5&s`kuA);#`HeC2T6|M@mu4*r;HmjNyoFtJJ;?2H5tgo5dM&+{f!)eesTXjbZ zf0}*#B%65e2Q;MnN^yP1*tElbuciPhUl@@aF7#`M&zBp{w>WclOgj&{Q=>00&W8T8 zcJkf2?U6P&*%t06iICy5_PfjS(CQD{dNpS@X6p@ZNX)RR6KhmJ2bQ=23d%r*7B6-f zyLpk3j$MFh5i`-z2#58(y_ne+vVryS`seo&SfMbH-Yv1sHK{y>QE@fE%XUWr3^#;> zP*)bF<_umVwyUM_ksqjv)2WNG}#7K@IsMNc2I!H)3ylocVlBf;d z?>_WFu4F}YNLiHtgYJM3xvBL5t4C^#vUjmBsIYyM{`AGd?OXm%Tyj$okME<|3?`2^@COXHz-b?vV>o_H`r8s1DrVs8TJCC#u^AO!G$xTg zMemH9ghfWlmG6w|Mob*LdCE!+35Bi%yl?5^i0qC`$12^~*|(NDL3~-Z?~PSAcfzCY zvSQhTKh<(qeH)u0;q0<&9(u1KVawDt*Kk%E(H}eB8^2<`cr9|TS4^tzNp_dN$V+lA zMn1s$qZHo>z(5?OX!*cWa16qiZ|Lqa_r?VQ;rM zIwPy7RA)KOb;80{=|@P%b6#2q?6KtyQLHoS#0YjU;veWBk2Dh$ojvgetuf%V-3r#8 zk-qAR@#>rq!DZc%L?z$V&keS3SRG3Mg0X8QVImA36~l_b-6*CC9*O(!Y?Xo~vAL)N zf~hD2$9spuAByS&Ix{dYA>Lq=j#|KWUS-hd+JY+%{m!w^ded7-pBX6OP8uv`3NjU6 z`t8%wgkd+zb-gG_;&i$%L05sjJNx{|1`p;p#7!+;o$AZg=fP{orm+X%4;K$;I%|$e z9c|=iCTG6~SPflR-{ScKF-JCgT!cO^-!`BjFpfk|n0;&VtN>Q+SU#M)Bq&d`UVkHO zx4>*Sfc`%-3Clv_jZ^z?w@UE~s0|)@l4y`0ev)XJa?m@xBr$k6*IX#>e3dj2yuR7< zwK1zu79FpQ+4$IH>9VW1qJox#k+_gv@|Ve@ zvp==hU`7Gfu6tJgwL}k`yaX6Jy249)Jx?r#z3uCzCj`7br`v|4%MN2kb5>WL51A|t z(?`!YAMHjAs@O@IJozGMq0Y79+~y!o*EN>DMQii-+KuIUJI1eHF3UQ9CB!;yGuA02 zr#H`W^dhD12X)ELjq^ubf2=r~Rtg-8F*gi$j#c2VPv*I80FiB)hTQQaymVxaZiUJS zhDrgAe*w~s7vS354&@IO$@93*0sj&4u192qd+qxPLknNp!u+Ku2Ia@2;4l-PtUfR! zo<_V1Gc(@zht&-Y3+i8op4>8we~R2#+gfmU45~S%oOY|emFRdLE8I+vjgpYCh5y$Z zSdt<%UmHvCM|4wg#}6c#UTWll-av~~riW#@{zoxH%Kskeg!WcE7=z{wk_0~*R_ISr zimu6QaVpZF5Gmk4Y!$myUtiz<#ob#6#qqWKz9d0HumHg=xVw82+zA8-!QI^*f_rd> z5G(|DXK;6i!3PO4*x-6Q`R(`Ycb}?z&aQjU{pYTtiYb^`-RpU}d%8c*cYW8y+#K#6 zn>f|irKP2?@bGYFd;UFbEmAZ{r>Uy)d{q#J z%Hp|{^%vk^;^0Wg%DyTqD+Ab&{mw|5bSM?Y3uC}&3E0B7`1lW>K7GpX#>Cvi{tqq` z_wy$m;8*l#&JP92AFE^O3iH2x0WSgY^Zyd9{W}D*jzeEppZzn)e_=Mntkl$0>Cm@6 z`CF<%_p^ErYWZS7JpZ;mxAgyYO#kfO{}T?UmH*<;HsV%mm?8mGCh~OeG?pz)1h6$@ ze^dgFAb@cOrl#Vfc859h{aXSIG;wph&yZPCBJF%(84J+Mzk#KC{Sru9A8n3z*NWR( z8GK?GD#JfS3>6FDc#YaDKx{Ghl!3k%p_gMpPJpWz;J%>|M2UsDCtUUyRdPssKi=#D z>G|jA0JaV66HGdkoMMXhUwxU4XU~2TjMK_ov7Ps{qu}`c-=&;XhWGAD+3Vsp;j^<~ zQ%+jN%pUM|Mw?3_^LSfFJ@$Nc^fPBiB35RftS=}OBzJ>r?LAZTRxRZjMDoKeAmhE; z7pZaR`Qu*}YE0=t9#6O8BB}fGOd~tkTp7R04$AA|VtuwlDeg zup8`9E+EP|ATp1HZNe zVBKZu8cp2{Ys>=OMpMaU2P;_~dIRrb^pRb8MHQfsi_9D`ZLq@Ew-fi5uaI zl1AcGe%{Y_5+Gw511p&yu|%IiTu+}ywv{w#u5ci7qwRZ9Ha zFjOvTfH~~k-J2>fJE#&9gH;veI%K92Z-fMXCG$3rt{Y7&s*HKRGK=j*UKj1mX11%| zT_8io?{3@;SRCAvJfjZm?gJ(#RMJ|IiZR_^z7a}1giBwnF{K<;&E5!)$rT#)nyakx zcr`8*Co^21vS7P?;(x!VWc1*DDmabfc^x84n3 z5DzmMI>Zo_=C$5TX`Ps}$Hs7Uc==yY*F`aYk&gk7hYM@DJB_nHnlBRYA7So{kCS)8 zjpSK-`7k1h@KnVT_v3qgJ34Z-t8L%#xk^dQOnrBnfMHm%(iM3u$;rK^dfDoRn$UtT z3`5Qykomo7@2P>O4#m~=o*zqWG`%FX?5U+?+nBtOH(%^a7N+G>AWm~aN%JG0^pMe6 z>HS>{OB3Y|h^^p@L573Rz&O2?J65@~fb&v8hoP9c^-cJAT$YV(^_jYWZ6SPfCU+Rq zDG#*QHhZ|Mf(Biclk#2@>m7rW_a}o$4$pTcZCcy8X9_cAuv0Zp=+bQYnxNG6U(%v% zJyNdRgG@w6_BQWb@&qI2!7*NqzRs`X29UD_dULXUl3@XG^&8zcXOXPe)ooc75>l`_ zupRC7qS?L0jpL@S4-R-BE)HEo95HOpyEzxZT+}BjR!K?!v^wd{cud~5=p1ZVn1_^J zcW|gT?K4vSNeea{u2h4qvC;{7ghu<&u!QP*AhUJJWnf%KqLie;(PAHYMGm%ITQ9p5 zI%hXkcBNva4MaWt*yAfsMZ4@Nm*8xHmirp~m&?k>i!aMHIU7lOWyYsLaCUYJAL492H5 zyxXLwx(y(f%s6)Xe$&OsOw2d)K}C^fZ0!6*ZLR{RN607r-P>BU>3holn64SJa$||f zPnrGos@0FL9h_3C&TeS&XU_!dk1U=smA(@!deK>7+NMbZg+BX=dtPRvJLK($qGg#i z;=O~TtJ4(bu%lbw>hdFW(Z$!Yp>&s-GXX&8z#KeNh)7lN1;_?*Q#!1M?;|*jtWweY z9dr-Z5`)|D*<;@q#n=7PyzXmj1AT6RLV$*-qTKuIM`rLsbpyhQ>VqkL=vNHo+B2MF zVRrNbXMYy6z!iTVO7rwoC|)6BdTMkX7tP2{S`urGabZSmgAGI(Z?wvja{4?jO)HS4Zvc~f5w@XTyJilEE7qq&3wT+>a zmP6TM((PnqL?PG}?MOyJqqX+9%mog~d7Bn!^Ml)33UiFDPgdIw-z)XjE|`*t^zdEX zeN|hYN$sJ*L*JK7P9S+28f=0`_*xA?b8;x~im*MTI(VmS{mFYIM1xJ_?<5sh#CpW{ z{53_!3Bo2kqI6}Faw6da@6H~ELG~iA*ALuQWwd3G-5w9kyhr!8#&RB_TIR~P4{UjN zAACo6SHqltQgHYudN)rlhBzpV(Pg3|A!9pASHJ%-Mj7QOU>`GNa9# zk<`oI!;c!($Q~75!{;O5JZKS>-n;BjbfdF}xLWGMVe=>!bf+^T;NXujGAZw4{6?|+ z?NR5Vkbn!9;`f`5yILxFlN~-@M070D^g;MLcJ!G`+ev-!08-kw^&YlmV@2t%)hYQ% z1pFAlim$uFcKJg$Ueu&K0bc&)yMBeI!57~8F-Aj;zavGRv-aov21exLvk=F&kmaSH z8_m2Sm9<~Dk2UHt7i7-ixw1_w&}|r%<0sP|VPwpHJ8h$nj~qp`O|@=AKGDk}a1-nB z9k)-IBiI-T|Hxq^T_9pQ6x|+6nvnOE7mS^dIkFE!ah;SG{Vv>+FhD7m+lY)i#<+E+ z{ffQ*BgQvz6y_pmlq{j79Q3370sd^i{&}sGv@0RsCPx~FKr^6X3H)KVAjMA3i5|M~ z_9y^JdXsB(l~HuAs5y+ZH^{-37@Zg5D2YuHWz;p}&`5%g#lcBnQDlZ->u1Y9zCRt|(xGSZoZRHXs>yBzvd^!-XbL+zome;fh_(nzJ5g z^h{DIs}H{@fYO$j9}?nHAGz}a>Dqb0oCET_wl)>Dg%(*0@=?(_^W6GM-o$i=E`BA3 zMXdGI=l$KHd5DsCJonS%aqDA(7ym^@!M%6IloADp{TQ(Td`F6&cfjm&l*71ikKJ+@ z@C#lkYf}y+dfwC>N=Z-{hRUW>gh3O6*1aBYL5=>RbVB*fNFK;!xL%A43A$kO{&`X0 z!tvfcm654p=fgDAo%T)KbT^?Na1r8%;LkO&@{gV{sST7GL7@H!~+?YE`V zsuTD>F|@m3v!lk7dBTBNG3>2fvFfjWk=K0tc!_(deO`5-2_omQ4nfNp^_^ry)W7hF zJ|C~fZ89OMW7cl~zpT%pS(pwx=&+E_jJo~xe$!sC3Q1m3>zfcmLjJlP|Kzgt zdQg_vtD@J-pJgw?O_xX6EKRW{?8KSZucGF8c{|bUvY9QJ%a3FHjwf zn({L~`N1fCZ3@Q{KEEB*-LeUp3-7o7ZoRKUK#u}?PJDcJn(i8<>)z(lzI4bYn)Zwj zznL6SRL}NI4Q8v$&V@@YdQ08u&|k0~mwUtkw-x@_Ah6u94rSH+V~p4;SvR!bZumJIDxl{E>D@`mYLI zJnZZdX=vgJSAna*nN*{CU3nT0iIQ-X-X4UK6$kRRJO;PbYSAPm8MJhS;$WKvNyWVD zPz*;8&wX=I3BgLmk4;d@-@54Bel8a_S<8JH zp_%eN)nQE#Mc7>O3ukteq67yenk)OLw%OH~GO28YW)R&=E3_v52M^fFxSbJ8TC#9UnNmGwtgc;Yu~9Jitz@AOox+He)+Yide@%~4mCo>j(`5?xD36(z7+TJ z=mGyO6`3knq$AaV(xO;6#73J2`Q2I2B)paA`gFcSlp|aZyXQIEmsP(MY?2odOvlKI zwV&6CdsF!mmfEDmz*jk2fqmt)&sT+fAPfgTXvZdvY0_qn%C@`QSz%kq>4HP8to@kv zLS8>PhrQzSXxkdro3?hGEn1MLQ=+N*sNnf-kAj^fIQvCJRu;>#Ir=xOy<@KgmfonN z&?@R4ES^bDjdk++j+F=!@uS%9Us;#L7+M!*(*ZSaGjnxZI6b!}HY}`4kw<(b19EjE z%6d%7>*isc+vUK(ZOt|cM`81=>CrCs^kV+*h0nLBPezd>yT6O0YtV51GEDf~+du+X?I;d`G<@>NlC+~+GXFaFWu zo3Z7AzW5x+0mOiHHjZ7qvu{Kl?*Uvu{N+hN^r0$XC0pE7g}7dKOn~sFgD=E}2~sr9 zUG3hR*D&$}0vF?;if?1rGqh)z3oa>JL)IXR_3Dy$M7J@1h!yRql;ZSU9*8VV*SSz1 z%uF9#otL}r7vni}NRubwj$ca9eg4`sK#vwfggvM40(fHR2UlWuNP7l#Y38-33vhZ8 zJ7m((bCREbaHPqz3B7hc|2YPw5jvxCQhExB7JXpn5rhw%jLK+9{pjEzpLX|Bir03l ziHr!po{tnCM=B+fY=z4QVtMdm7rHaz_jM=w&n9>>>)$?ukeafPN_>T!K?aaps44li zJwhsKrjhC^Pa(v~IH7uLiin|%OMVSLi=#nJHN`89!8woajILld$!2I4o?ze4v^OJ6 z1+`m6tyT?~sPC48ZkS}-HNVIZDzRjZ#wLsOb!X(|J}^-hiYg6uPYU~~iEjxwJUpyn zyFdASCBuhkPV$16ZHyS|6vQz3p$%E-OJBWEw)U=6zAY7KoLCSsEgp z%V$Q2Uv)ruM_$5xCWc?yghI6BbM~)p4>3Uma4iW3&2qy^<~1=Q4H&~*$P49Fs7%;) z9U-uYhtQ?6(Y;nfSw%deCjQ$#?iq>L3?2pf!^W(>ZM$h;I1A|p|0jW)s$_<7I85rJyzs4Y-A*R&Q3ti%SzBJ&nTRPakMl3%B_g<-;-L8Vn-2?$|2C#3}IDe$mM#21-ovfGzW z2pd2fRSm~kSL(?@^wX^6<(Y>j|Xp#@-aB>Dss<@dU@&KJXqd&Xfr@EYhA z{iyxU_J5{khWLno%x3=}uY9q;Bf?Q=0hXxz+v5I@i})Ua^+==y;Qyoh_p}TQ3{&LN zWb`+0$bn(Tp}+q#5TNC+S^R&-IRCC%<9Q6I^J?qsrvNwzNaiR=V!}|r<%)#@l0q?S zYo?DMKgOX;iRUZ*qi7_c`Xl`l6MLapl#rPC29W0c4J_76cylYOyxiOX;E&Yb#f_E! zQAQenGqU zg$wRaYP0tFgQ2rG&7mu-!lSpcdhkXl<281|<05t6f|ZgSd-iW&aT9(%+b;{`a!jlq zT-Hw18o6+ldI_^x7Nv5u#^M0YkX9d>gS2A;Y+i3iM!TtqsZ}Ly0uozkL^z) zZE@?Rk=|+yzFOxcS^j=?hj;q1&WKh48KJeB0cD=sy133%@UG0-)TCFh;WtHjT|_js z2{EsVbPN*AojwC%ru->U?uWs~)gAKk&m`-AQ}RVR%o3gbOTrhf0ZJOHOlMfs+%;BN z{cO0AemjH%ZWjM5(p!ExI{9kzDovYD+RT#W#^IFjwv|A%-F9fd@X!zIy~5$r2?O_~ z@g}R~&k!#Pm|ff|E0P zfjkabRovFMGa1(#^uQdjZ&|8Z#3mWjlbsfBwIgrs;1GwdB#xLGuQWCO+}(S9OuDTQ zP!JVaisIz6Q8GRfVv+F1t26K5;dBr?i|D@;)A5f!(TLRhHyN?!J9y97FdM)+1-yIWvh}1lwt6NI>Ot8n1ty2( zR^aRX$GYrWN7Ypq5+ZVCzRUwQJf_1(WheKJDlqa#M8etO2Ji=oUU#li+W2dHc zX1Fre0oXmr2RhC4~f_4H|0396yO;gy zk~0gZH$iZCT%##FW%vLY@jI{-xJP92HzLoXai8AbhFTseRL(KVmxt)q2*YXMMP65B zmnoDnc#c9mWqSiMJQOgm+^KQ?X4!($PfJr}7oRKvS&)O-q7SH?M6Sm@(Z&{z=jZh}?S{3|qez8pg9+pKY$7eP9 zINkr~))aYV*He(7q5N(&*md%wEjMl3=P~2z!f~V3E%B%rCpEEK6R*qZqF$BcHO*#GwONyIJG*K0)=`r3#3;_Nf~7c z^cGXgNdv1t0nZN^{CJy@=?F8N`Ca^xkiH=LOZTD%sD$f-G^$`aZ4fUZ%bvRM(0-^RlSf->qkRjj55KP?c_d6l;1Q<>5+L(u1Va=Y_jik}j9fGw8ck_a7+G7aTEoq= z62{jI3$1w~TqZ*tr`$94991osRpGDJe3(AhC96f&oPL^{Fbtv5s#CYs7nAt5ya}i` z+x$N%@fYA^%LPV_ntnV5zE>UOztl!`S>x5JS60;q^z^G&+O9B=DxFuhIrA#KE@UqH zFQQ3AL|;o;xdj8$D)S4gQXL*7Hy?Uxu-F0U`h{+KiDe}X^Y`DUaz&2K)hHe`q6itB zkqE;PgKgiKnj_k$p!F>Op=AUFzbVO%%fg{*6=g=VkZ`UfJL6rG-Z`f=AsMx#z|b7c z(fw_hv^9&Fs)v|aR=sy)4rDL5^z+>-3G7@;i0P#ZJ2(Ar*aqi|++z<{)#?qg%$woQ zEw(sLn+J4Y;x8D#>I|_9Tv!TOZxF0fM#=ftsoi_$qS~)OkvAoDVCUCHw}14U5kF8_ z1$hr&Mq5`AF6gSrs-78@er||SLs79{V9h-Y>kW2N(;?ZysW4S@ARTLVoJ%C8@?CwU zUcp2)Ta!?|LJ~~8!^c|o)qWel$FQtTS1nv;`XU|pQ#>Q_-Brq1ar|d z%p3_WHq55QOcv&C@3$TE$E^uLXhis@upAh7Q39su6F;TjgH`*HtJCUn5mfJydMYu21->jjH{~BtF;orH?RaeTK zF5lYS5vD{dyxMiZE!b{O>2NPTrHvTE7#+UH;!TR-`~9MuA$=KpFefvt+dhuNf?}{9 z<@A&Cwj7mFE6Oy4{B@4x5106KHmfc|bjtI{HiLcxxO_IZ*$Pb#MubpCB!?kEVP9CSXZc1J96vZZ>~)^y7+teh0(^2pZewV0MC>w<>k0 zpzVzH7%J)5@LOv3`f;l@RMg09Br;uX4WqQgqpsuA^%vTU5~Vt7((0uF22}BJ`(4Lq zvy^Z#kxNo@fE0D=5TVo)ef~yfJfrh-N&uxupbWuLjP#cq`zME}nbZ9BPKe951Y$;wZYuoxTB}%4yWcNei=$Q`Rp7KU69MAUI{ptfJ$n6*gafJB zauF}07EpJ#3O0lrK~9tQT9%|W)l<^pZLOwg#OQgjr!4;dl6}S%YewtnTjXmrZKK_f ztv|xFueNi3ORQz$|FVDJs_|+uA-=^nPGj(z9!z497P=BM!LgF1LAb0t+A*_}tR#-HK_HJU(53DE_i)g_f28&BE~Qi2fCi zQDQksU309D8X$Bqi|2})v0%n9#?+fyqXqDjgzlXUp6 zr06M0DsJS3?z}!)EE7s%67(i5N`{24hxKr)Z;iG5EjGJlejRl6p<&gW{r21=Oi=!b zC$S&VA058i=fk&Lfcl~t-wf+fu)cz!>A%WcTeNoj+29z@b%uManxyUBlHyU4O4eF3 zRK_(fUX$4!dvw~Cgb!77xXuEv`f<6CUt2nFES=%puPO^(?|1VpD-!#X>7pD8_fDc{ zfHj)&hVv1EuZP^KclJ2Kl+3(WnM_S|ZU);A$8nDPHc&{eaMY}N64pa*$9mK>m|6?T zMg;0pc${e_GEc+t)qj?+gli-${?2{UX|1oJjO@qrpkJ#BC;&5OS`cjQ>~a#EWau(U zbxA%l#*j=XR-N{DRki&Kzt(l;3S_gGZgigmH zJ?{vG81X4u1zJ(w?DpEcjKJu-R~l|rJJY?M=2k~X)z-&xjS`R71F#G(zU-jdBa!| za;9)sWy)lrW-EkP_Nn!p_|CINsRivmfCU>Zq&s{idn|K1IH*nIWsw0=M!gpS$QY*v z3$5}OoyRYs^X!$FK)BV&!c$EV43TbyQpg#JfIT?3^L)BVGI}jET=;w|-~I~1`^xS4 z>5M}wa|Vn55+)0+SX=y64-{#OtE<>I=IEGJH~X+r3TeE+9P~^srDwS?Kj+AqbTEgKL_fe&SeAKGZa** zM(50cX#;(^%(3{*!{IR^Qo_SrAMK_wRVB~geksI3WSg`fE2%KLk2eO6RCvnhd#fzj z3D9Xn)>?n+_^+Y*PfzxTb&;4UbtM6bS%j$ADdKuj2t`i}XFG#Oc~h@z z+_d+qoMc6%YyN#BEJmB=*KTZBn05&`1bw@nT#V1}lBr=4+5@_#Sg`iqyMl3t243Uo zZOz2`_S|rqHIA|+x??p3n=h@PjQ~Q=B!S1ZUlR-r>fMU6x^&z2N|auu%7&ds&W8Gv zp7~#g3Rj~m!$Fgi5IGK9KnpIyswKzJ2!|6PerqM*>xWv3{2MNU#}C ztEP_Qu5R1Yq-@qk(+yO*a;UXxb+uy)H8IhwQpfeE@H8Rv7{;x)jE~4AXT)NNcr~QV zX=GM=OcppWZtzZ$N-y@{tl4ts-ra9(*GXhIlQ##Jw$d(jpZ;U?@RTQJcX}LSmKsj` zkcGuYq$om^1sl(m!WvOo>~vp=UKsO>IY-{672TJ%#Ri3Wjr)rxGfkB(aE1Z?x(GY1 zw5W>ImC@6#U(v#LWWjmf>|uwK?bS`mrRq1{2(x^F997+gLIGtbp+Fb540}1;+f*2aiRNk%mu`s9xKP%><kyct1jQJDp;g^e=p|4Kexj2{f%>TQ}&A z;lg_pKcr0Swb%-06Slh>p)0Yj%A+CXo`zFN`h)i@^GIeZ?@A0Wb6h-do?O-|)&dHl zmrV2EfV}yu8DGD%yyI9>1S$BKxp^#13*Ng_cDQp?w1WAL0B=2&4>x5EXAk8%_%9GA zIA?f`iXNuqS~%n}`A50V_T){KwhIMWH(2dPHrh>G*~FyVEeKPtp1Iy=v8MTfgua4W zJw@GE>oL>4aVR&Lt)1s5=3XLET2M6THv4Pjw8tvhAYNVv&=Q3UCuJP9W@@aZb_X(w zvCHH2b(A!6inQPSPMV|A=~R2MTeCIUqG?%$?bl1)1C}jz$aqKQ>v~{w_@y#c>Il}R zJZ*Pwv#iOBH`MX5Sp0q)jnvy1k29w!JfE@9VfrZLdN}aPV4<^?n!`g9_r>1UB>Q_K z+Kc)$dF}gGD5Upqy3tuUgSJXr8f|JxexB9Xl1b`tr_WXhjSJ^!RbCK{s)e&#vy^oy zXw@x;%GsrlB!ietFMa%aO~u*Rx^(r%U(}e|tEvS;EJMC|PRqyN*2Qf#Zm!8JH3k(O zYen~pe3N69q53f(6OHonVmf(G%FKu|xf||U`9_c>>YC{&J@bw7 zhc0PigKa&h#;7 zvW_N;Pi3Z$lzzQX$Ia^4&BCYi2g596{nR%PF6bpC+KriPCu_$PjHDp6Rq6DBtoin# z5*V3r-O34?c=MLJY2OvO@8%m8y%UDQg&e zROcST?s=y0rfutB_pp(Tl4ZMIFrnl7g{$$AVXq#p5$GUxnWt1Iv=9u_Lh`-K!d$GcWS-6s@W}X+4qB% zZ+0T>ZP>4umBL2`pA@UM2z@;zerJ>=Iyv#5ccEOsM7a&|#aG?Pc6B_q_b}we zmwNZkN6l&g>Ae3u0ly;^oNAH$ucr~8Pn0>$$bl0R6ZLpj&B?-vmlY-|x3M^f9Gl^l z8HZ2z$B7{{ut>>n0@TA?G|5i&KMmOq zNwkh9OhuMHi;07jqtYovZo8;_4Gkekyp)L|&{bD0o4Si*G%uI{?+3vz4NyUr$MK{~ z>*2RtGfyw8>aV0SE|zj3h6@(Vwe@5Dh=NDgf5O%-Qjjoa2)eVVHus9jTXVd%xbod( z|8O^PrV&t=SEE?sG9d|?R%v;dJm}63&fzRx>`&paI$dkpoglaL68sHOo|6&(+8q}c zz|5bPp2XZg(u`9as0bgrG83H+7BAe@c~=u)4(1mwHT(Uji`?Uj zcy}z~qr3!cbrg$4p~ud5de$IpfZ)R*twq=OwciJ6mw68scF*2&g)`d9o{5shXPWv* zx2=v}cV085;x0rTY{tgq{n1VjSQP2~XDR0UznLz4{l_*T&bG_4Zqt?b!9x?1KcjKQ zwp86pTHQrMS=lipf*VpdAB3|Ma7WAaCMI}IGX}ZU< z4ht7o(%W08>Y^A8d^Z@45aRC%gqMbx!j@({46+mi$jAe;Rz2 z?*B5Te=AeSfV}um@#0UTGXFPIi~o{@zNy(~f4Y>`k4duG4%N|falxmU>K!BCipqBA zZut{ufBL@y@+mZ8MsUQ{)9TKGKh>{azy7Qe|0jgq|Af%-(IKhp&A+q&H@iUj?fv+Z z7m;ramNLbZ%|D@f0VH3@ln)I7;(6+lhGgpG^a_w!>OKA-(tH8p?*#e_+> z{KrNVi1{6ZfL4w!Cf=+Jk%{7|waC)ZTbxx?Cp9B~ z)4Udl70T!?#|5oOUS_T(s5;8Fm>ZF?%(dOWggJrrZjx?TVRwt5?S`6~Mb}q0C-R$5 zpIx47P`9Ml=id5x@zu>yVKGJDfpl6fmACgxZQK+^|4s$T>Id>SuxGmI*yP})b+cr( z>D;n+?F35rx=mJX*70daBuR3B-r5YD&drCUH@bkI*R92012vgtM4_wg#WWGUDW+yg zAV>(A+EB@`T~;JwRa=<&=ID-bs zyT^x4Qwr!l7FM}(gZh7Af3l;z>SiszcwWvhgzg?571@QZA5Q5ci7Mt-TJ+7?Na0p= zTE?V6W;uvJo``Jv6h%Gs+l_OiLEO-f{AU^2_H+#qiZ<<;aAPn(P=AU;~prr8`L#ZoWr;ax}2?hg=eIJ z+_ZT7t>ppEmNFBCHN9tsNG0qixV&XcO!o_nntJxN2vtIHL#&xH3Pwp86lCIc4Zcqq z5M1wV+1K1T%o;LM4c%N#?mjp!FDhN4qOwKBwrvTheF>K^^wn)pS65oIF5IZ&HAi{Q z(Di$-L61w9vy!&TdyAdKmAeygVL8PTEpjxERr2$VH%wLgE(;j6x{nqis zL_XaU?V=Z^UM=D!Qh}GaQQynH69R4|r~YwcJ0iSgW(G6Z&|Q|K65}Z3;5K?4?Z}hV z)OzxP#YC4kiFG7*Yd=lC=EiU#8HTlZkWnv+hfpCZw;th83(mAS z(q}t7s~)cW%WRQEh(zsYGf*eg$Cz3AOvtk{oBqDWpB#=LXEuwY*-xzI58BmYaNBr$ z-j~nVgOuMv!!hNeZ)x?`*z4Y}Z7_{&ToCIC*3Iexb}W2%d%GjTH^ydu(i^K^?0e#P z3>fBPqr1#&Fhn?`GcIWRp4o?G?qPrCI3&P*1WIs5SaCJ64L`mH0&6v6*zzVnKJY(6 z8;C2)3PQWz$HKQzE!b@P5=MNi83GwnYWU1FV7zbcp&$u%ZJ&9rDN5!ZljUKtd5qqlBijWUuaMJD zUc<8eB4j>-J$MDzcrg^zNGr^a4LdGkPw!z$?A(R%+lx(y25i`-t7ED5C=O{g?&g-S z5NQ;Pu!~yWyDQQv7O`43w4}G`;chB`vRVpt#id(}js@ykWEbjfGLH2INq@=1VHd&R zf`e`QM%*JjoXoy$4(+aB_;4ce&zmIZ;CBUu?wsB)w@5qe#I?2Nx1+Zm$!a^y!%kXv zQh#elRUekjZEa|ykM&En z13(hat=l)A*$;EFGqX2AU-U?N99}alwarvC%hOO$04`WyuQNWpLu!+Kon;Xb#QVC% z1f<|px`g;H4sCzUGwR^kq&MZP-SFBb!HKVX{jrq>%ScMlb0AQCf#OGvXJAT>R30ZR zG1RAB6@B2yL)EpEdqvO0lwbKaC-cyIq_%_r``N`q0%x>^(^F4tC=KSv_)$5(k_@?W zj+7K#Rw~0Ov5~uJZPO@+ntj&AH?<`>;NCM0$DgDe(M{u`<3%-dGL(G8aV_Y^S=KZ?khIVy+i5O1Y zwDe#RXR2m=f65rX%G3vYb@j~{Lis37$&J-!1nWS81l-`2N^F-&iJp(6*$i-jxeY)G zHp=3C^@gnY z3*X!vqLF?0l9(|hOS^&BXFv~@3`Z6>J~??38bcEkV?!khWR2+jS)RWH-)#$vK4S9* zBL{7Fq0;1|nJNdhiB-9wu_grrqRzR8JGPwa+kF4vXBI~I#D@AosHP*zdYanr1{MfL zy!KtEC{R%;LlQg58%r4YtA6#ybzPt1e?S#vhtS%`l|_B+qERv_hi>6DhqwIxu;xG; zgOZn-XkB8Bl^3vL+SvgZJ>(F(K+Go|`=ZEWWG}gNa5@g;&Kg%}B*h6EMDN*DS*y9h zp$0IUMZ*R5pFixOpi0#CFg}Lj(t372mdv3p8@`8RP}1jz>eneE0abxWm^f3C88&ZO zOxWs$!StL8L+woD6x0Y>GS5spR-s;%Xo?2PME$=YyY6?NiRq1v^V#TVzSggbqt@P2 zWaa)c24Z)!Rl)kbbg4&6t&5b_;Y^M;c!q-%0wO!TZMw{ z)2!@|t_Zpu3COCy`LrE*vRTEh}-|M7Q z52NGM4%E9`g9Pb9*}EEI#K+SO1FoD97Coi?#l|YeZa`fxU$K(aDPM%$&=AkMHfVYm zUiABCSn}=^J8H*PPifznjUT0;nXp$Yk2NmqktJ(Aiq@)<^e1?pq6Eu?1dL(mir+Bx z_ocm%CFnhKOQ{IvYb=~VgXf0^D=o*>mRdLq)}LGJ8ZD>?Tm>!`l<-jSbU7)%^XGV5 z2v<}@l)Unw6%6f?tz2Ysw<FkE7Eyg8=~h>A51j;Zs;tnr z&b8UrmVPE}S4JjXl9 z8(h{4Ot!z;*b0-JNS;@9dpKH zkC$Zn$o30NpAn5Y4pys1CAB~8n8IvjP^U>VdX)t$nKKqEYA<2UE+0t{#1CIRzpJWABIT(Khuj;J4JqnbYnq8Ui*#;&iHL#Luk$&4dZ#0Vs0dq8 z{>)fFr_sjs1L}*&(GbnO9t0QYj7$>BAvyWGi$R6kgI(9Q?#=C4ZLS0Q@^JPJruBkF z{-WH>6>%G!yY{E-U(`>rMf-kKtTE=JPc#(1nMQSvipQ(>_UClF3)bQzX)kb5fPi~0 zK!MlYolm!{z9;T{{eS8X{E@%wS~yc33<&Y6v+gg{T%2wi7gVcDpiLU`*bT__uaOXG z)WBz-K}8#Zg(+Gk3j*cwQoKQ%~iW9tt5anWd>Q+yu=Ep z(Rh%^5!aW*d>?rT<;4PfpW9xAD|HdPMo0pCI~}es^`=mdsjGg>!n{ix?tvmuQo$3o zBbPX#U~mcGt}a1K*LE1oN{rDC7{u_s4H9TWzLDqm^3#tnmWr~B=};e9&_9{s`xBY9 ztnoUie0$&;H^tlRd22kOj~E)*GPly_`4N=>OL4oIJ8=d&UCNN#GjINo0!_JQSQmyZ^wn{yO?9ZrUu?dt!Z4A~%=p^+eCZB}R>O1%nd`eh-=8NFpLko)`8i@hM80SEO?@wgA5PdK{bW4J{`R4Gh!8 zyay-9eXicx0*NY2{-nh`9T)KAf)f@KV>dKpd%9q~RLc1CctS0Zo}HW9zTFoC9b%K% z;+h^P;!G0`CbDgbny<;(nZyvQbYH=L!O>{b_uVx7N8bS&Ii+x>2Cit&!y?az5aVxH zX1N`J?)V)ao@CPLZRX;qmX%a;JcoyoDlRYZ(D?Tr!*TTN?+G(4M1L-}a^gaBZs-4+5@9GJ=eAaq}w+Oq&Q^-My z-cFK~nNd~1{K4DpTLRRWGrh-eG*x5LWJ9y_=osu8`s!pj+;}k?9vW;piNppqjuPwB_Pe|q2z|jS!;2toqUxp;` zxDY}pU$*8D3B^REBDCIZJ#2lp?ujN6%Pd1Jx;vDqxQvO+^Mi^menGTwkSZRyOC?zo zBlf3^cIdX!ymuvSKqr$TTc4PF{%dne+R@o!fxew?Aa6N1ehT9)^7H|N6Sdwo{1D+DB#jkZH zdbYXPm#kUKpn0!o$!!kvdU1*!wO(2@>w&iI$knIAYitDrpbd3AM5sEGieSDJMXMW$ zFJ=N+jm%}*!@yoO@P4=YVD*p*>@v|iQ4gAva*qs!3?7HYZa-_IL9s3QoQ`+{xq%xS z?G>}$dy6TkH@68t3hhO)9?S9Ip3s>C4!V@x?bD$dpU5MvgWs3V(v5c#q~k(oPoFjC zBEweeX0lWIN^f9;5mpvC6pjKukIN5H`0URiyQ<9siMT!up4?Kp-c`PjMUtq2CtgRL zkFcDa$Br$jJb+Q^kaTHw?wvmbdjiuto>g!njUc{ND>$8?1h*RTs$HipA}@m%Queb0pufj) zl>Drm*m6&gIwaZ7PO>6dq*-a+Q&w&iKr^dajS;z)W4v%Z&u!4H!;W*U7MF5*Byn>F zIHDuqZ4deLu0QR6M?$4}4eGw|dtclCp}#u6Pd2(gm(Be_c=0w5Qi7O z^j3(?Sz7?Q3xk*c4?O(cY}x*rpO|$;_7mW*_3@()Nqm@d9H}U1a-KFU%o+P%1X~yq zauA>jaM)%N1wNL4!a+gte*zi%T&=k2kbM0^Z~;czFkPZiQI7t8NoKH!1z_61;Q#s0 z|50T8Z+6H3J;wb1hY$RVlmCh5K$^wYKwMEPTJ(Q9MgJG5E?KeS66bXMdFg+|-&t^t zIemGSTw2iLKOLq2AJhr{)9L1acRKw|{C|53GY2GwwCr87{}*v@8P!GzdCslbNi2DxIOyNu) z!z5LAY)*9pJy%bf`ZB)M^=nxw5Ws2&cD`z9pU(s`<$U4Qx4e{!AhEl?IvYyX`x(s-KYh6}*a_QC?YgLt zE#H53WzVS%zZT1r3dlRzllXM66&Hnl1ejwG_6(@3is<^2*48zm4?*qc>-pqx-$175 z*%nV0m4c(renJyx0x~(+GU(MjwaLlAttz9a&5y5S8n%XR%Wo@W2g*x*Oy)47hMozf ztuWkDH=`P09P|_mjmbI{Gj1lKiqc-~y)2V;#Y*efR^?F22e{K_UiBA(dUf*SKFsjoYOT>HXUlMDs9g7Pr?gd7I3 zxeQZvNt=TyNhgz#0*u}HGlp~FyfbQ^``1&Fwecm(iG{UQ`8PAqd(GYxC`h&SaVh|H z^gig@T*Q*sXHv4cflcVt$apDG?m>!-< zQ8(&Ctm$jbkU`cYeDoz@kp#TsTUC-4hU9YO9Yu?}XvXLT3}NRfh}HjO?-U}D=oG=LCXFAwrJ4d(dN=-9W|lZ8Co>)@zp zX~-;Tk$g$=aJqt7fy8s1vnI^UM+)nwNj4woC)Rp(g%BQ+b#tfNp9Qa`p=*zn+A)?G zmhCWHH60wCz0nxA;4W2eh?IWqHvOpf&W3td6KnSGSrQZBx?o1<4MsP4T0*(A(%Q^f zl?59PJpH}wobJ(OF_V)zR!`&>Bp=bEw#$bVl2vAFZIX(cRY(<6eNxNbhAtWt5B#?4 zb!Dj1Lza%LU#t>^)-3^sDhh*Q@#giV> z{(Hu5`XdhBb3vz)DmgX&7@u45VCl||b{0aXvt}RSc-+tQR}Nc6my#-TIK|;qq09D= zPk-X0`97?gyuD}(Be8ZYQ)goE)UQ4&VJ%bW!${>3#Te)!CZraYo@Ez};gj5+thvmXe-i}xu_+4TfmH7A7~Y-r2E z>s=V5qvSj*SQ`k?4=`#1%S#IB>Y@e2A0PJNlX10Q)Hd;NcW{dASLMQ6 z4|JJ+sLS3O-B}pyS#DhU=7bjyKz%;Au9>~`X}9Kv*e-$Ox0BlP(+ugI#?dEQ08bM0 z`PiXYpu-El)X+w+$+rQ<7l#|M*F}A%y(dyF2+B5{b#i^@qcL4H&@DUjV$f*Hf}qE& z-isiVu-(w5SndNwPK3h2->+{$UuN&k`7L|Q(Q;VstdIMH{Ojw6U&CoC;D}Iojme3= zq3JSVo0ZC;vwmvkE`Q0t@&5j^^HC?CX}~u<{d@xbwEbJuU_N>bBdQQZLF+{1a_92L zGq0S%(UQyIW2GMhW0ZsMv(m3?a#Wm|G%RhLYAKhsX{XYPj^^NvP3PV!-l<8(lrP5x zvE9*c{K#batJ^)L{H+xDC*Kd`wc6hpEfF?T;=90flrZ@3(E>Ss-TYxgniS^x?cG;( zUT@qFRo6&Ft~&qZT3>HoCk0temUIo2?Ty}54(|ypit)qh_ik(}yP2wfklE_;@o1!} zT)MtJFF|48q6yhXORysn$c;L_l963hgorf!#~9+KjEg$w!hRhjbjwOpxGl^sY%B@d zThaL(BDb&$x<&{+uwJ%F=Z2Ey)%kG&fzCC)%b*zfqqWuZHNM~NW(!=4N1Jy3q5^S) z;yWv2v%>m&V7V_rb-_~=yDM{MW&;HYdZm3c-FaxQiTL3AaMg7Y2QPf@+_S(DC5*9y z4@~DJvi%b5wA#q2>$B8zZ3TTh<1e%8*>iaMg!^|?VUHn}8-UuKs6suoI#x)Vuix7K zWv`Hy9K^xY{)CGwK_x813hM*D+^Tv(tae?B<004y*6Fy9-{<)vR+C(3kb^v5`$Vu^ z=^h!ZjJ|negy@4q{XN_t?8_Opao)sfe(uG@qjIFKw|bY0rv@nl=X-1bmCD)bw*B6Z zYCo|`KW5}^ZRV75djMHm=1KQPKbH@1^`2vJxVZ~qx;MHelYtwU9Tch;xYzSeza4HA zjLGUu`zz+uEHo&Njf(#WnZN#hZkGq}g!!I*J-aW)xGD0_eXo=jtc=i>aZ;OjNqwgd z8N(a=!aW~&DUy5k_0S+A^GJBD;=23>E2kH076`-8b_H)<9~eA5Ir&d601dv*7mkK# zefR728qTz-)~rqK-St6MR6W{;kk|i+^U+#4Dl9uBmXd;m;mlqHWwXE1(@RnBZbIkt z9{X`;LLN1*xl4ltLXkENW=Vd_8DkD_{fk0Zv#tA}flquS3S5x|vQ+n4!u360U+oSj z6o-P-kSc=8y5Vyc+j;45-3tZ+LY}xk>cKMtXJ6L*#Ckm zpuSGtV*L=R0&AV&A3%dM)JhT1kK3`~ev5>Mp$B zKR3PAF!$$Pdzc_N`wlUW8s-|e%wAgGck@l&(|@C(jR1SzfGzS2)Q;cT9Po*5#L*K# zt*!T%r^WVKk9M#n$K+&G-)nJEfw>`ACeV$4Jgl1&PovhEdud*iy^mQgHGWUkdOXY| z_dtTB-ME(%JK4vHeYRXEVfG6<|EcfNcpFN{|Ad2He0S{{PnjVl5YRRojg4KHV;HpQ zy)y@;muA4aa+k;&pk76M{d)dwl!_oSU@=RRB>qqc7lp(nfEib86&bLXD=yIK3QmDN zXIZ0fWsQzhuZGV}+V=ES6nuWP#172 z6h0}TcfHh0^y;ZchvX2Y8x}WJE8`bHSG#!B zChoM@tf_d!1;Uo>C7+!3_zRT_;{PyX$!1NH^Pq&lXn8N4|H^c4*=fb;zJ-cYW)t@FC6VAmBF}ww0OaqM8s6fTxzUjzKjtEaZDpUCUU@FrxZq|7 z^H}CS6b+NF<*JMudfZjd*nI&~;=@sxUAkE;n_1Vmr}rH*ZXH5XU~}Y$@{3EClUjhr zkT64g&a&+nC5;u%lg%=V`3>bGbcOdDJKK-kXt>kg_ZT9<-HrvYl=RiC31pOVityKjh zoMV~AITp`~acbe)vhmV?)@1U+PZy^9JhcHY^^?F}S3th^4a!Hq#eh>Drh|>>d}{sK zM}elxSrt5gs{X3CMnsHCapDAl!(UJz+nhQtDzu=n6;_j@NdHO)kX55%+*B#^JW$uqSS3*Skv;G)^G6pYjH=~B{+ zCQQLiGYulEP&OGNzal05yxe9UK>y7x8+>PWOdd{CY9xVw$o5a4#25by3x=Eh>ulZL zhr8&zNXy(ztQPBXjA838i!}I<>kAvI2D4h5wr17`S|26IJ{}V_if#1fmaV{OF>f@E= z#Oq$Lp#_VIxSO}>g>rshxUsF$l=r;c=9c4k%G#lXfZT-e*`0Ey7g_>yemOO`gwYhO zSVfC+@F0%hrs}|LaDt>Zv}s6e+NagFx?bl{QqKhyOC=zkH9Kr8!wlic=ipS4`0Up59x!!Cb#6pqvsbl*<}vI4QD7662kXsLSv1*%8h!eJY^&|Zt<=U z1uAkRamKM+D0XBLOu`+xcxrdnj0MaIf#%bC8UwG{hZn8p#`h7yV!WB(jy0;Z5N}~o z3cS1=yL*>3%u{boJhx-|*?8orW(LS0n3~)6#$3&w2tHYP+SD5;VNC{^3-TGV+u%Jf zDnIxw=|l%zp4VeaZN!6}t3^Wq&sHy@Y2WiGiEJ8Hi5=EvJyC!SFsNiyGIRmE%R-C883EHO9zR)IRFt{x2+<&$={d03x=4tIOIYlXE*`|mP)SmU% zOHIJpw<-LsURrEPcmwO$_=>y2gh-UZ(J@6-CV+VR6@B|L0ms8=wT{M9gG^&Y3%L11 zN=qcLJ2P8tII>q`WIL_nwZX#OVc}|;yhU4|49e(nGe3H?nTqE`S@4=lbKn~9p4zFX z)I1e$oS;{A z!|jk;y*}4!1L=JT*wEy33=e#2-L1fG8YyZgqpaSW{bvy>`@yf`j>An?^gT6=z$ev1 zA|DKo@wElS(yt~TCB`myo_Yq&nu!}1#EE4XGv&SXPjVl!B*_QA6h>7vR^41(9BzJe zP^xtFt7N81E@DV0}Im@`z-se0k`zj_s4hGH`f}BcSTjDW)S)>{@8};?mOeaNdvC z_hb4k~@VfUHoa1yA31Ut>><#oy*i1%-Ko9^bpS zQ;kS^3~p;FnAptmKRBX@x&R4Z!#pe*ow>y;%)9-FwrA3?_1}zb^LJ_bQ>D7S6_^&A z(~!}~ncopqBhg6rBTVaIfGJ*p$%wUqupH}$ngM59hh+scQJE!aSV%`R_Z;({PmrLR4>OWXdXM=A!dL98@xSd5|PK4GchyeMU}OL()T^8v_tRR2la8hqyt3Y zs%HEw86S9iq!yjFzHeX)AD<=jLm97U0c2nxFrU8qfw;4j>SP3r?Wc=c8Qp~>#4Vf|$NgGgUzQEP3Q3#E3y&Vq*2QbltumFMzp?j@lT zNd14lN!ejQb{lHk#Mve8i=h>U&t|MD7Aj-s?Rx8bs?~6HCxw|YqA^Evgp`UxIkrt7 z9`_ZWURZrXcSXxe@v9ikQl4JSBrImbFP%}TthxM;tsTo{*bARq_XxDcQ}|BwnF1lg zY9E^5+3n*7iI(&WYSPMAQBC{xiIe(Z!8wR#rBT^kD4D&v9oHS&&(p(hq{b*UXNqL< ztk=sv;am$-PyFb_!yJ&4&o0dGbu|8n?8^9ka1zAG95hwGUxSHVk=5q9r$lnmdH~Z1 zypk6-_79MY*$^bU6bFC#+5#3;ZNykGwTjhHV$Xn2mwI?9$O3;8oD)m~;F?ZmgVP?B z$w%Nzio8ctXYR(btQ3ppc0Su=F8~tG757TYt|uxn6FTOyHB6P0{`m@Dk%_ZdK_znbmg|6E609v6R-*eKF(^65fT9{bO51UVrF^>Wp_RX45W|*>5A<1sz>DD90hE~U( z7t`sGX9AXmd$+dlx|z`P1K$GRWR~HHlPkX;W@KxaX-ml%U)>4UMu~ehmdminH$rm` zS!hMhUyHX^Mv+A&){8ZK@73W`+E$0LU1y#h*xIAgU_U-T5_wvD=rVW)i{XMMcpCqp zO<^Lf=jsvYq!9|W!kI?1))z8Ez$*>;Is-FTA29Ek1c@lC$q`tPy7g_i(ok5m7~Dxy^H&}{Col!DJY>9|;S zB6`;MIdILYbH|{oO3$vZQNLO?vTJj_BYH*@h6fmwkJ4X(UBcOf>5eOIDnf290jyln zTxopE{Sxcw%bQ5Hc$!~DI^Bp`X;JoUp(M^Kbd5FqV8?ekNm-DEFP2;_VHb*LyM5v0 z8uNt5fhf=b7UTJI*ty#y6Qzq2cGDHRW(jzD6x`G;w5TyQJ|cP9+r z{L#wX%hl<*lP)%8xUk4c?vJ!|ecUP4*z?=gQzZCi$ zEF)i*Gq~Gyx)t$!Pe3ktjo(t|raJ&>Gus1E=r(R&YTGJXG?utBsHQJ;+qLz}Fp>ob zP^l0R41(H&>9s=;xmfXWp9#i<=Q#A4geGCrB&#KqTEXCj>s3Xjz#C)57Y!*ZJU}5q zRjp{7sjAzr``Hrd<`hn>M<)&B4eLVhY@#Ad=YC(jC1Bl)Lnco6SC3cTO-yRnx(WO_ zf9a$^Kh)jL38vz+_?RQP9b+(DsL=N($9VE>Whh?>fEBFAYgl)ac|0C2R@2tIbi`82 zfDd;jeb4Z5lKkUkv;EFub0IUTnbIO>A`jtO=dydI8pz|x#EPqBXy@qXL|dXf*LyZn z{hmC*H+-U{E6HK|25HI8>vgxak+no8_&3(GvaDr7`S(XZh@+#>y?e&bxD7g^^ZXHi zB3nT{MO{?b-%g5g%j4lm&lcbbvBptd0V45w*8-?i8IDhX;y8n9D;DtMbSS%dAwhb* z;z!u2ChSJh4?iqc`!_=uu@(OPPap0+Rb?R#Gmm)k=A-(#$-x8Y+0&mddEONf%_zeMt2QdC8b|)FSi|Ipp+TQS7h@u z2NKKSwHgd8ESh6ZD-~yT-6Uoxx)xIL-R6l6ron^AL*hJJx8-W=a)0dh6s57elvfVR zMvi3ad?YZ#DEZgL!~*KN|1gd7iF0s)X={_9e-8xHe8EL8%u(}v`0LHKVZJwoK9T-J%Uc5zEov4|&kuCbM}biz zpu2KB@D?M`;>?MTPt!qY!$=+4npZBa0<~d9%pm_f?B$iKjAIGA(ujjWf z)@^`gzCwoeaqqX|%Y~B`6ki(SPYY*ad zl02UBm($-=b4nUF@ZPq0%V4#K8oACm1BNe^)}f}5zw=Ma7s5d-7~{x)1cTy-HBEAo#Lz{CrxhE`q{pf~Ap1SbYxOI}P}7voUl zN$lI{6+#*QY)`o-13FB+VfF*3#`x%$TYChART$;$0_t2c*_ zU0Ki~^L~D0EkQ}?2B<=Zs}3>D7s5ZW(76CB$EmKCC>eM3-AixgP-nSKyL-NImKxdd z*V__FIy95$wPI26o84#R18kPt&4!hCq0I+IOr`U{GYb=Pzwrvr-FN1G{+z=G*c;NQ zWsbM|$GYTN1zJ%Z-k7VJXg$Smk(u6%r5E!Kf114SDqSjAyLT2JO`J(Kf|)sa9K6jc1ZLR2R&<_Z0 zYuIRi4@nN7p9cZ8Y?L*byj}&K2dv_T*|L#B?*Dj_uKcZ?>y^@L5XQv)&e z<|WLY-r?7FoT{$vZx^-OEd1@Z3N9B5Oc%|rHWCE|N8kuM?ZOo;dku2v>`GgNHj#q4Xm zjVtkE|T1|j@&(kO-GZncqay(@EV5f83-PdWNDfu%vb)Hnoj?r&DzFmXr-d4gnU zq#wWN3#WM(9Im98692AX-iIK#ulLhI0;|)fz*G#H=9{XpF6?v!~0F83y;h!sWYt__88jVab)gtM`U&vbSV!P5%R^M% zahud8c>$Y#LvN8pxr6&^TI&1vGsRdae9y0f2`0CZUXyi_C+!2V1ecOW%AOtgh&xYq zF;ihfU5Fl0zL+h_JL*E@_Fq`9OrNH8FhIa+tLup$444d#?uP`~uYdi{DrcF4bKQzz z7&uHp-DIpLe}KZF6zF->#!6P{a}5HRRS5}x6Nb~@K`Q)?(IFY+c;6gbGwZ4HjcZyX zTM|~fUnbYpOLROndwe+lFx60fov>iXz*&*Zn+Kn>qSx{-Os5>3vFd58zqpYLjiVnL|^I?rS-=Q7XXr=BU zxy2a4i{iY^{Gv&1h~i!RH|ZGpK&eN_J7oIU+i!L^J4f7*#}`d|9$TYaqfcia$ubQ- zZuP>1jBsO5P2UZ2Cg``dO;7fhTd*(tZsz&Rb#Sw|kK>$*HMTn!I*j;@Gnh5kKG3{Zng@hz$-7?m_4p zq&tU{D|bL0M+!)Ai^;y=1g#YnjfCbrBXLAb&HT#BPng}AaOoKO*SJ&sdl7Mj;Qyr? z#Z20KrA{-o=>@cZMF14PJsz1$`)kE*s~kq}pPK+&DzUGoRp^3|!JR#TtT zqfVf}Fr9?j-?WV476@n3;l!^Eem!h48*h-Vl#X~cKmIcHMmnJ`Xg3RX+vHpKSHa#OCbPg!Q< zCVJT^v_W=czhf6BreNpbd*~zgR4p5qw)^?>_cF@g^(sJ^$lJBkJGp$^xS_fCKGdZ* z#DjF7cIyCV*chA$Tyi;r)0@Qep+t~~qjAnl{XT!HL2bk>^R-Hhcdy#i+R7$jnZRXQ ztGCQdg!NlDQGD}gH7i98TgzfVU3iSsj3g-IO)ican~-JNeNNoNPxWWqmD7ngr@SbJ z5sB?PL}wgtNq$JE>vLSXuVkcLzK#d}F3`4q`i@`5=V^2jb=9Q1+nsn~X!JaJ$V*(g zI@MmCqhc}l{pHxlhKTrsqw_W52QCjKE}pJw#v*U6lwLAATz5;N>tH#T293`W ztOrK=V)|`njwHHV=R+DCL}>Y4Bsqn^KXL?g$E&v^Bn}|+cP*XpoQKEDK-Xzf^%@~X zXG47uTka4B^MdaOJK{X3ukn@E6*;ug%Tq0G0t5H71)t)#sT=h~JDPKA_o)%9fioSp z`sBS2(9>}$$+bXLR*XD>9lf_J!Xf|t5iwOCA)E?s<*a70n@apZgT9$!A2^Wh zce}X^F6F3h*qa&ygv36}rd$)Wc}Z-@HJF&V)~=0v&)g-@xmju}wX5)KABolgkGrI8 zIkj9btKX$kF%EBKA0JKC`ro5{g-r=P^|*(A;t*Iz4wCy-3b`}jKM<8h zCU|B>y)64i$D7R{rzW-pJ%kJiq+n%Xm^d zgl`$XHM6rJ6U>DMk#|MrS%7<9kMGNcO^m0lNPg~VQo6RzTnWhi{Ug>c2$(WS1fH`g zh?vEDEwHA$bxnYGQ*CCkDHT^ zeIW-=zz9Z%mFH*G4AzinDYD%cUDqvHYj<}PXgX4NKyxWiYg0w)a)n4lI zczdS%iEeEJ$sH93)9OTO9$?g5Y(~&ip#p;Ugg45IvH+Dzg}2bopcPH_N@;Q!tN%z@=R{1fqtojk9wAA6+s1+maohMeFFI7 ztgOs4enzALd~yu~&{OqZGC{0M?Wi7e@VxmNUqGk=KV3M?n~#3vc1Y5|j!}1y6SR}h zH*^A!sbX^D`?hv9ZK5HuzlEIFe^U%#vC7X9JI&UpQ1gTCYAo- zwCk2|BujzASR*Q<(Uysog%6>1!56*GmddGqwX)v65h`%dQ+1Ecw+_9Btg*ot7MZB_ ztqv!T1}ycbQ3(yq4s6xfL4<^wnM?rK)u*Nf1o6n|1>S`M-@%e2;QDKvJt_`|zmh|0 zOF_T_wGOG2T8Vf>IF)dq2{9y~-n51L1ajCQ7i1AK-&|^X&E6Rh?X%iXZIHfUiSCIF zl8WnN7_U%$%DMIw0NH&^wP3T_JLaHG$md;6~d?W=_M zRD+)@218rjTE*Ug=0W;p3NQOg3(ryNt*i1)1Zl1MT`UxL)l+#bI%8}uVw9ClxVXh%Xb^iR5wXGctU8`@G?!6v9K9K+yMcj5Q| zMWp-elg4RB4@S|DVy&*pEWLA!2?~jz;_gF)R~gSP@F{5HjU^*~++{y$irD}}(qUY*+j9}#?<@q{cB%;%!Ls|5d3Sv>d zGFrB+Z(5y7OPOu{Q88!Rw_jQz^Tf4=k=_6h~xa6f03A=*2V%s!k+4XWGcQ4WbM}OZQIoZ z`r)(9OJvgET7pDHK(VX?pkNCkMF*P6nCSqEBl!g-XrF=zJB$Zk+_F=1$=MixW@I)N zp=-LcDCy`!yOPc!by*dmp@GP(iD!5sz)EIp!U7Wm04IBZ)svNaVIZ76<`6Q(#K&3+F|fex1|*h5Gddab0wleBVsr>o^Q)kz-b+|(G*z>;Y&S8C9~zPb5@{tY_`0J zxZK|nX9i!h4wT@`f&uax3$jA3_mVuY z=SQ;>_~7h78i13sfb_QojpQ?zc`}`?g6EiyHoem`41BQ8op)Hx?PvUz zKUsonfRLMTm++j`PJg?Q$AeSEq#y$NzK*+u>@(i7qKYA(mbN6}ru0Gzh*9LX+h2qQI&)Xt+&NZA5hxU#nGFklw ztn9IYHj0C^ieX5Xt{S;_PopA!KAZb(_MVs}e>dH5kB7#~(wv5hxLx0q(A@HNXNKr_ zOH=Mo|4JjiGdcb%l0L#wPn<=a&zQYdHCUivsxWsz855lZZ#y2{hVPQ+Qm2;5$||E} z^iLqV(KW-o%=C$TOK9=rX<;*&O%RNS3g;r zgoL#4hZJf*8HG)98_nL*YR7gQb{~sLd`d|R5$JtA8|<3S^S)X@B)&^FBI~Wl$*5vj zXd~#mIDWtRub2}@|5HQgZVhD?gzQw4R>h;bu(I3@;NxF^y?E}=$JNB08(-jnR`LJ9 zmk~}?Z_w(>uI(%cLvji`_dMFW1!p3Y50t+gFZiV6+Vzz+vN6~m&O}{MMxBPM#lTus zej15Ct({+E;+zj^hGo^u0uHPypRyOab>+!PG=hVEwf)#&!>$C??d9$l1B&^q4SxZs7K~KGRx1b9}c4jwb z=U0={M!kbsp}Y$c`A3IxLJAhPqu;2aO;=u^aK8l@08wU$gfZ2EXTp%nRxdPo3n<^V%*M5kr47OOW?pJkM9TS4 zMek*V_;4ic?pJ_ttdt8x{gH{<$f?JwkM+H*ju$)Gg&BH0?^EAzdj`c>f##5-$1jg< z*JOomwPy|f;^Qg>(f*IxXL44&S#k`^$>bflmEGwsk;JIyUscEYxXKl_U zLk>TJFe7~zThVehc|q)J^0@}0UzO+1R}6j~n>t%-@`aPwk;Hc$FV(beC}=-}mXfCZ^Bj7T&6gMW2p-UF&D@z(WmrN~JJ8eCDk>uv6seU|gi#clnDT|^S{+x*=QC${%^U%g zC_zW9y||Mq@XUOh3x`Oa(@a&R)PA|xPN~mVS83Mng__vDDBnS=#0+q7d-op$3EG+A z+n&B;RTwgXBdGEdK3w&tGq@**n&qC?au=>jF}$sMFJxwNJ?g2Ci~D8fqzh@>Qw6>| z>XyPn&v)juuI&7}qcz#ebCn$o+O{2_X7Z<)$Fi5pzHV@Hk68BdG)B1d)pHLGQHEX? zPM2jDmxY8jS&heAG8H|iK%)-m-C3!Ol`+a(a>mg1EEQ#_xmJ;go!yFFwxmzid3zGi zWGXnAFB4)zEH3hiO?Wvmk~L(vsR!7m4ghK8v@g&B5$q^Y;j_G`s)3#5Kw*vS?g6^) zOx$-WvRxv8N&gZl=~i3a4Ck|0OUiJ^k6;rTe=%R~l!9av*)>@8(+IJaMhUvr9$I(E z4|F|g?a4}#*?%HsQ!e|RXSmYcYFC3@tmbOJ#DpKuq-(Wgw71tAltWYs7(7x(j_{`? zWWMg?qBDVV=fBP=wqs}={c{BBoYZ*SoEJQ_UHoms__aX8=dzS4bsO%qa8GznZS9(= zP?PcHA0A8dRkIm!r(3o&1x_`qb7gh48{!crTsA9mc>bTxiSwBK;$775g6xEN$ofh& zWN#5~U1)!KpriOrsd!!2!A9s{&$zpq+3&!&ndVT}P_oehRpF3YPMq_puU1b0-D^r! zI@GNZv8`kjN5t%TqI+L3J74YRF00MKMv2A`6R;!OfMHj1vR)~~Q6`K8Uck8i?OvZ6 z=}s9rdd4El*ubeoX29{(Xh}!ZC@6hl~HY}GgIG?N#ax~BM!i9}&@{K&FvhQ?k zKHHdh95Jk=ASp#e6S`9Nk0z5;49)4<>VWy!J`=FX0+&cvjj4h6n(weN(bXl6wO)$NIEupoA!o%zG)s7@R^#xYQs}86 z)8%B4g|Cn$hoFFX?RliN@#oT%lDt(|ywhUn#t_=zlVkqcEytp3(YlsKOK*g>#r)`4 zDd2`?#!>k*dSZ?YCRob@(`B+cjvBjKnlQ5zb3|~Qvh>)(`z8^zeQG8HKWqZ~K_w)9 z*-7bLQR-<~KTmk3#3T=q9W+wQbHiR`F2$k&{WdZ=Q=%^b&u3?crD(6ys># z;Ik(=HT77|nRtWwtfFOgeq_qZ<~_oNM@~ibCp<$45cK&N!hMlC9DV-N&LMwixwvQ5 zi8cG9>5E`n)l)*^P5CcJhk;Cxk3?&RrBnnWsI;GoT`YWCd6)7%bvCs+Z`amau`R@> z9l8vzg$c&iiPyDGyc!#5+xz*C$nB(@IJB}2#!ttYaNhl%`3TbZ6+_}XH!!M$Q-O1D zb3NV48ztr>$?#&w!phgx@7G$T7@ykme8-VZ-JjFQ^>p9Wd`LOCpvgZ^YHQY}U?z5= za>>?ZVuXn~~PH5Z=6mn_sjTW=NpuIkj^NO*PWPN9L9RIHG_rE2{*1A=G8n3-284dSmOjd$e&3Q7PWV~ z&q7H~cmUbTAgCt6efVkf!V{ znmGK^jHUa-t|AO4TER}EX|lp}YOh7l&+25`y;cbY+SfksL1pl3mBov`pnF_K-#epw zepN0N)jhdme6206i<avk00*U$ig%v0*uiUc_eZK{yP?rRzWp!?TY2-0*YmS7atuY=J2~zevBN5i9 zdPPoHO&}8=0?shi5-Qp|*XOs9%MtjT+U~K@t`9ql8zmRdm!D^Mtw9!VD73v#v!f>V zzl40(jZWnm9&R)O*0(MI{|JmG;K04KMD4N+r-!=hbywPSW^x4G?zvB-MlB*tBK7?M zkrA_O(=;@5`y}>{Lz=GL-}F-BfWx(+|7QxXU7GEJ#5j$CzmN9&w{Lgb1z+OI;Nd_w z%YP~WIH(ebbeg~+LZcS5Z*cgjVxd;f|EUG5v~*@-B1Kem^w^y(?v&KjwS-?-hdoP7o4h0$NPUktzWf7%hoAgY zfL$rTdDv#ZQ&OBF18$QbA^GnTkNsa*7Z2@c)+r|xTgw780g;dYr{NY@*!Y4AzI zL!9RtEnpj|Z~ua@g);a4n$qs<8rp)r@xzHrR8Bd53@xTSOX23@TfI6*Lu8pOfj}W* z)=`}5Lxd{NcNzYFM`X(fWL8!Rmg<*KXumJsg31P5c>E{&?QM#)vD=7C)7($+g8biy zJze7vjzkguv;J_@e~9Tykc^0qp^8ImoM!ahZT_A-#N(9x@8kKq{cqC?CPHM$s*$Ny zg`8JN*`Iq;{3iaGtt2=sKa}(1$rL{h0E)_Np21k)1mY^9)cYU8&xvxlY2S# zv)f-8T+EagJW+t=k(udoe00*!k@uG;n?QmY?qPyXGw=D8ZR7NQ6SiYpKYu_o2ljP< zTYd!|v8Cq9*(q4vXT+|WRVpfl;&fXRsBlWV&wovPgN}#ZWzWm(>agK*=-LxY<6Pq~ zClHK3_!Z4#kR<(yv$Jk7Hdr7{uGmkN23pfys1GQ_8L45ID4eUw;JNYT!%9gUoGgHz za9#GNl9QPqm>VMd>GF|<4>jz);0oG&`+6|1!UVLoX1TVud_A+U(P%}uyS)5gaB_-B z{4Qv&QFCXI@ArlqeLHBtdiuEx=zh!j4z?S_X0ye?fc zt25+U1jO59YeU2wrW{6)x@uzPMI?o&BYK_u_9BWr^%3$w-PRULHjh!ZD)$0b2fHrBIxp*-x zF^pMlW&wcD9|i#d(X&DMg5LhGmvV@FmTv$M(g1|aHs+JDt>wN*Ot(8!@h#hj@ZJ1tdjO5;g`nG-$sV(9#p8n0 zF8#M}YZG^a`0r3e9P0(tW_XT|KyT;3uO9hhs05$^>8QyDrxnMw`Gr>hg;Cvj#?QVo z+rxouBHR80J$!_x!hV<{qvoDC|6`WzJ~sSE{+(KT)`u0!7JQR~jbd4@(5DVj|V& zwqw$9bK+^=1zP*QM(Set@&&)z9#n3X~ojy#jeOMeipTt8du*TTb#muvzuNVST9-uh1L&u zwYeR{t%1t17B~5*hK}K!1ax5Ul}>^=(^9^~xXViF6mNhJZEKFLwRR7|H$Ml2EvVXJ zjI$oU&*BiR4)I977mqYM_o&`-{CthKD6mqm5ZR!fdU$mubNIb{IsfRxRvCd{wZy>y zrxGzvSOI%_^|CWvM(z9wqrl>HbCh1*Ic<{*t|tmg)5Ju=siNxi8c0Bw0o(sP%RW470ZRz_Rs~#gakH_4Z^k zpt_}n?Ko&hZgaH){i$VL5L9vZ;@Ke^OW9~5yL!41P`Rd+T|M<~S<5gw{L3+g#gY&n zPMM91Px|3c930hsU8tb5h90UlmiVe)`gz8EkQ`JuvfHq}anr-|KgfH_sJObVO_YQH z!Gk*_xCVE32=4Cg?iSo3xJz(%cXtWyS~!Iku3dT0`Of$2_PBj}^ynYA#wb~}tM;0E z$y{qc^OpPud_1`c>9Vw{P5&N~qEYkmqn*=r?+0)?Rj^orP4yQh306@8?ny!^A@#Ha3v9A^{Y^N>%g#g$SZ0oi{fD&)t_ zr}lvl!CDm_8k#Kw5t5-^>2Y-~GKiErJK&?Y!t1fgLA-B=*lnv9$KL7m!zg5U!%7%F zM{+^QI3s)URLB~#{~ZHgIAw-^=X>XR21MxnJ3)P~XRcw0YxQ)f#p&SkW4Fmn!&=P_ zvCrXF?M-(AAG*A}d7)pZVSVnYM;xP9T&81JSK7cHkNY}E=(qF`U_gKCY>KSMtvi-@TlFB-+>Nj*AN&^5X& zF-8Upd@L)KhU4X<PP1(NB z8rDW?hu7^Ij*aO-Sh}$$cSc|8vqwfx_YvtN|)e^U_;JvME0JF_LP1M zSY-Ye7l6ie8HmkC#E13~2OdpM@SAgZ*=yR8<)CHloY@`POUXe>kT~C06Qyr%-!;cx zI}sS}K|=td1*P(637S33$Dh{|!PCMhs(Fd-aA81E^w3d0Xi1ejmphp0>liy+sl&w% z1-7sn&6;(lIMHrg*<0^lp&R9!-!`~1D0C(Ikno?6+-Uh#-4;S7V-_sUR?(a!&5sBN zY6Kdal=7!oWvUZMGSX*!du|?kEqiArc#D=|U#HSuEa$DI)!4WfJNxm<^{?{$MKtL| zqMtnKDeRClnX@g|&Sol&yKM7}C+OIa)$*l}VHq>f{ zd*5l)s8im0!q%^Zfj`T>KX)ssYD?qw%l{Xy7lG4E=71L}xj~dlHpFucYuC{d^6C=J zTZjC$G&b~R{$|WTIGbbzAM}xrpRYnh6R}6w^#sYW{8qE}XE%?6AUdq10y=Q>M-x`h zcwA+ULxt2w$M60c*zRBb?XlZXmjwyw=f9VJzAfXH4%dSaeY;;sV%&SffWq%u z!0p>@iii)8ZSpnG_1A2li)b={0Ygu`PV4F5hcB zdB^DqY?s^6_ZlaZt&enT3qY8k64REHn|mSHsy2|n49gQ&4PPBf+;8<|19qG94)|uF8nC` z-n=K)wP|_1W*1Q&3Wvd=?}Y!+TME;~ixbH`u?Gl73+Y>+2XdN+GlW#m6oohe+sPyn z99FOI!>W}#E5mflbiUq0HqyQg{GCs+2Hw>e6fqm7RJjdA^c(q>Ck<5@D0g# z@1Gl+IR-v#wYGx*bZM=L{Cw1hnasG3dazcz4JU})3UwHFKj2wyetOZmGk7sy{kEyz z9JFO!V|W(d)1y`Ksfa4VS~sg7*OLEdBeIV--oq;Z>cNLR`9lJY-%RCi&%B`tQ^z}M zRi0Z8Rhow|!-@4P|8GI}Z<%SqDyzBt0YPc8v;1XGb|VXXEU*)q-0>wE@U^ zVv`2G@rafMN1m*;F(t`7L_pZ6HXDLg(f*eDj((M$7}koXXG%PY!Y0} z%?a#k0eMfJtm$0ftmJBtOYY6=F&G}>Mk<)ZAPs|dd*w$sfAXUy>dv}?|Nby-P`{k! zx;96LS}!5x`0_CP;CeBXlOxyL7o61X>h=WAy)#!!LIwq;U%UNq6)Q|o2433CnH_cq z>bjcmzS3V%LS*o5;_m*kMN0XuV>z@+P8f?^zOh*_8ZHwHgRDwyH35Bo>wc+qb}Nl~ zJ_ul}QS+HS?FBUJH?FZzIr>N=6B5brv8C$r5b|TT6J#hDwZ?khHHI%487g~CA>hkJ z60}$Pr{hsKr@64L`%>D`FP_+x%y{lw3lSdEy3pB!90a5Hm#ik1r6%iZw!{iDvNDgZ?0NU#L(jL z78aWgs!zAEo1n)SYZg3bdsNK?Cn^7RfhFCYEb4Ol*+rhs12t$KqG{P}cPlE34oQcB z$7AF^!xYxHqzuopSsKKS2{bgnCo*KTp0g6si@?ig)gY=I@lqrbU=x7Noum;JxN@i9q{{>H~_h0YUu%@oZSA_xW} z6@P|$W?h8j%Uehsz5fs6TKzlr_E)=u@}UmGtFfbB0@QuI%x!*!N6m(!4~ zi{h3Rb>7@6MKJ4caBvWexq!dM{F`hE%*U($FVtqxu=Y>4pPy_NSMV^Pmt|5u)Omvm z0kVBV_WpdMR^3^O3XG22&Sm`#c)6}jiwV!`byr|Eo&6h06nvt+qTK&u1W5LBc&zV1 zT>r+e;!b7%AuI;4B*e>on{9PEln?#-4|D@&UONAa!1zz`e~RRQFXHdxf07pJf0-!y z|Cc}f<)pHgBOzBK|A+p%lm%}${n_R7tn|tMJ)-Xar2Ugt*K7tvLjW}_w-^Q-#3W&n zEapTLJpo09yX;4V1cKVe54W{Mv<7rv%&^Zg)@7@jD!w)_M+#mXefC_dzHjbfz`y&v zn}^3?XP0V&0mdQ0c$-S3k9T4+=Fp9v^v(wNS~CCl-`f+MO~pl^hvH&Rl|!!mj$w}@ zcJ9YN$Qu_p{c$<2)!t>xUj8?MTcC-YZUDTTzQ*$rw(1LQ-0|zG|4OobevqGcO0O`z z&#>;#RPKlCQVG>}@}y=T|0QRl_>p%0<6(0=^tohvZ||hG6j-86Lq#1%6%G(DTK4KR z7Vr7?I2oiw$(8dH#ryaMQ5K{W`=+-q?cBz3l*S#w)>C-}ddA37H|$~B7VrSzIaXTp z+8HhVvR={nQUgS2K_Ex6Qv1`92{|djl%F9F0sCyfUv_)$P`?)H{Jn zzDXLI!%f&P`n&d<9o;Yri9csBI|%u_>666oFy}5E9#9D_T5Mh({D>bw6=HXQG~15H z7YTeK+g))lDf%UkXDZC^j{(g(D!PW@2a_g}=-nDdUbHCE-TeNdB>9r|0uGNJTHwg~ za@|cSja5a~{|e$a()fE%sa&;>U$L2aZ~A+U%XXDub3JgF#AE4Yi#j|I^{v*4!mZma zqKt@wUiI;dp7#xdQL@P|QEuBalu3Hu_z@U>8q*P)*37;kV9xrW`#pQqqY2X1Q*LJ` zJisd@URO_j5!~k$$lljDy!>^{9IJ-XMP$FI+6tY zjm9r*rmxp96-S=w!|yHOtZ9SVq*0rJyXsd7aWBm_6h`lf)B7^*-kA)2;q9G7)2~3= z&D%G6BYwKFMp>WXI!q8cJkiz^E`87%*Dr<=qR4Y1$&*~?^LHcH+X8|=&sDypEs+Ms z{JU>=bQ=5Paz0&I-?<^*L$~4v*!iu70kM9gru8w{v>R#cpWXynkhcsj2iY4FyavUQ znGYTj>VDdbQw(io43zUr?GnlP^sZ%-CogO}lqoFLA=H(HC@eX^tPWFL9=z^Y``VyQ zo*+#sBV3HYygGVtgU#@DFgK!cb-6e z;cyZ);L%G7hFgN^o0!?8H==xDh_G;f9e43jK^bCjvKzZ4+7Vr6qi|%%R<>R`i9cp)2 zmEXN`*COq2Y)Uq%6RPU{T{|NFpT)NH2e<5QAXifd58gt2-UkBacbb&TIJZmt$<*=K zgtbGH3wE52OBF`gz&6%r^+iMIMJKO_2SYX7cefSxf_v9G>w?Omh^$n0rhw|V^eNY2 zIv#wj+t%@0jEsfxN}NC=3H~9lE2?{0%GLe4kk6|(weG1a#`R9wHYvJQjiAeA-+Er@F(0bRCo2WpMdW#4SOn*qYefy^+i@cfG`g8w`!uJdf?d zEn(V7LZ=R`c2h#iIGP*;7VqJMyPdCzQ0?d%^*^gIs__p#o#LvZ3nB3CuZ8_rNw403 z8imwDZz@N8r3~$ML8P=Dh3VE><>r9!xVh9-ubuk~H=f*E0lx#CiZ%Q|4nqN+f;dP$ zOYONea*N2Md~K>g5Rn1}zlkBD^FW1QoU7H*;|$!7Z8UAGca547$2 z54tC~19>>Jr&L>X?^|B2Zmu7m-M&0uRvyanUx`~6c!t#|%E|R{sc1f*O=PKw2rilX zat92~;&CyZCWX^LFme3NcoYR61Cj~IBJbn{-8KrLmp`WpUydIe9^}N4ewYg%KgB)^i#!wY>I;kL3f9{slJvB+e0<>=ta#Pu z&nJ>J9IxZ^zFN4i*i0WEerya?RJqRCzIuPkcHzgHWEa-<&V1snE<8}V%d~tVcQKp+ z0MgX1mT&3~79V=2A$nPmn=r56yY2`z%W;!$e}r#vFs+<@W8%Pxi6m zT%d>Uj|99rl7_#nFZu^zCm8mB<>QN?fJv+HICBTZm?R{|yUfGay4nVhJ@yX89>c=i z*Da{Qaf5c^e5LEa*uGPYmNSv*J>PKpJN0hf_+I}-)&$;M_!55^BW<0y-KI~S*VkH8 zc#1Ke;Zwhth{kNw#D#gld_FP}51vkIY{bSR4`{Wy@gVkNOKQRti66mKMy&K+M*i?4 z18d|`7xkWUw2RgO2TMXFod(g_VQ~9UEs%oI+K;#TWm&}_{A(4>3wS#5qV=~L$y4Nw z5w*%?8&;P${AZ9^|KMx1v}twOGj}`>u!oG8Zh5$z*1MFB9u3$Kchcwr3GD+6)o#Bz z?dTY(UrDeGh6pD18=tPNrM8wIU2{Ep@YJ#7EQQ<{Z1TRkJB^+d*lkS4@^Q&59H*9X zxiCc6#`;Yoi{BY-rjowB-<|W*mYwR}ylZ9In2L$}!hS#y2{k2mu};;_MHNQt94l@3 zR_spP*{jUUS}JFlGq9L?kddkc8c(ovGU{N|1;pkbNN7nHQd!gp=3raBp#1h{F@kS3 z{D?^W$w0tN?5<>sAKC#r#NdlqkGJl0e?rSj{k>Ur>YVF6q&o^ z$c)HS>q&HK<)b%vLPsB1WHPeP;?Ps& z`*v4xWreJj@y@WIw+Py3;JYi{mVy`xpD%)5ht7I!jJo|2b26&CA?B=`qckKsh#nC||k zQ>PIJy=%kUhUn9;M`Ho1RW73E5G~A1}w03 zYm7H3=lN~QCx)8am^{P@lHjWK)E&6OB``gmGe+D#zT?@h3U2DZ?fscm17uO6qyn7E zhRJ>^ioWfB#b0W!IyV z@=h{fZ~O6~E1-SBf0RwPa{piA^#?T^YUn)cY$fl0gfiS!E4F)ODx zOi*VtLHBz+x^6zhX|eO<$mwW|9qoRYOUhYbK4%?BA>~I z;OH>jy1N%@nhzbf@Qr5hzlPTmKaO`BA!YvOIVxR#UbE9jOT7218A`$zJ@PK7T*k{R zs+|GnDSssi@Nm{?u+J?DwL6T6FilqQ+y9_ywB2=FyRJTW^n9Z7rNMKM;tucQ{>8X{84#4e#3MeTFfs=-YoDqPXFI^pXnvp-gCxhy;~2&~g}JeXqKt;ra>;ZT*WzQbk^ zpe;YtqaU~=^9r!1+gCpmA6a4iGdG38z9cN2hwx6f$x~e1tnNL;L-9ZVYJNc~DCqz- zMR!*;)IFWkk3Nm`K=QW*!VJas?VAr5Mt$^x)M6t9>EN%9BLXNcU*u%gi@F6O0UtF@ z(2M1~r*%oR^Qh%1qmJe_`e!>7?clTrfV1{+qb6_DDN+{JRN z3fJc!d|R4poqk_w%u#%Ki!C+ggON!>$l&-m)ZR)lm7*+h)laxAaX-gcNI&9iQa{a6aUs=gJApzS5>G8h!_&x<<0!cb^Y@Otm$?cyI-xW0(beLZW z4Ee={6g9=9i5cn#=VrF5;GQg;l60?ABg%ZJ+V^bxxB3~VA+zFx{kJLtSMe*Zm*PLA z&nQex52XKI7Qo|ANDMmXze^$`Bijl8Zk4}!3^53SKM2$I4zSW+m9NwQwkK*9_+nc_D8t7FfhZnx8S#x*Fq`+MFHLlRl+R^)v(1c%eFC0)8AM!t+;tYV`h zj{DxBDsC|bmn*<}21hOym$Zu{Ni?*u2yqcO?&mDpjEl9Z&6>xzZaB9TIjbm6o>oc> z0-H?A@41%}7}4Jan3G%fXdRst!@DzEEg|=Vq0m+49TySJi6tb`WNe#~u!CtH*E~=W zSHs*Va5RkdVhXEL_3SE0O-2<>2Oc8J0nEo5#%82>+_f1gP02Hh{R>tJhal49n@#Gsz+)qB|TXnr7)<+Ln7>%p4_8 zv`jxUI&)ZYsJCRqM<}&GG|Wg(O^~toJJH{HUbxuu_xDCuE9On^j6$hS-<5aLbAPfW zxkKZ9mpnj7%b1pYyi1`}cGQS76NGJ(spxQgMIjR`P%}&cY0nsWDu`dLMk}Xd)w7gq zMXnZh*#Jg{^puq68ZjVP$PcR2f2tf8PDWo=0$6eBLfo0D>g!VDjE|mv3ogEDvfx6Z z3>h=kn;C>FznlYh-aTKNEP8O(h*ngb6D{S8ghhnlRyxYaiT3eG$qvhnCOmEK-7jXm;5)hZY9Sou>&0H_$Y8BcgD_EFNb zL(WW_dH$(2aFjb*nF4V^obvnTNI9zq?P#O^&zO!Mq-Gj~m=~*R`JynHoalo}asR~y zh@W00p~eBJG0=?uY_Ce}BM7!$DFj^cz;mtEoUM*jrISwU%XEY^r8Bu6zK28yb7fDk z=5LF+STJn7V>`v8%V)7DpP8u5V8S)T*7sOKG)JuM-?4mt+f~eBQM)M4wTI_siLK=z zizJgXbp=}GZ>t5jIQa@@iylW2l{vKQv~Z6b;_W};t+-If0S9R$!a)O?4OUoqU?$l| z8t9%g*Ihm>Q3VCggBY8Xxw-u!tzy-%o&NAjefYuX(TVp#)QLD-T)_3@SvsMQBu9Tm-NeaDB)f#YYckBcyZP2pKfM9QA6?K#6B ze~4qK7M&KaCqR{)kJVR`H-LJFT<+r4+af`tsQQt03}j=gO_xi@iFHv?MG&vnl44eU zkZ7qe(4b|!vBbtj86BerfJniw!8YtP>C>XyZKJ8fs0E@48`10=i*jeNVa2CgmVLv+ z#iVaf5h=8KJl@}wMk=acfktW07(#|$&UnJsPUf|$wzIh=SNAyR9C}JR6|V8Axo4vU0O8x#TV7Q1%J5Q7$4J8M zyf8&J{3^3ewkvb*tO;pUFbEu*5egi+#NI?c;+KPOn+O9>n%tEeQMuY_NVj2 z1o_3u=JigNMi|1uvDYnh1`Xmc`WjxTkp~<-f1VN4ZO7YfK+%*<0`88a>AE_vQdWMN z+xxT{lcG3h1A}NZ+*V6a*>~Kj!P^@bVanng@{JW2G7cINx|#^5^KKzcERK=1aC3YY zib`HpWEA!SS|yOJkDUOK3rfDUc41p~0-P1}1Js|v;&|q@PpoWmglzZ)EkfT+&b1W* zE$W(CF zy|SEvz_nR=XfN@~;T^_MyuOx@W67oDB2Hi$8PAAt^hPGz40g2slwL+0VLf#|jAFQ4 z)~^a6{_ewdAOZCU#b}hAMpQiOEJ$qwZ+o-dyP%E<7eLOuA}^Cx3824JbJEjYw8CTL z`Lsda_e+vq^ib=ao8||{swpW2JI2L#4?COleJ(yGaulmYVqczd0T%@n;ac3=uH+GS zX6A#bufJOZ>MY-%Keg%l*=TCr!s4=;GBNQmtDp-L<8Y>tmMtvHK1Pa&NT6zw`l0jI)Fb%9lLY>nxmWXmc*v}aaPg8 zxs*qS6BDU9k)oBk4-$cHQ!a}#ag5>W^~|;AmbEI}ujgn-x^P^vEYL$@uAJFRJ5>d4 zq*n(x#%2Q!O{+sxUm1OvW+sQJZd(OUDJ#uMi8-@K`J3IfR&>WQ$?HH&5-bo$Va*4> zO#I90#%OMx=^b>YKNfo2`?Mu?cili$D>A%pO!;)2Lshr8kk3%i`F;ZD{MdM|{c_?{#Qw703ro7>3IYqb@~N-V7wcoTn?su!|>To?MVpvo5f< z0O+z;bhFT<>#^!N)@ZAP9(EEAI**j}>;<%C{F-SXsg$DSjQ|5RX9>^=NSn_k^UB!) z1EZ;J%0C$~;b*FFWgiGe?l0-kmdboFmLN-wLOZaF4yN*677Sh2CTMFO%AeM12r9*l z-WZ2Q+*hN5(Nk3N$H^%V-T(1qxjQ;C*;f(xP@!EXXH$H+kHukLr!OgM zVCjgOqQ~vlro4_zw^~IP{ypA3$2TbhdJW~r8q2opjJnQ`;}zq&Vi?_?!LzoGF9jI~ zC$H#h+N9F#4y^PJ9Bb>jAm&>_L@t7sHErEB^xZngG|k4izFqWW#H?V`prcTUqJsl_ zL#uDqUC!wFU?0px^!Q1}jq}K#_qvlPG0lUFyqxg^O{3T`$%+o-S><|`q%CZQ?w^Zf zfYtJaY6KUD7lkt{&49+wdOo&JjnD|VST?d4A?mVi4gh5pmY-`ba9T6Imc!|zH{@|j)NHhZCA+77m|u^$drJ!h5$MIDJ;Mr@HXn-r(5asa0r zeNf5O=k_LLSH557#EK)?MrXiK4m=u4x=P6PmJ@4y+BpVR`h25}X6&LOa{J3yCd;}6 z3B}x^8x9)>CCTHRMTIO|7EPUIn6G3^;HZV2o4akw@}q$46iy^3uIc$ zj+bMt(P{9hDEfbt7_tAfopPK8{btgf&EcxM_gS@X-7rV+{jORp9Eg^(B%r;`#Att8 z-tq?C)G_1^7&B1h2i&$tmat5nbqQxg*WuD7YlKr$sw+mJV}i9rI^%Q0)l5MVMcusw z6e13Dm__-vd87XxnHbA+JCQ10T+kq@!~WLQ_0;*o42oT?_VK(qaafD@Ayv3Bt#AA& z1$7B!d2XIN&2uE$sP3f^lV|qgUTDSvE*{3@&oxOW9THMCm;I^KH!z1IqQ^I$n0AX!N zDIUIJA8jgeIt++u8Iof{>Pwi^?NAnCn2n@5B*pRyHly?lSKAo+1(S9vpKQ|W+hv5e zMpisn$y67!H9z*>R=m^zh;Zpp!bc}`63>_gP#!$Jtmd3aH=dPL3)B=s1fQFp$O;b3 zhkdVxgg%PkK%880+(1&jX)+@*@88a=Gl;w(yTIe|d?D%3s z>dpSRbc9(s{NLo?vs$m<-gHst2FVh>GiN=GNIKyi?RHqLewL;>R`&vuP>YZkGLcXv z;hf<704>#*=VAi@_o%-Ws%WiGL9Eg?EG%W%yCmFP!kQBrkt3ZvFx{1zXI6f~y}t0V zX1DWD%ntAHksk*Z*l`1)Gr#j&Nw+$*3C_JvS5Zo8?5X=s#%y>o79&aH3nB19hA-lf zYML<=Pm1+E5$s{5dDMZR5$!KZ7ZO#np$ZcN=-41umD!7_sm)Uv0cC_G0d^!Ikv)HV zn7DJktrHz+)6e3j5L>-=+dApexZ`LAS*^n=k$2%8HGF~SP3K?D-ohpm&X|yGLe48y zc>W6Bp3-iYTor#Xg}npcB4$MN?kkZVbEYMBX-?Q`-9aE|-mJ?VH4YrH99`1x0;O&z zyyZL0N(Y#I-jdozJq+2ayA%Y%_i?K2`hb;kb7a?>*XGu!qsPa*Q@JfWglCdUZk zY+cd{3OLDw$Jv6~fx+lTD554R9JLC^KS*g>5B)twi+j(UcZ;l=%lyR_1YY(jK)T>b zf17@A<4ttaNPl#%qAio#n%O&#h-*lDTmW;cTNZj^*bCNIQP!Q^oI|{oHg6) zH(oIsI~R+t1&VYUcG#1O_Pg<9@lKvm@@JD^An(483cI|5XGCq0vEroZgekMB6kIX~ zds1Q3`Nh>KZGo0DH4@?8_Kay14wc?`2&?jZ8_6>6uwlahRIMALpZ2rW^?0YFWH>1& zD~gN}iHqzXGIG?jvR>D*W*j%wJzLU?Z!8FYFE9waRXUkx!DAl!bzST^Hs*tWL(g2UPAY;g}sp@+PYs7pRlXVpb ztx{$7)4DY|>w?HYDbv_XmHxVwypI*Q%L&&8-rxj1OI?6;91=N2B9u5sH=# zV&wNmubBq@#WG5@7%%C17gaZwTj;w=3E^(+ihzr13rKsrI}K@QCnf}CjI-ZQWm_yK zojHHXk4SeLCo@aV>lR})1J8mYj|Q^4;dD~W$rZekhYq@uU&=@Fb43i9L;|@kesf zq|PNMP~X`rvnCiEwt4ROtltcdCCKLeK?(GT~m>)N~rBi0tk2vmNW z#~WrY-=Qmdw!)MyD!3Lv=K;c(K3w5QvvX3>Q1-bFB|KRQa|U}^F{mpt+%0oh@oee4 z(=pRJaMerO;L}m&cJw;&PPIA0T_ew$j^z#S7b9RE7~?sx<#f9KMdnb#MU+G(cLXiD zJhe{J9FbJBOsoy_w$v)UIct+tciBvTF_Qp(WMwCbIy#dy&6Z>PoCxFXxN^78D+#v` z-LH|~sp-wql&~ol+S+}9$%r;K(fR|u+}#Y&nIbaNf}}t?sBdhE8>pbI=)W;2wevx^ zV3YaAI3u8?!6@1k5;M(FN<~*Fix$hcIz9aCW2dXL{#RS7EJ=e@Vq>9aT8x9@!gSd@uQi9`h10XR~^JVcbyRLKDar^ci&=_*L{wysBz_e+S$3J zs7+&>8v3F+H&E@1G>m_ck8iCuPv0?Cou=imTwL*e{)1&5 zB}E~Tl=W#mfh_+HQCa$p>lu9LX3TH~7Knw~l}-;$3nGx52_H+VW75yD(D zyE~x6;4xjyTw9U^3^Q^};D9u^`qvBdZgsYxezvTD{fr>T*9Rx)z8g|6Ykcf?Hx{pe zLhtH`--0k7ivN|_zneM#6S1u~U3hUujjE$8jX($i)VAJc_nIC3LtfgeRwyxghO5*D?mcOn6 zuJy)WeL>yHrHgXDDHd@2hNZrjpc}2cEW6{HWw=z`)&LnrwoftrD`S4{ifeUULyrV`}C|#DkGUMM-=^Pc%!bp^xbt%Sixcm*-c26qePrQaHKF*j2-?OC@1W``aFgv$Hu(aX@k-R_1j(!cjx&1Rc%e4G0?3?J|%0XWxpk}j?lJ{UJ%T>ZD32zfbQ1{haYwSBxQEg)`qW2+DsR>U&2 z0()~4VD{wYvl?30%@Lu|x1~M!+G8?OGY+3&Bu7Jg+3h{-ZBCaiFE9V(i2PYy^<2pD z`FgR%^}gkyYsv@9C_;JUd74PYg&^z-EriuirTR@sC{*wu9UjFoV(QOuXyIQI!VK7i zfE~yPF@Mj~#)h6{Akk^+vZ3J27nm8h$b+f%dPe@punhejImGIfx(Y)B^#9pJ^S@;d z`G2+x%^<0+#tEM$w7@^&+^nTBo3-qO;6ZaoosJ#Nx-7FGO# z&2pYnS%u)gjuQU2Rqg+U#Z2J#!rk|ge9TKnv8VUR=fk=@b5r8GHBPH{|KZM`$NZ0p*p1BPy}HEp0&=4# z@@cka-|nE?Rl8=0Oe@yEEQbgL>Agq-+Fq7t-y*C5Emn8vLy#hcjNIb; zPfrb})`L4h>_Yi04$WI_WFyR57&y*9misem2L_9w=R6zN<*`F)rtwZjw2-)le@w`4 zx~R7%Ke*sJ%^E&?kaFjouZfpaWmv#2o%FS3EA_goAwAWUeC4wo-m$-b)IHJiT;Wtz z7dexG*XX2J-@sW)dD$QLrk=*%r7=SUxUf4}?Xh$*e~bNW^P(ThUeO|# zGxT=1#xXu7e|jQME-jD}FSOiwpVxXd)Qcqd`CAqDYzXW(AwlSzu9y?4m1cUl)BWBk z1`>-oXzr4oVcjjbTJoshz7QAbdOihmHB58{KaFsOOp4x?KinCRa?>ThA>uR|t~JN{ zS(@tg_ZC+jDu-n;MZY2CL(MkdI4atOQkI9;#Lz8OtRoxcG^<_?Q6K}Hn_X?WU8*9K z4$C+^E!iUzw!Y3Vn0z6@=bL(ey~xz84TvI566`vp-iyK=9nc5816mLGrn^PqkLoAhTC>U`VQ{p2K+ z9cWE0-Z_o)=?wmnZ?JZEa(=^4jTtK%@*<Sb8#AsylvpDm2x`BzTB-42 ze;z-z&#o@Eowu3D=K63FQ&-%P6nP7?+JiW^FGJ?wFZ%J}l^BvDdmg_69yNV98^u2$9M#^-ZeR)R$ z8Q37W6%OoXiSq8pWqaOrzTk#tAp*fUW(eBv?Qh_NGb+W1#XgkG6L}>pE!qJl1Bj8F z4^q0FVgS_{zN2<{bkfg5>OHcL7mM|_AX&JG9~`cIf;W|0q+;-9?{9oC>Eui#{mVA0 z8SkEuW6ivCWSxDKKF4Yue*FXm(w7LL3+Eo;dYyidZUx!}M}>{R=i&tB1#wPoqG|Uv zE9(=@bC}DnHbNGz?(KK)7ZPo(si)UY&nq4xcJu`Ngi&m=7hG}njuJiJ(G>o7&J=v4 zME7aERrWwDs^ z=bXk(-bY-SDNW6(9CFsXY~NwB5G0HKpf5UE{C;m`{O!;8P+9L6U0<#E5N%?Nfa~7a z``H*38!Zvyl``lylm(~)Hv7(lAIfNvuYjuyJf1N4YYkb*KnUub^YtFWd+BU#5N5}zTCL7xGordYHckhxvmMt(Kq^ZthLzsV=nC9$lTM7+Lu&jpDg4~f zZ^pyA-BC_#2l*}=g5XLEo(@wIE44jTmDXsNcJ9S<1ucs*>OoA_jL?*Q!(*oXf+lk` zs77)^-TJsC)jftPZ@e}Bn&;{2C*F!RWkKb0IB?3){`|R2^vbm%M(&>pi$!oh%4PQW zo+vrR?AOkgwX(8bRJ3ZwS7PCI5 zSAQN$SVvPGoRWDifsrp?>9oabOt=Q;KwVP5%?)1-Z~?w3pYg~&)@oM36Yl>`OQ@OB z>J=(SiZ&sn$xaL86j=s!^U~2Zon<1iAcA9t}Km4+`{F!7<^2n*el3OoX z4DoV;_Kk$PJK?nzRmPZ13@ZW=k^Qf)M1|@GY-EUd@tE9-)Is%-zn?0 zA+q8Gg_)W41_Q{e_s5(leD;`!_ZK%)K* zjh*C+{j2K5uY`Fvk35|>u-Uqe5h~SPGAjK2+O3Xct*xzm{4{D+z(WNu*S9^*?@c*^ z%zSh8J9%hJX44@+Z$tO^zYEO|Fs3lj`eET%gbcyxjwxHmRqhZn$22pzeb#V>#vrxc zNn%b-Nz)bvy@;nFxiNNArb1URyiq?~#lq4O3jM~j5Y)Y)*Uos|uX5_)7=zN8_rMmTuF+uvI@zm^F@);KXfD+P=MdyuY?_&r=0@?Q=twg4nBblv?`Ye+ zMY))RZip^fz&3Q|d#%sZ~9%6N7rRtqKX6SB^4(GZ^A->Hi#Y z?8n3`80*`S#7*mY@~*MQX`Ywnj5UYaCw~LGp5+Cja63#{4MUA=zieP|mq`^w1!n+` zhJLohsh zH1jOgOb99gJYBRvI*F%EzgEGvpD{E-0L(xWPlu`OWC)@&MzS&j7G=d$VNM}3UWtw6 zgv2%-0ePhQtie0xWy5~Wml1@g(YD7JsgA8N^3K^0ZOL9Ulf^O@koKUKHHN6JI@hJF zD}LiCx_6?vU8nF_a{G?s9{perYYrqL=)q(j%W7OQ`bBk5F(30Jp?xIH+s@TwEmO^1EDbac-EW{CLdgQLfgDtdq}x6RUES> zCT5Dz_TSGFFl5OYdAW6^7@zy!j%}N;@QW5Jg^L{k){gniP)>?43(rlpPScW_7zA9= z3FPe3@UALGUNn^=1_VUFKnryC=yufmntk)WDGq4`wJI$lR;12;*#x*wy4FO7Lj}w> zcuIEzs*GD$_4BsS%(OSvH-*~fraTc*k3v=ST0qYtI`H#vEYT0ja{h{17K;5XRAt@O z0@Rcd(4sS5b{~}AnO*Zx!}b|&UF{p#exKOw{$QdG5{(wFtOI-v6K(k&Yoh0`8|ugt zFir9~GFr&)ey=l56X|Syl)H)i&+(rYEeF*3$Nm;_!Dq>llo!6l1Jel99w1W0}Rs?l;x<{k`w`<9yCP_j8{6`P`p#p8L7(>$;mq$;lBXQYkyi z?-r9Wn!8~nvBCP@6(LDY{8-SIz)Kgjbeg2bTa?VPnD8B!V0`_nh`T6D8x@Ya{e3n$ z4>d%`zbYH51h*=ch_{^Me_bZe$(GV_TCJKjmrXAzUzmJ=(r?SoUFWx?Ix>WK^V(PA zN5)o5f}8KFc7VvYr=G#Wl~3gr&x=;FHx#?x+BG4&HXknu=k6&uOty2&Slu9~tYGdzL53Z6oo9Zc8(cH5hmKsLl5F^cb8!t%dk8sL z!-mB3<>+a=Pq5anI|i^(-A6;6~jDXSC&I(qX z!Poe~R$B?*TlNnh{<1M-`BBb;}CC5RmOe9xzPU70i z^fkG67aBe9SL=J^8rKKX>7cRY^CWjR|BVvBO{R<8cC-HiCQ)g5>^vr7Ne|s!b;t!$ z2ld@l?&5-G;HpF_Ix;1GxgPh6D7EsI!jK>Ez}O<%TwM^?)<_ab9-2_& zH(9)=C8PJHEAxGGXQSiBJP~|L+rqx1Sa3bXO2Et+; zDBxZ@lM9`Nq9(;S-izk`kXNri*zqBn{ea`dYV20>-W|UO3R5$ctc}P^)`bmflyiBP zwdgcf%V0u(TD@~hXn9gL2NoxI-Kz`07FWk5Rqa?aW5(J2`^`9#_QQsNN6N=$OuN69@zIms2{?DUEf= zhSY&K`v#tE>OrltW0hK?-?3%5btmWhGZ#cN2$aVq;L@Qby^A%7LmB(7SwX-OvPk2V zo1GCB@oQDqAD(&Bm^ot-=6gzty{yYAT?qHY{c<+QV(q4xw49By^gI~S*X^(r@FY*! zgg`?O+To~t4g7w~<8!6ZhzfH7S*l}o^Nv)cn$0h?V$+bUG0j{;0Cz3WfF?}};kwzS z@XTt?fIS4;fSCR)*EOF!#r&2HQSv59d17FsnzHAL@B3{4iZA3; zHa1~a`DbLVfWDI(m$jy<4v9nwEZ#LJ7zid`+RSX<{$6J7j+Oxv|*`Go$6k>f9b1zIS&|hXF}<68Eaw&l-hG za=m&svpm`ye!?`q5koe<*BS$8G9UT?&PD9S|b9b7sicE|6-5)b!0 z3Hr>zaM~g-?_;y|Zce_!KtYnVS1nDst2_t3zp{b$n!>QIguR*}O)(m*PrH>BjL2H# zNhHmxQNBwkOy;fdBRB6VN^0QO+jpIlx8VSl#ThuoiGuGf6_fD>!EYZdmQy&c82%6% zFEUk8NzE5EA;s0|Xw=r&*1`2o2=D(^0*bj}`V$Zp`SghC7hmO$_+rHHqnIP}PDc&D z`W<61eA^&}INmGHlB|_S@Y<&4=g2?-YDTryEP1necVya024Y+EM8MOU`2?EDPH0H> z2W=Suy_7wBaxE#Mmze_-hmU15Z#2np%A-(2lvu+0HEQVMtNg-_9q0Viut?IqfK>c| z^A_=Nl3s~U@n9^|l-(80?Ie7rGc{Kr69dkVnF1K|J)b+1#8U3X2Bkqb2_%lEovPo%;Sj%&Rd z&gZkZjn>DsjnIKS&~VQs+WUxv$a=fQP`Q6VG|IRSvCT$mnxK@Jzja-HR9C~4C(vTib2rjS7lh{_m}U6zmi^fS+O+xh>(JDv{QRC#B z#~pLGkbFP0Jm;Qrv$&~uHJtffwe5?P7onZM!3|;2L^PJ$N9&Y_2O@Lcer2aFa!x>y zk&$8Crx`su){6}aEijU3i_p6Q3K9+5#%n0uGu)~A3PPNUW;4p*H_7Ph2DUg4)lk{U zl($k)D}WB;r}{N%+X!GxGhtKYI!F5H95!<4lDMSq=&?iuq+`_)V3KtZ090fyT?JZgwch@CFor`8XIWA{ z>TDl7?M1fb#2+$FvhkWS^IaR1LD&Zi65&C|-ef%i1E+o>N16IeUtV55AXVQ+E9k89 z9r4XyNQw`!ID8r9=yS z_b0RtmL8O>>xk1Yt|*q)(MKIo-x#G+6U}y$N0b5>d?xAr%Kv5Kc>3N=Tt-k(iTwl~PhI94AEZ?*3eJb|}!gs}iJ zHmh_9I+L~x?76-7V*Lw9dKhkfEDpZj=Y&QhR0I{&e7?|4rYG6$ees~%ZN5kjN9v6x zJb`t-vg-DfaxeW${~+vk#mvjsA0Ohw6*%Q4&_d4iQz63%F%!Oc!V9gJM=5tC+MoSN z*q)9o{Q;58h^`#JuqlUI#q?PhL15YC@HHtS(Qj@oZ&_trZ=|RwJTTDYY7$^&3fM^4 z5=em&2S&^zEd@nZV&gZT_#9lBsWIR(oIH;{h*}J2eZ*_DM2Ol9%|~|oh5f-TJm;ju zZBn7W5$k!oQ^ox#rpyI>haCkaVXuT#+xw%G8PEeJeDW*%@{i*7c4?V*8?IGH-+S&D zM8GvyKJ=EfbmGImaMxVg**$b^btXbtTlm7ZuT#o0b;32UiWsmLid+w&9;g0(;apAy zz`9k)Fq-r)0c+%|l%NSX*nikg#_J{d^5BYJum*R+Kj`0CLZ$9}=!;wc@xLUm1mn5x z7V-b3a!K(d7X6k$Y89Do2%c)=l`!WX%5bO0Q2Ce6RW!+!AGmo2RcM@7 literal 0 HcmV?d00001 diff --git a/docs/images/site_admin/home.png b/docs/images/site_admin/home.png index 89f303dc7969d754c9a902fa941380b3883ec2b7..a63cc5863ab6d43b1dfe5b07319a7bf1fbbe72d0 100644 GIT binary patch literal 99731 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%0D<1)&^TL)+ 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 N^|7W>$s^O({{tnMn<)SQ literal 285383 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

2Gm@>WKhQ-F?$W-8mEOA4{?x9N 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

OJm;J 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 diff --git a/docs/tethys_portal/admin_pages.rst b/docs/tethys_portal/admin_pages.rst index 75da65beb..9d0bacb98 100644 --- a/docs/tethys_portal/admin_pages.rst +++ b/docs/tethys_portal/admin_pages.rst @@ -2,12 +2,12 @@ Administrator Pages ******************* -**Last Updated:** August 4, 2015 +**Last Updated:** December 2018 -Tethys Portal includes administration pages that can be used to manage the website (see Figure 1). The administration dashboard is only available to administrator users. You should have created a default administrator user when you installed Tethys Platform. If you are logged in as an administrator, you will be able to access the administrator dashboard by selecting the "Site Admin" option from the user drop down menu in the top right-hand corner of the page. +Tethys Portal includes administration pages that can be used to manage the website (see Figure 1). The administration dashboard is only available to administrator users (staff users). You should have created a default administrator user when you installed Tethys Platform. If you are logged in as an administrator, you will be able to access the administrator dashboard by selecting the "Site Admin" option from the user drop down menu in the top right-hand corner of the page (when you are not in an app). .. figure:: ../images/site_admin/home.png - :width: 500px + :width: 675px **Figure 1.** Administrator dashboard for Tethys Portal. @@ -17,19 +17,29 @@ Tethys Portal includes administration pages that can be used to manage the websi :: - $ python /usr/lib/tethys/src/manage.py createsuperuser + $ tethys manage createsuperuser .. _tethys_portal_permissions: -Manage Users and Permissions -============================ +Auth Token +========== + +Tethys REST API tokens for individual users can be managed using the ``Tokens`` link under the ``AUTH TOKEN`` heading (see Figure 2). + +.. figure:: ../images/site_admin/auth_token.png + :width: 675px -Permissions and users can be managed from the administrator dashboard using ``Users`` link under the ``Authentication and Authorization`` heading. Figure 4 shows an example of the user management page for a user named John. +**Figure 2.** Auth Token management page for Tethys Portal. + +Authentication and Authorization +================================ + +Permissions and users can be managed from the administrator dashboard using ``Users`` link under the ``AUTHENTICATION AND AUTHORIZATION`` heading. Figure 3 shows an example of the user management page for a user named John. .. figure:: ../images/tethys_portal/tethys_portal_user_management.png - :width: 500px + :width: 675px -**Figure 4.** User management for Tethys Portal. +**Figure 3.** User management for Tethys Portal. Assign App Permission Groups ---------------------------- @@ -50,39 +60,121 @@ Anonymous User The ``AnonymousUser`` can be used to assign permissions and permission groups to users who are not logged in. This means that you can define permissions for each feature of your app, but then assign them all to the ``AnonymousUser`` if you want the app to be publicly accessible. -Manage Tethys Services -====================== +Python Social Auth +================== + +Tethys leverages the excellent `Python Social Auth `_ to provide support for authenticating with popular servies such as Facebook, Google, LinkedIn, and HydroShare. The links under the ``PYTHON SOCIAL AUTH`` heading can be used to manually manage the social associations and data that is linked to users when they authenticate using Python Social Auth. + +.. tip:: + + For more detailed information on using Python Social Auth in Tethys see the :doc:`./social_auth` documentation. -The administrator pages provide a simple mechanism for linking to the other services of Tethys Platform. Use the ``Spatial Dataset Services`` link to connect your Tethys Portal to GeoServer, the ``Dataset Services`` link to connect to CKAN instances or HydroShare, or the ``Web Processing Services`` link to connect to WPS instances. For detailed instructions on how to perform each of these tasks, refer to the :doc:`../tethys_sdk/tethys_services/spatial_dataset_services`, :doc:`../tethys_sdk/tethys_services/dataset_services`, and :doc:`../tethys_sdk/tethys_services/web_processing_services` documentation, respectively. .. _tethys_portal_terms_and_conditions: -Manage Terms and Conditions -=========================== +Terms and Conditions +==================== Portal administrators can manage and enforce portal wide terms and conditions and other legal documents via the administrator pages. -Use the ``Terms and Conditions`` link to create new legal documents (see Figure 5). To issue an update to a particular document, create a new entry with the same slug (e.g. 'site-terms'), but a different version number (e.g.: 1.10). This allows you to track multiple versions of the legal document and which users have accepted each. The document will not become active until the ``Date active`` field has been set and the date has past. +Use the ``Terms and Conditions`` link to create new legal documents (see Figure 4). To issue an update to a particular document, create a new entry with the same slug (e.g. 'site-terms'), but a different version number (e.g.: 1.10). This allows you to track multiple versions of the legal document and which users have accepted each. The document will not become active until the ``Date active`` field has been set and the date has past. .. figure:: ../images/tethys_portal/tethys_portal_toc_new.png - :width: 500px + :width: 675px -**Figure 5.** Creating a new legal document using the terms and conditions feature. +**Figure 4.** Creating a new legal document using the terms and conditions feature. -When a new document becomes active, users will be presented with a modal prompting them to review and accept the new terms and conditions (see Figure 6). The modal can be dismissed, but will reappear each time a page is refreshed until the user accepts the new versions of the legal documents. +When a new document becomes active, users will be presented with a modal prompting them to review and accept the new terms and conditions (see Figure 5). The modal can be dismissed, but will reappear each time a page is refreshed until the user accepts the new versions of the legal documents. The ``User Terms and Conditions`` link shows a record of which users have accepted the terms and conditions. .. figure:: ../images/tethys_portal/tethys_portal_toc_modal.png - :width: 500px + :width: 675px + +**Figure 5.** Terms and conditions modal. + +Tethys Apps +=========== + +The links under the ``TETHYS APPS`` heading can be used to manage settings for installed apps and extensions. Clicking on the ``Installed Apps`` or ``Installed Extensions`` links will show a list of installed apps or extensions. Clicking on a link for an installed app or extension will bring you to the settings page for that app or extension. There are several different types of app settings: Common Settings, Custom Settings, and Service Settings. + +Common Settings +--------------- + +The Common Settings include those settings that are common to all apps or extension such as the ``Name``, ``Description``, ``Tags``, ``Enabled``, ``Show in apps library``, and ``Enable feedback`` (see Figure 6). Many of these settings correspond with attributes of the term:`app class` and can be overridden by the portal administrator. Other control the visibility or accessibility of the app. + +.. figure:: ../images/site_admin/app_settings_top.png + :width: 675px + +**Figure 6.** App settings page showing Common Settings. + +Custom Settings +--------------- + +Custom Settings appear under the ``CUSTOM SETTINGS`` heading and are defined by the app developer (see Figure 7). Custom Settings have simple values such as strings, integers, floats, or booleans, but all are entered as text. For boolean type Custom Settings, type a valid boolean value such as ``True`` or ``False``. + +.. figure:: ../images/site_admin/custom_settings.png + :width: 675px + +**Figure 7.** Custom Settings section of an app. -**Figure 6.** Terms and conditions modal. +.. _tethys_portal_service_settings: -Manage Computing Resources -========================== +Service Settings +---------------- -Computing resources can be managed using the ``Tethys Compute`` admin pages, which Tethys Portal to link to computing clusters that are managed with HTCondor either locally or on the Cloud. These computational resources are accessed in apps through the :doc:`../tethys_sdk/jobs` and the :doc:`../tethys_sdk/compute`. For more detailed documentation refer to the links below. +There are several different types of Service Settings including: ``Persistent Store Connection Settings``, ``Persistent Store Database Settings``, ``Dataset Service Settings``, ``Spatial Dataset Service Settings``, and ``Web Processing Service Settings`` (see Figure 8). These settings specify the types of services that the apps require. Use the drop down next to each Service Setting to assign a pre-registered ``Tethys Service`` to that app or use the *plus* button to create a new one. + +.. figure:: ../images/site_admin/service_settings.png + :width: 675px + +**Figure 8.** Service Settings sections of an app. + +.. tip:: + + For information on how to define settings for your app see the :doc:`../tethys_sdk/app_settings` documentation. See :ref:`tethys_portal_tethys_services` for how to configure different ``Tethys Services``. + +Tethys Compute +============== + +The links under the ``TETHYS COMPUTE`` heading can be used to manage ``Jobs`` and ``Schedulers``: .. toctree:: :maxdepth: 2 tethys_compute_admin_pages +.. tip:: + + For more information on Tethys Jobs see the :doc:`../tethys_sdk/jobs` and :doc:`../tethys_sdk/compute` documentation. + +Tethys Portal +============= + +The links under the ``TETHYS PORTAL`` heading can be used to customize the look of the Tethys Portal. For example, you can change the name, logo, and color theme of the portal (see Figure 9). + +.. figure:: ../images/tethys_portal/tethys_portal_home_page_settings.png + :width: 500px + +**Figure 9.** Home page settings for Tethys Portal. + +.. tip:: + + For more information on customizing the Tethys Portal see the :doc:`./customize` documentation. + +.. _tethys_portal_tethys_services: + +Tethys Services +=============== + +The links under the ``TETHYS SERVICES`` heading can be used to register external services with Tethys Platform for use by apps and extensions. Use the ``Spatial Dataset Services`` link to register your Tethys Portal to GeoServer, the ``Dataset Services`` link to register to CKAN or HydroShare instances, the ``Web Processing Services`` link to register to WPS instances, or the ``Persistent Store Services`` link to register a database. + + +.. tip:: + + For detailed instructions on how to use each of these services in apps, refer to these docs: + + * :doc:`../tethys_sdk/tethys_services/spatial_dataset_services` + * :doc:`../tethys_sdk/tethys_services/dataset_services` + * :doc:`../tethys_sdk/tethys_services/web_processing_services` + * :doc:`../tethys_sdk/tethys_services/persistent_store` + * :doc:`../tethys_sdk/tethys_services/spatial_persistent_store` + * :ref:`tethys_portal_service_settings` diff --git a/docs/tethys_portal/customize.rst b/docs/tethys_portal/customize.rst index 1abd3b23f..802ca8db1 100644 --- a/docs/tethys_portal/customize.rst +++ b/docs/tethys_portal/customize.rst @@ -57,11 +57,6 @@ Call to Action Text that appears in the call to action banner at the bot Call to Action Button Text that appears on the call to action button in the call to action banner (only visible when user is not logged in). ====================== ================================================================================= -.. figure:: ../images/tethys_portal/tethys_portal_home_page_settings.png - :width: 500px - -**Figure 3.** Home page settings for Tethys Portal. - Bypass the Home Page ==================== diff --git a/docs/tethys_portal/tethys_compute_admin_pages.rst b/docs/tethys_portal/tethys_compute_admin_pages.rst index 84e39995e..81e7c2550 100644 --- a/docs/tethys_portal/tethys_compute_admin_pages.rst +++ b/docs/tethys_portal/tethys_compute_admin_pages.rst @@ -12,6 +12,7 @@ The Tethys Compute settings in site admin allows an administrator to manage comp **Figure 1.** Dashboard for Tethys Compute admin pages. +.. _jobs-label: Jobs ---- @@ -30,7 +31,7 @@ Schedulers are HTCondor nodes that have scheduling rights in the pool they belon :width: 700px :align: left -**Figure 3.** Form for creating a new Scheduler. +**Figure 2.** Form for creating a new Scheduler. .. _scheduler-name-label: From 207510276632024752ed5fa2ebf642f0794480fd Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 13 Dec 2018 13:58:07 -0700 Subject: [PATCH 197/215] Fixed tethys uninstall command to remove database entry even if there are no files associated with the app. --- .../commands/tethys_app_uninstall.py | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index 344e60390..1524fa2fc 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -31,23 +31,34 @@ def handle(self, *args, **options): """ Remove the app from disk and in the database """ + from tethys_apps.models import TethysApp, TethysExtension app_or_extension = "App" if not options['is_extension'] else 'Extension' PREFIX = 'tethysapp' if not options['is_extension'] else 'tethysext' item_name = options['app_or_extension'][0] + + # Check for app files installed installed_items = get_installed_tethys_extensions() if options['is_extension'] else get_installed_tethys_apps() if PREFIX in item_name: prefix_length = len(PREFIX) + 1 item_name = item_name[prefix_length:] + module_found = True if item_name not in installed_items: - warnings.warn('WARNING: {0} with name "{1}" cannot be uninstalled, because it is not installed or' - ' not an {0}.'.format(app_or_extension, item_name)) - exit(0) + module_found = False - item_with_prefix = '{0}-{1}'.format(PREFIX, item_name) + # Check for app/extension in database + TethysModel = TethysApp if not options['is_extension'] else TethysExtension + db_app = None + db_found = True + try: + db_app = TethysModel.objects.get(package=item_name) + except TethysModel.DoesNotExist: + db_found = False # Confirm + item_with_prefix = '{0}-{1}'.format(PREFIX, item_name) + valid_inputs = ('y', 'n', 'yes', 'no') no_inputs = ('n', 'no') @@ -62,15 +73,13 @@ def handle(self, *args, **options): exit(0) # Remove app from database - from tethys_apps.models import TethysApp, TethysExtension - TethysModel = TethysApp if not options['is_extension'] else TethysExtension - try: - db_app = TethysModel.objects.get(package=item_name) - db_app.delete() - except TethysModel.DoesNotExist: - warnings.warn('WARNING: The {} was not found in the database.'.format(app_or_extension.lower())) + if db_found and db_app: + try: + db_app.delete() + except TethysModel.DoesNotExist: + warnings.warn('WARNING: The {} was not found in the database.'.format(app_or_extension.lower())) - if not options['is_extension']: + if module_found and not options['is_extension']: try: # Remove directory shutil.rmtree(installed_items[item_name]) From f3ef7864a9b46eef5e1d7199ce2d0dbb68f5ee5c Mon Sep 17 00:00:00 2001 From: nswain Date: Thu, 13 Dec 2018 15:31:52 -0700 Subject: [PATCH 198/215] Fixed tests that were broken. --- tests/coverage.cfg | 3 +- .../test_cli/test_docker_commands.py | 24 +++++---- .../test_tethys_app_uninstall.py | 51 +++++-------------- .../commands/tethys_app_uninstall.py | 13 +++-- 4 files changed, 34 insertions(+), 57 deletions(-) diff --git a/tests/coverage.cfg b/tests/coverage.cfg index 10e49ff7c..68d61a745 100644 --- a/tests/coverage.cfg +++ b/tests/coverage.cfg @@ -6,10 +6,9 @@ source = $TETHYS_TEST_DIR/../tethys_apps $TETHYS_TEST_DIR/../tethys_config $TETHYS_TEST_DIR/../tethys_gizmos $TETHYS_TEST_DIR/../tethys_portal - $TETHYS_TEST_DIR/../tethys_sdk $TETHYS_TEST_DIR/../tethys_services -omit = *.egg-info, */migrations/*, tethys_sdk/*, tethys_portal/wsgi.py +omit = *.egg-info, */migrations/*, $TETHYS_TEST_DIR/../tethys_sdk*, $TETHYS_TEST_DIR/../tethys_portal/wsgi.py, */settings* # branch = True diff --git a/tests/unit_tests/test_tethys_apps/test_cli/test_docker_commands.py b/tests/unit_tests/test_tethys_apps/test_cli/test_docker_commands.py index b081bd703..c27577206 100644 --- a/tests/unit_tests/test_tethys_apps/test_cli/test_docker_commands.py +++ b/tests/unit_tests/test_tethys_apps/test_cli/test_docker_commands.py @@ -528,20 +528,22 @@ def test_docker_ip(self, mock_dc, mock_containers, mock_status, mock_pretty_outp mock_status.assert_called_once_with(mock_dc()) po_call_args = mock_pretty_output().__enter__().write.call_args_list - self.assertEqual(11, len(po_call_args)) + self.assertEqual(13, len(po_call_args)) self.assertIn('PostGIS/Database:', po_call_args[0][0][0]) self.assertEquals(' Host: host', po_call_args[1][0][0]) self.assertEquals(' Port: 123', po_call_args[2][0][0]) - - self.assertIn('GeoServer:', po_call_args[3][0][0]) - self.assertEquals(' Host: host', po_call_args[4][0][0]) - self.assertEquals(' Port: 234', po_call_args[5][0][0]) - self.assertEquals(' Endpoint: http://host:234/geoserver/rest', po_call_args[6][0][0]) - - self.assertIn('52 North WPS:', po_call_args[7][0][0]) - self.assertEquals(' Host: host', po_call_args[8][0][0]) - self.assertEquals(' Port: 456', po_call_args[9][0][0]) - self.assertEquals(' Endpoint: http://host:456/wps/WebProcessingService\n', po_call_args[10][0][0]) + self.assertEquals(' Endpoint: postgresql://:@host:123/', po_call_args[3][0][0]) + + self.assertIn('GeoServer:', po_call_args[4][0][0]) + self.assertEquals(' Host: host', po_call_args[5][0][0]) + self.assertEquals(' Primary Port: 8181', po_call_args[6][0][0]) + self.assertEquals(' Node Ports: 234', po_call_args[7][0][0]) + self.assertEquals(' Endpoint: http://host:8181/geoserver/rest', po_call_args[8][0][0]) + + self.assertIn('52 North WPS:', po_call_args[9][0][0]) + self.assertEquals(' Host: host', po_call_args[10][0][0]) + self.assertEquals(' Port: 456', po_call_args[11][0][0]) + self.assertEquals(' Endpoint: http://host:456/wps/WebProcessingService\n', po_call_args[12][0][0]) @mock.patch('tethys_apps.cli.docker_commands.pretty_output') @mock.patch('tethys_apps.cli.docker_commands.get_docker_container_status') diff --git a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py index c84ba3ca7..a310794f2 100644 --- a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py +++ b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py @@ -27,23 +27,6 @@ def test_tethys_app_uninstall_add_arguments(self): self.assertIn('[-e]', parser.format_usage()) self.assertIn('--extension', parser.format_help()) - @mock.patch('warnings.warn') - @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.exit') - @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_extensions') - @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_apps') - def test_tethys_app_uninstall_handle_apps_warning(self, mock_installed_apps, mock_installed_extensions, mock_exit, - mock_warnings): - mock_installed_apps.return_value = [''] - mock_installed_extensions.return_value = [''] - mock_exit.side_effect = SystemExit - - cmd = tethys_app_uninstall.Command() - self.assertRaises(SystemExit, cmd.handle, app_or_extension=['tethysapp.foo_app'], is_extension=False) - - mock_installed_apps.assert_called_once() - mock_installed_extensions.assert_not_called() - mock_warnings.assert_called_once() - @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.exit') @mock.patch('sys.stdout', new_callable=StringIO) @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.input') @@ -110,42 +93,32 @@ def test_tethys_app_uninstall_handle_apps_delete_rmtree_Popen_remove_exceptions( mock_os_remove.assert_called_with('/foo/tethysapp-foo-app-nspkg.pth') mock_join.assert_called() - @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.os.path.join') - @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.os.remove') - @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.subprocess.Popen') @mock.patch('warnings.warn') + @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.exit') @mock.patch('sys.stdout', new_callable=StringIO) - @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.input') @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_extensions') @mock.patch('tethys_apps.management.commands.tethys_app_uninstall.get_installed_tethys_apps') @mock.patch('tethys_apps.models.TethysExtension') @mock.patch('tethys_apps.models.TethysApp') - def test_tethys_app_uninstall_handle_extension_DoesNotExist(self, mock_app, mock_extension, mock_installed_apps, - mock_installed_extensions, mock_input, mock_stdout, - mock_warnings, mock_popen, mock_os_remove, mock_join): + def test_tethys_app_uninstall_handle_module_and_db_not_found(self, mock_app, mock_extension, mock_installed_apps, + mock_installed_extensions, mock_stdout, + mock_exit, mock_warn): + # Raise DoesNotExist on db query mock_app.objects.get.return_value = mock.MagicMock() mock_app.DoesNotExist = TethysApp.DoesNotExist mock_extension.DoesNotExist = TethysExtension.DoesNotExist mock_app.objects.get.side_effect = TethysApp.DoesNotExist mock_extension.objects.get.return_value = mock.MagicMock() mock_extension.objects.get.side_effect = TethysExtension.DoesNotExist + + # No installed apps or extensions returned mock_installed_apps.return_value = {} - mock_installed_extensions.return_value = {'foo_extension': '/foo/foo_extension'} - mock_input.side_effect = ['yes'] - mock_popen.side_effect = KeyboardInterrupt - mock_os_remove.return_value = True - mock_join.return_value = '/foo/tethysext-foo-extension-nspkg.pth' + mock_installed_extensions.return_value = {} + mock_exit.side_effect = SystemExit cmd = tethys_app_uninstall.Command() - cmd.handle(app_or_extension=['tethysext.foo_extension'], is_extension=True) + self.assertRaises(SystemExit, cmd.handle, app_or_extension=['tethysext.foo_extension'], is_extension=True) mock_installed_apps.assert_not_called() - mock_installed_extensions.assert_called_once() - self.assertIn('successfully uninstalled', mock_stdout.getvalue()) - mock_warnings.assert_called_once() - mock_app.objects.get.assert_not_called() - mock_extension.objects.get.assert_called() - mock_popen.assert_called_once_with(['pip', 'uninstall', '-y', 'tethysext-foo_extension'], stderr=-2, stdout=-1) - mock_os_remove.assert_any_call('/foo/tethysext-foo-extension-nspkg.pth') - mock_os_remove.assert_called_with('/foo/tethysext-foo-extension-nspkg.pth') - mock_join.assert_called() + mock_installed_extensions.assert_called() + mock_warn.assert_called_once() diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index 1524fa2fc..6b316377a 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -1,6 +1,6 @@ """ ******************************************************************************** -* Name: collectworkspaces.py +* Name: tethys_app_uninstall.py * Author: Nathan Swain * Created On: August 6, 2015 * Copyright: (c) Brigham Young University 2015 @@ -51,11 +51,17 @@ def handle(self, *args, **options): TethysModel = TethysApp if not options['is_extension'] else TethysExtension db_app = None db_found = True + try: db_app = TethysModel.objects.get(package=item_name) except TethysModel.DoesNotExist: db_found = False + if not module_found and not db_found: + warnings.warn('WARNING: {0} with name "{1}" cannot be uninstalled, because it is not installed or' + ' not an {0}.'.format(app_or_extension, item_name)) + exit(0) + # Confirm item_with_prefix = '{0}-{1}'.format(PREFIX, item_name) @@ -74,10 +80,7 @@ def handle(self, *args, **options): # Remove app from database if db_found and db_app: - try: - db_app.delete() - except TethysModel.DoesNotExist: - warnings.warn('WARNING: The {} was not found in the database.'.format(app_or_extension.lower())) + db_app.delete() if module_found and not options['is_extension']: try: From 490dce03ca11eaa4c1e1bd68e0c8ddd51194409c Mon Sep 17 00:00:00 2001 From: nswain Date: Mon, 17 Dec 2018 16:31:05 -0700 Subject: [PATCH 199/215] Moved 2.0.0 release notes to prior releases docs page. --- docs/whats_new/prior_releases.rst | 147 +++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/docs/whats_new/prior_releases.rst b/docs/whats_new/prior_releases.rst index e1ab0c4e2..011e9897b 100644 --- a/docs/whats_new/prior_releases.rst +++ b/docs/whats_new/prior_releases.rst @@ -2,10 +2,155 @@ Prior Release Notes ******************* -**Last Updated:** December 10, 2016 +**Last Updated:** December 2017 Information about prior releases is shown here. +Release 2.0.0 +============= + +Powered by Miniconda Environment +-------------------------------- + +* Tethys Platform is now installed in a Miniconda environment. +* Using the Miniconda includes Conda, an open source Python package management system +* Conda can be used to install Python dependencies as well as system dependencies +* Installing packages like GDAL or NetCDF4 are as easy as ``conda install gdal`` +* Conda is cross platform: it works on Windows, Linux, and MacOS + +See: `Miniconda `_ and `Conda `_ + +Cross Platform Support +---------------------- + +* Develop natively on Windows, Mac, or Linux! +* No more virtual machines. +* Be careful with your paths. + +See: :doc:`../installation` + +Installation Scripts +-------------------- + +* Completely automated installation of Tethys +* Scripts provided for Mac, Linux, and Windows. + +See: :doc:`../installation` + +Python 3 +-------- + +* Experimental Python 3 Support in 2.0.0 +* Tethys Dataset Services is not completely Python 3 compatible +* Use ``--python-version 3`` option on the installation script +* Python 2 support will be dropped in version 2.1 + +See: :doc:`../installation` + +Templating API +-------------- + +* Leaner, updated theme for app base template. +* New ``header_buttons`` block for adding custom buttons to app header. + +See: :doc:`../tethys_sdk/templating` + +App Settings +------------ + +* Developers can create App Settings, which are configured in the admin interface of the Tethys Portal. +* Types of settings that can be created include Custom Settings, Persistent Store Settings, Dataset Service Settings, Spatial Dataset Service Settings, and Web Processing Service Settings. +* The way Tethys Services are allocated to apps is now done through App Settings. +* All apps using the Persistent Stores APIs, Dataset Services APIs, or Web Processing Services APIs prior to version 2.0.0 will need to be refactored to use the new App settings approach. + +See: :doc:`../tethys_sdk/app_settings` + +Commandline Interface +--------------------- + +* Added ``tethys list`` command that lists installed apps. +* Completely overhauled scaffold command that works cross-platform. +* New options for scaffold command that allow automatically accepting the defaults and overwriting project if it already exists. + +See: :ref:`tethys_list_cmd` and :ref:`tethys_scaffold_cmd` + +Tutorials +--------- + +* Brand new Getting Started Tutorial +* Demonstration of most Tethys SDK APIs + +See: :doc:`../tutorials/getting_started` + +Gizmos +------ + +* New way to call them +* New load dependencies Method +* Updated select_gizmo to allow Select2 options to be passed in. + +See: :doc:`../tethys_sdk/gizmos` + +Map View +-------- + +* Updated OpenLayers libraries to version 4.0 +* Fixes to make MapView compatible with Internet Explorer +* Can configure styling of MVDraw overlay layer +* New editable attribute for MVLayers to lock layers from being edited +* Added data attribute to MVLayer to allow passing custom attributes with layers for use in custom JavaScript +* A basemap switcher tool is now enabled on the map with the capability to configure multiple basemaps, including turning the basemap off. +* Added the ability to customize some styles of vector MVLayers. + +See: :doc:`../tethys_sdk/gizmos/map_view` + +Esri Map View +------------- + +* New map Gizmo that uses ArcGIS for JavaScript API. + +See: :doc:`../tethys_sdk/gizmos/esri_map` + +Plotly View and Bokeh View Gizmos +--------------------------------- + +* True open source options for plotting in Tethys + +See: :doc:`../tethys_sdk/gizmos/bokeh_view` and :doc:`../tethys_sdk/gizmos/plotly_view` + +DataTable View Gizmos +--------------------- + +* Interactive table gizmo based on Data Tables. + +See: :doc:`../tethys_sdk/gizmos/datatable_view` + +Security +-------- + +* Sessions will now timeout and log user out after period of inactivity. +* When user closes browser, they are automatically logged out now. +* Expiration times can be configured in settings. + +See: :doc:`../installation/platform_settings` + +HydroShare OAuth Backend and Helper Function +-------------------------------------------- + +* Refactor default HydroShare OAuth backend; Token refresh is available; Add backends for HydroShare-beta and HydroShare-playground. +* Include hs_restclient library in requirements.txt; Provide a helper function to help initialize the ``hs`` object based on HydroShare social account. +* Update python-social-auth to 0.2.21. + +See: :doc:`../tethys_portal/social_auth` + + + +Bugs +---- + +* Fixed issue where ``tethys uninstall `` command was not uninstalling fully. + + Release 1.4.0 ============= From d4bf918c4f25196412d05b30f9957001923eaf8a Mon Sep 17 00:00:00 2001 From: nswain Date: Tue, 18 Dec 2018 09:31:24 -0700 Subject: [PATCH 200/215] Updated the What's New page for version 2.1.0. --- docs/installation/production/installation.rst | 1 + docs/tethys_sdk.rst | 14 +- docs/tethys_sdk/extensions.rst | 6 +- docs/tethys_sdk/tethys_cli.rst | 4 +- docs/whats_new.rst | 182 ++++++++---------- 5 files changed, 89 insertions(+), 118 deletions(-) diff --git a/docs/installation/production/installation.rst b/docs/installation/production/installation.rst index 1ffd3ffde..478f31052 100644 --- a/docs/installation/production/installation.rst +++ b/docs/installation/production/installation.rst @@ -108,6 +108,7 @@ For more information about setting up email capabilities for Tethys Platform, re For an excellent guide on setting up Postfix on Ubuntu, refer to `How To Install and Setup Postfix on Ubuntu 14.04 `_. +.. _production_installation_ssl: 4. Setup SSL (https) on the Tethys and Geoserver (Recommended) ============================================================== diff --git a/docs/tethys_sdk.rst b/docs/tethys_sdk.rst index ed6618ec3..b61fa09a4 100644 --- a/docs/tethys_sdk.rst +++ b/docs/tethys_sdk.rst @@ -9,17 +9,17 @@ The Tethys Platform provides a Python Software Development Kit (SDK) to make it .. toctree:: :maxdepth: 2 + tethys_sdk/tethys_cli tethys_sdk/app_class - tethys_sdk/app_settings - tethys_sdk/tethys_services tethys_sdk/templating - tethys_sdk/gizmos + tethys_sdk/app_settings tethys_sdk/compute - tethys_sdk/jobs - tethys_sdk/workspaces tethys_sdk/handoff - tethys_sdk/rest_api + tethys_sdk/jobs tethys_sdk/permissions - tethys_sdk/tethys_cli + tethys_sdk/rest_api + tethys_sdk/gizmos tethys_sdk/testing tethys_sdk/extensions + tethys_sdk/tethys_services + tethys_sdk/workspaces diff --git a/docs/tethys_sdk/extensions.rst b/docs/tethys_sdk/extensions.rst index a1e071f6c..cf43e0b9e 100644 --- a/docs/tethys_sdk/extensions.rst +++ b/docs/tethys_sdk/extensions.rst @@ -1,6 +1,6 @@ -***************** -Tethys Extensions -***************** +********************* +Tethys Extensions API +********************* **Last Updated:** February 22, 2018 diff --git a/docs/tethys_sdk/tethys_cli.rst b/docs/tethys_sdk/tethys_cli.rst index d821a386a..f2d902de5 100644 --- a/docs/tethys_sdk/tethys_cli.rst +++ b/docs/tethys_sdk/tethys_cli.rst @@ -78,6 +78,8 @@ Aids the installation of Tethys by automating the creation of supporting files. $ tethys gen apache $ tethys gen apache -d /path/to/destination +.. _tethys_manage_cmd: + manage [options] ----------------------------- @@ -93,7 +95,7 @@ This command contains several subcommands that are used to help manage Tethys Pl * *collectstatic*: Link app and extension static/public directories to STATIC_ROOT directory and then run Django's collectstatic command. Preprocessor and wrapper for ``manage.py collectstatic``. * *collectworkspaces*: Link app workspace directories to TETHYS_WORKSPACES_ROOT directory. * *collectall*: Convenience command for running both *collectstatic* and *collectworkspaces*. - * *superuser*: Create a new superuser/website admin for your Tethys Portal. + * *createsuperuser*: Create a new superuser/website admin for your Tethys Portal. **Optional Arguments:** diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 4f0ebc1fd..4fe76b551 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -2,153 +2,121 @@ What's New ********** -**Last Updated:** May 2017 +**Last Updated:** December 2018 Refer to this article for information about each new release of Tethys Platform. Release |version| ================= -Powered by Miniconda Environment --------------------------------- +Python 3 Support +---------------- -* Tethys Platform is now installed in a Miniconda environment. -* Using the Miniconda includes Conda, an open source Python package management system -* Conda can be used to install Python dependencies as well as system dependencies -* Installing packages like GDAL or NetCDF4 are as easy as ``conda install gdal`` -* Conda is cross platform: it works on Windows, Linux, and MacOS +* Python 3 officially supported in Tethys Platform. +* Python 2 support officially deprecated and will be dropped when Tethys Platform 3.0 is released. +* Tethys needs to migrate to Python 3 only so we can upgrade to Django 2.0, which only supports Python 3. -See: `Miniconda `_ and `Conda `_ +.. important:: -Cross Platform Support ----------------------- + Migrate your apps to Python 3. After Tethys Platform 3.0 is released, Python 2 will no longer be supported by Tethys Platform. -* Develop natively on Windows, Mac, or Linux! -* No more virtual machines. -* Be careful with your paths. -See: :doc:`installation` +100% Unit Test Coverage +----------------------- -Installation Scripts --------------------- - -* Completely automated installation of Tethys -* Scripts provided for Mac, Linux, and Windows. +* Tests pass in Python 2 and Python 3. +* Unit tests cover 100% of testable code. +* Code base linted using flake8 to enforce PEP-8 and other Python coding best practices. +* Automated test execution on Travis-CI and Stickler-CI whenever a Pull Request is submitted. +* Added badges to the README to display build/testing, coverage, and docs status on github repository. +* All of this will lead to increased stability in this and future releases. -See: :doc:`installation` +See: `Tethys Platform Repo `_ for build and coverage information. -Python 3 --------- +Tethys Extensions +----------------- -* Experimental Python 3 Support in 2.0.0 -* Tethys Dataset Services is not completely Python 3 compatible -* Use ``--python-version 3`` option on the installation script -* Python 2 support will be dropped in version 2.1 +* Customize Tethys Platform functionality. +* Create your own gizmos. +* Centralize app logic that is common to multiple apps in an extension. -See: :doc:`installation` +See: :doc:`./tethys_sdk/extensions` -Templating API +Map View Gizmo -------------- -* Leaner, updated theme for app base template. -* New ``header_buttons`` block for adding custom buttons to app header. - -See: :doc:`tethys_sdk/templating` - -App Settings ------------- - -* Developers can create App Settings, which are configured in the admin interface of the Tethys Portal. -* Types of settings that can be created include Custom Settings, Persistent Store Settings, Dataset Service Settings, Spatial Dataset Service Settings, and Web Processing Service Settings. -* The way Tethys Services are allocated to apps is now done through App Settings. -* All apps using the Persistent Stores APIs, Dataset Services APIs, or Web Processing Services APIs prior to version 2.0.0 will need to be refactored to use the new App settings approach. - -See: :doc:`./tethys_sdk/app_settings` - -Commandline Interface ---------------------- - -* Added ``tethys list`` command that lists installed apps. -* Completely overhauled scaffold command that works cross-platform. -* New options for scaffold command that allow automatically accepting the defaults and overwriting project if it already exists. - -See: :ref:`tethys_list_cmd` and :ref:`tethys_scaffold_cmd` - -Tutorials ---------- - -* Brand new Getting Started Tutorial -* Demonstration of most Tethys SDK APIs - -See: :doc:`./tutorials/getting_started` - -Gizmos ------- - -* New way to call them -* New load dependencies Method -* Updated select_gizmo to allow Select2 options to be passed in. - -See: :doc:`tethys_sdk/gizmos` - -Map View --------- - -* Updated OpenLayers libraries to version 4.0 -* Fixes to make MapView compatible with Internet Explorer -* Can configure styling of MVDraw overlay layer -* New editable attribute for MVLayers to lock layers from being edited -* Added data attribute to MVLayer to allow passing custom attributes with layers for use in custom JavaScript -* A basemap switcher tool is now enabled on the map with the capability to configure multiple basemaps, including turning the basemap off. -* Added the ability to customize some styles of vector MVLayers. +* Added support for many more basemaps. +* Added Esri, Stamen, CartoDB. +* Support for custom XYZ services as basemaps. +* User can set OpenLayers version. +* Uses jsdelivr to load custom versions (see: ``_) +* Default OpenLayers version updated to 5.3.0. See: :doc:`tethys_sdk/gizmos/map_view` -Esri Map View -------------- +Class-based Controllers +----------------------- -* New map Gizmo that uses ArcGIS for JavaScript API. +* Added ``TethysController`` to SDK to support class-based views in Tethys apps. +* Inherits from django ``View`` class. +* Includes ``as_controller`` method, which is a thin wrapper around ``as_view`` method to better match Tethys terminology. +* UrlMaps can take class-based Views as the controller argument: ``MyClassBasedController.as_controller(...)`` +* More to come in the future. -See: :doc:`tethys_sdk/gizmos/esri_map` +See: `Django Class-based views `_ to get started. -Plotly View and Bokeh View Gizmos ---------------------------------- +Partial Install Options +----------------------- -* True open source options for plotting in Tethys +* The Tethys Platform installation scripts now allow for partial installation. +* Install in existing Conda environment or against existing database. +* Linux and Mac only. -See: :doc:`tethys_sdk/gizmos/bokeh_view` and :doc:`tethys_sdk/gizmos/plotly_view` +See: :doc:`./installation/linux_and_mac` -DataTable View Gizmos +Commandline Interface --------------------- -* Interactive table gizmo based on Data Tables. - -See: :doc:`tethys_sdk/gizmos/datatable_view` - -Security --------- +* New commands to manage app settings and services. +* ``tethys app_settings`` - List settings for an app. +* ``tethys services`` - List, create, and remove Tethys services (only supports persistent store services and spatial dataset services for now). +* ``tethys link`` - Link/Assign a Tethys service to a corresponding app setting. +* ``tethys schedulers`` - List, create, and remove job Schedulers. +* ``tethys manage sync`` - Sync app and extensions with Tethys database without a full Tethys start. -* Sessions will now timeout and log user out after period of inactivity. -* When user closes browser, they are automatically logged out now. -* Expiration times can be configured in settings. +See: :ref:`tethys_cli_app_settings`, :ref:`tethys_cli_services`, :ref:`tethys_cli_link`, :ref:`tethys_cli_schedulers`, and :ref:`tethys_manage_cmd` -See: :doc:`installation/platform_settings` +Dockerfile +---------- -HydroShare OAuth Backend and Helper Function --------------------------------------------- +* New Dockerfile for Tethys Platform. +* Use it to build Docker images. +* Use it as a base for your own Docker images that have your apps installed. +* Includes supporting salt files. +* Dockerfile has been optimized to minimize the size of the produced image. +* Threading is enabled in the Docker container. -* Refactor default HydroShare OAuth backend; Token refresh is available; Add backends for HydroShare-beta and HydroShare-playground. -* Include hs_restclient library in requirements.txt; Provide a helper function to help initialize the ``hs`` object based on HydroShare social account. -* Update python-social-auth to 0.2.21. - -See: :doc:`tethys_portal/social_auth` +See: `Docker Documentation `_ to learn how to use Docker in your workflows. +API Tokens for Users +-------------------- +* API tokens are automatically generated for users when they are created. +* Use User API tokens to access protected REST API views. +Documentation +------------- +* Added SSL setup instruction to Production Installation (see: :ref:`production_installation_ssl`) Bugs ---- -* Fixed issue where ``tethys uninstall `` command was not uninstalling fully. +* Fixed grammar in forget password link. +* Refactored various methods and decorators to use new way of using Django methods ``is_authenticated`` and ``is_anonymous``. +* Fixed bug with Gizmos that was preventing errors from being displayed when in debug mode. +* Fixed various bugs with uninstalling apps and extensions. +* Fixed bugs with get_persistent_store_setting methods. +* Fixed a naming conflict in the SelectInput gizmo. +* Fixed numerous bugs identified by new tests. Prior Release Notes =================== From 9eb3f6eb2b961e49e072443ff62f96334febf1bd Mon Sep 17 00:00:00 2001 From: nswain Date: Tue, 18 Dec 2018 13:45:40 -0700 Subject: [PATCH 201/215] Preliminary update for the upgrade documentation for 2.0.1. Added Python 2 deprecation warning to home page of docs. --- docs/index.rst | 8 +- docs/installation/linux_and_mac.rst | 3 + docs/installation/production.rst | 3 - docs/installation/production/update.rst | 131 ----------------------- docs/installation/update.rst | 136 +++++++++++++++++++----- 5 files changed, 121 insertions(+), 160 deletions(-) delete mode 100644 docs/installation/production/update.rst diff --git a/docs/index.rst b/docs/index.rst index 1498306d9..4838b9f33 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,14 +7,18 @@ Tethys Platform |version| ************************* -**Last Updated:** December 12, 2016 +**Last Updated:** December 2018 -Tethys is a platform that can be used to develop and host environmental web apps. It includes a suite of free and open source software (FOSS) that has been carefully selected to address the unique development needs of water resources web apps. Tethys web apps are developed using a Python software development kit (SDK) which includes programmatic links to each software component. Tethys Platform is powered by the Django Python web framework giving it a solid web foundation with excellent security and performance. Refer to the :doc:`./features` article for an overview of the features of Tethys Platform. +Tethys is a platform that can be used to develop and host environmental web apps. It includes a suite of free and open source software (FOSS) that has been carefully selected to address the unique development needs of environmental web apps. Tethys web apps are developed using a Python software development kit (SDK) which includes programmatic links to each software component. Tethys Platform is powered by the Django Python web framework giving it a solid web foundation with excellent security and performance. Refer to the :doc:`./features` article for an overview of the features of Tethys Platform. .. important:: Tethys Platform |version| has arrived! Check out the :doc:`./whats_new` article for a description of the new features and changes. +.. warning:: + + Python 2 support is officially deprecated in this release. It will no longer be supported in the next release of Tethys Platform. Migrate now! + Contents ======== diff --git a/docs/installation/linux_and_mac.rst b/docs/installation/linux_and_mac.rst index 833bb4d38..ab588205b 100644 --- a/docs/installation/linux_and_mac.rst +++ b/docs/installation/linux_and_mac.rst @@ -29,6 +29,9 @@ For Systems with `curl` (e.g. Mac OSX and CentOS): curl :install_tethys:`sh` -o ./install_tethys.sh bash install_tethys.sh -b |branch| + +.. _install_script_options: + Install Script Options ...................... diff --git a/docs/installation/production.rst b/docs/installation/production.rst index 2c807f2c0..8d3022b8e 100644 --- a/docs/installation/production.rst +++ b/docs/installation/production.rst @@ -13,6 +13,3 @@ The following instructions can be used to install Tethys Platform on a productio production/installation production/app_installation production/distributed - production/update - - diff --git a/docs/installation/production/update.rst b/docs/installation/production/update.rst deleted file mode 100644 index 68257f704..000000000 --- a/docs/installation/production/update.rst +++ /dev/null @@ -1,131 +0,0 @@ -******************** -Upgrade to |version| -******************** - -**Last Updated:** June 2017 - -.. warning:: - - UNDER CONSTRUCTION: Pardon our dust, this documentation has not been updated yet. These instructions will NOT work. We apologize for the inconvenience. - -1. Pull Repository -================== - -When you installed Tethys Platform you did so using it's remote Git repository on GitHub. To get the latest version of Tethys Platform, you will need to pull the latest changes from this repository: - -:: - - $ sudo su - $ cd /usr/lib/tethys/src - $ git pull origin master - $ exit - -2. Install Requirements and Run Setup Script -============================================ - -Install new dependencies and upgrade old ones: - -:: - - $ sudo su - $ . /usr/lib/tethys/bin/activate - (tethys) $ pip install --upgrade -r /usr/lib/tethys/src/requirements.txt - (tethys) $ python /usr/lib/tethys/src/setup.py develop - (tethys) $ exit - - - -3. Generate New Settings Script -=============================== - -Backup your old settings script (``settings.py``) and generate a new settings file to get the latest version of the settings. Then copy any settings (like database usernames and passwords) from the backed up settings script to the new settings script. - -:: - - $ sudo su - (tethys) $ mv /usr/lib/tethys/src/tethys_apps/settings.py /usr/lib/tethys/src/tethys_apps/settings.py_bak - (tethys) $ tethys gen settings -d /usr/lib/tethys/src/tethys_apps - (tethys) $ exit - -.. caution:: - - Don't forget to copy any settings from the backup settings script (``settings.py_bak``) to the new settings script. Common settings that need to be copied include: - - * DEBUG - * ALLOWED_HOSTS - * DATABASES, TETHYS_DATABASES - * STATIC_ROOT, TETHYS_WORKSPACES_ROOT - * EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_USE_TLS, DEFAULT_FROM_EMAIL - * SOCIAL_OAUTH_XXXX_KEY, SOCIAL_OAUTH_XXXX_SECRET - * BYPASS_TETHYS_HOME_PAGE - - After you have copied these settings, you can delete the backup settings script. - -4. Setup Social Authentication (optional) -========================================= - -If you would like to allow users to signup using their social credentials from Facebook, Google, LinkedIn, and/or HydroShare, follow the :doc:`../../tethys_portal/social_auth` instructions. - -5. Sync the Database -==================== - -Start the database docker if not already started and apply any changes to the database that may have been issued with the new release: - -:: - - $ . /usr/lib/tethys/bin/activate - (tethys) $ tethys docker start -c postgis - (tethys) $ tethys manage syncdb - -.. note:: - - For migration errors use: - - :: - - $ cd ~/usr/lib/tethys/src - $ python manage.py makemigrations --merge - $ tethys manage syncdb - -6. Collect Static Files -======================= - -Collect the new static files and update the old ones: - -:: - - $ sudo su - (tethys) $ tethys manage collectstatic - (tethys) $ exit - -7. Transfer Ownership to Apache -=============================== - -Assign ownership of Tethys Platform files and resources to the Apache user: - -:: - - $ sudo chown -R www-data:www-data /usr/lib/tethys/src /var/www/tethys - -.. note:: - - The name of the Apache user in RedHat or CentOS flavored systems is ``apache``, not ``www-data``. - -8. Restart Apache -================= - -Restart Apache to effect the changes: - -:: - - $ sudo service apache2 restart - -.. note:: - - The command for managing Apache on CentOS or RedHat flavored systems is ``httpd``. Restart as follows: - - :: - - $ sudo service httpd restart - - diff --git a/docs/installation/update.rst b/docs/installation/update.rst index 15887ec0e..9e891701f 100644 --- a/docs/installation/update.rst +++ b/docs/installation/update.rst @@ -2,32 +2,123 @@ Upgrade to |version| ******************** -**Last Updated:** June 2017 +**Last Updated:** December 2018 + +This document provides a recommendation for how to upgrade Tethys Platform from the last release version. If you have not updated Tethys Platform to the last release version previously, please revisit the documentation for that version and follow those upgrade instructions first. + + +Upgrade using the Install Script +-------------------------------- + +Run the following commands from a terminal to download and run the Tethys Platform install script. + +For systems with `wget` (most Linux distributions): + +1. Upgrade using new Python 3 Environment (recommended) +======================================================= + +.. parsed-literal:: + + . t && conda activate base + conda env remove -n tethys + conda deactivate + wget :install_tethys:`sh` + bash install_tethys.sh --partial-tethys-install cesiat -b |branch| + +For Systems with `curl` (e.g. Mac OSX and CentOS): + +.. parsed-literal:: + + . t && conda activate base + conda env remove -n tethys + conda deactivate + curl :install_tethys:`sh` -o ./install_tethys.sh + bash install_tethys.sh --partial-tethys-install cesiat -b |branch| + +2. Upgrade using existing Python 2 Environment (discouraged) +============================================================ + +.. parsed-literal:: + + wget :install_tethys:`sh` + bash install_tethys.sh --partial-tethys-install cusiat --python-version 2 -b |branch| + +For Systems with `curl` (e.g. Mac OSX and CentOS): + +.. parsed-literal:: + + curl :install_tethys:`sh` -o ./install_tethys.sh + bash install_tethys.sh --partial-tethys-install cusiat --python-version 2 -b |branch| .. warning:: - UNDER CONSTRUCTION: Pardon our dust, this documentation has not been updated yet. These instructions will NOT work. We apologize for the inconvenience. + Python 2 support is officially deprecated in this release. It will no longer be supported in the next release of Tethys Platform. Migrate now! + + +.. tip:: + + These instructions assume your previous installation was done using the install script with the default configuration. If you used any custom options when installing the environment initially, you will need to specify those same options. For an explanation of the installation script options, see: :ref:`install_script_options`. + + +Upgrade Manually +---------------- 1. Get the Latest Version ========================= -When you installed Tethys Platform you did so using it's remote Git repository on GitHub. To get the latest version of Tethys Platform, you will need to pull the latest changes from this repository: +Change into the directory containing your Tethys Platform installation (tethys_home). The default location is ``~/tethys/src``: :: - $ cd /usr/lib/tethys/src - $ git pull origin master + $ cd + $ git pull origin |branch| -2. Install Requirements and Run Setup Script -============================================ +2. Upgrade or Create New Environment +==================================== -Install new dependencies and upgrade old ones: +a. Upgrade Conda Dependencies -:: + Install new dependencies and upgrade old ones: + + :: + + $ . t + (tethys) $ conda env update -f environment_py.yml + (tethys) $ python setup.py develop + + **OR** + +b. Create new Conda Environment + + Remove the old environment: + + :: + + $ .t && conda activate base + (base) $ conda env remove -n tethys + + Create new Python 3 environment (recommended): + + :: + + (base) $ conda env create -f environment_py3.yml + (base) $ conda activate tethys + (tethys) $ python setup.py develop + + **OR** + + Create new Python 2 environment (discouraged): + + :: + + (base) $ conda env create -f environment_py2.yml + (base) $ conda activate tethys + (tethys) $ python setup.py develop + + .. warning:: + + Python 2 support is officially deprecated in this release. It will no longer be supported in the next release of Tethys Platform. Migrate now! - $ . /usr/lib/tethys/bin/activate - (tethys) $ pip install --upgrade -r /usr/lib/tethys/src/requirements.txt - (tethys) $ python /usr/lib/tethys/src/setup.py develop 3. Generate New Settings Script =============================== @@ -36,8 +127,8 @@ Backup your old settings script (``settings.py``) and generate a new settings fi :: - (tethys) $ mv /usr/lib/tethys/src/tethys_apps/settings.py /usr/lib/tethys/src/tethys_apps/settings.py_bak - (tethys) $ tethys gen settings -d /usr/lib/tethys/src/tethys_apps + (tethys) $ mv ./tethys_portal/settings.py ./tethys_portal/settings.py_bak + (tethys) $ tethys gen settings -d tethys_portal .. caution:: @@ -51,24 +142,21 @@ Backup your old settings script (``settings.py``) and generate a new settings fi * SOCIAL_OAUTH_XXXX_KEY, SOCIAL_OAUTH_XXXX_SECRET * BYPASS_TETHYS_HOME_PAGE - After you have copied these settings, you can delete or archive the backup settings script. - 4. Sync the Database ==================== -Start the database docker if not already started and apply any changes to the database that may have been issued with the new release: +Start the database you have been using for your Tethys Portal if it is not already running. Then migrate the Tethys database as follows: :: - (tethys) $ tethys docker start -c postgis (tethys) $ tethys manage syncdb -.. note:: +5. Create Shortcuts +=================== - For migration errors use: +Use the new install script to create shortcuts for your new environment: - :: +:: - $ cd ~/usr/lib/tethys/src - $ python manage.py makemigrations --merge - $ tethys manage syncdb + (tethys) $ cd scripts + (tethys) $ install_tethys.sh --partial-tethys-install at \ No newline at end of file From 8cb7e91ceab1d99ca181d2a9c9f27c9bd75a6dc3 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Mon, 17 Dec 2018 16:54:56 -0600 Subject: [PATCH 202/215] fix some bugs in the install script --- scripts/install_tethys.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index 2c4c55291..747f69872 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -105,6 +105,7 @@ CLONE_REPO="true" CREATE_ENV="true" CREATE_SETTINGS="true" SETUP_DB="true" +INITIALIZE_DB="true" CREATE_ENV_SCRIPTS="true" CREATE_SHORTCUTS="true" @@ -331,6 +332,7 @@ then then # clone Tethys repo echo "Cloning the Tethys Platform repo..." + conda activate conda install --yes git git clone https://github.com/tethysplatform/tethys.git "${TETHYS_HOME}/src" cd "${TETHYS_HOME}/src" @@ -424,7 +426,7 @@ then if [ -n "${CREATE_SHORTCUTS}" ] then echo "# Tethys Platform" >> ~/${BASH_PROFILE} - echo "alias t='. ${CONDA_HOME}/bin/activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} + echo "alias t='source ${CONDA_HOME}/etc/profile.d/conda.sh; conda activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} fi fi From c83a066dbc624b1808ba2f6bfa8272718156595c Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Wed, 19 Dec 2018 08:33:21 -0600 Subject: [PATCH 203/215] Changes to facilitate upgrading --- scripts/install_tethys.sh | 60 ++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index 747f69872..9fa02ef74 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -30,10 +30,12 @@ OPTIONS:\n \t \t FLAGS:\n \t \t\t m - Install Miniconda\n \t \t\t r - Clone Tethys repository\n +\t \t\t c - Checkout the branch specified by the option '--branch' (specifying the flag 'r' will also trigger this flag)\n \t \t\t e - Create Conda environment\n \t \t\t s - Create 'settings.py' file\n \t \t\t d - Setup local database server\n -\t \t\t i - Initialize database server with Tethys database and superuser\n +\t \t\t i - Initialize database server with the Tethys database (specifying the flag 'd' will also trigger this flag)\n +\t \t\t u - Add a Tethys Portal Super User to the user database (specifying the flag 'd' will also trigger this flag)\n \t \t\t a - Create activation/deactivation scripts for the Tethys Conda environment\n \t \t\t t - Create the 't' alias t\n\n @@ -102,10 +104,12 @@ DOCKER_OPTIONS='-d' INSTALL_MINICONDA="true" CLONE_REPO="true" +CHECKOUT_BRANCH="true" CREATE_ENV="true" CREATE_SETTINGS="true" SETUP_DB="true" INITIALIZE_DB="true" +CREATE_TETHYS_SUPER_USER="true" CREATE_ENV_SCRIPTS="true" CREATE_SHORTCUTS="true" @@ -196,10 +200,12 @@ case $key in # Set all steps to false be default and then activate only those steps that have been specified. INSTALL_MINICONDA= CLONE_REPO= + CHECKOUT_BRANCH= CREATE_ENV= CREATE_SETTINGS= SETUP_DB= INITIALIZE_DB= + CREATE_TETHYS_SUPER_USER= CREATE_ENV_SCRIPTS= CREATE_SHORTCUTS= @@ -209,6 +215,9 @@ case $key in if [[ "$2" = *"r"* ]]; then CLONE_REPO="true" fi + if [[ "$2" = *"c"* ]]; then + CHECKOUT_BRANCH="true" + fi if [[ "$2" = *"e"* ]]; then CREATE_ENV="true" fi @@ -221,6 +230,9 @@ case $key in if [[ "$2" = *"i"* ]]; then INITIALIZE_DB="true" fi + if [[ "$2" = *"u"* ]]; then + CREATE_TETHYS_SUPER_USER="true" + fi if [[ "$2" = *"a"* ]]; then CREATE_ENV_SCRIPTS="true" fi @@ -335,7 +347,12 @@ then conda activate conda install --yes git git clone https://github.com/tethysplatform/tethys.git "${TETHYS_HOME}/src" + fi + + if [ -n "${CHECKOUT_BRANCH}" ] || [ -n "${CLONE_REPO}" ] + then cd "${TETHYS_HOME}/src" + conda activate git checkout ${BRANCH} fi @@ -380,21 +397,27 @@ then createdb -U postgres -p ${TETHYS_DB_PORT} -O ${TETHYS_DB_SUPER_USERNAME} ${TETHYS_DB_SUPER_USERNAME} -E utf-8 -T template0 fi - if [ -n "${INITIALIZE_DB}" ] + if [ -n "${INITIALIZE_DB}" ] || [ -n "${SETUP_DB}" ] then # Initialize Tethys database tethys manage syncdb + fi + + if [ -n "${CREATE_TETHYS_SUPER_USER}" ] || [ -n "${SETUP_DB}" ] + then echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python "${TETHYS_HOME}/src/manage.py" shell pg_ctl -U postgres -D "${TETHYS_DB_DIR}/data" stop fi - echo "Deactivating the ${CONDA_ENV_NAME} environment..." - conda deactivate + if [ -n "${SETUP_DB}" ] + then + pg_ctl -U postgres -D "${TETHYS_DB_DIR}/data" stop + fi if [ -n "${CREATE_ENV_SCRIPTS}" ] then - # Create environment activate/deactivate scripts - mkdir -p "${ACTIVATE_DIR}" "${DEACTIVATE_DIR}" + # Create environment activatescripts + mkdir -p "${ACTIVATE_DIR}" echo "export TETHYS_HOME='${TETHYS_HOME}'" >> "${ACTIVATE_SCRIPT}" echo "export TETHYS_PORT='${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" @@ -408,6 +431,21 @@ then echo "alias tstopdb=tethys_stop_db" >> "${ACTIVATE_SCRIPT}" echo "alias tms='tethys manage start -p ${ALLOWED_HOST}:\${TETHYS_PORT}'" >> "${ACTIVATE_SCRIPT}" echo "alias tstart='tstartdb; tms'" >> "${ACTIVATE_SCRIPT}" + fi + + if [ -n "${CREATE_SHORTCUTS}" ] + then + echo "# Tethys Platform" >> ~/${BASH_PROFILE} + echo "alias t='source ${CONDA_HOME}/etc/profile.d/conda.sh; conda activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} + fi + + echo "Deactivating the ${CONDA_ENV_NAME} environment..." + conda deactivate + + if [ -n "${CREATE_ENV_SCRIPTS}" ] + then + # Create environment deactivate scripts + mkdir -p "${DEACTIVATE_DIR}" echo "unset TETHYS_HOME" >> "${DEACTIVATE_SCRIPT}" echo "unset TETHYS_PORT" >> "${DEACTIVATE_SCRIPT}" @@ -422,21 +460,11 @@ then echo "unalias tms" >> "${DEACTIVATE_SCRIPT}" echo "unalias tstart" >> "${DEACTIVATE_SCRIPT}" fi - - if [ -n "${CREATE_SHORTCUTS}" ] - then - echo "# Tethys Platform" >> ~/${BASH_PROFILE} - echo "alias t='source ${CONDA_HOME}/etc/profile.d/conda.sh; conda activate ${CONDA_ENV_NAME}'" >> ~/${BASH_PROFILE} - fi fi # Install Docker (if flag is set) set +e # don't exit on error anymore -# Rename some variables for reference after deactivating tethys environment. -TETHYS_CONDA_HOME=${CONDA_HOME} -TETHYS_CONDA_ENV_NAME=${CONDA_ENV_NAME} - # Install Production configuration if flag is set ubuntu_debian_production_install() { From c13ec0339f1ba381a689fec00f75c554fcbf02a4 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Wed, 19 Dec 2018 08:46:51 -0600 Subject: [PATCH 204/215] update docs --- docs/installation/linux_and_mac.rst | 9 +++++---- scripts/install_tethys.sh | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/installation/linux_and_mac.rst b/docs/installation/linux_and_mac.rst index 833bb4d38..8ad8b0fa0 100644 --- a/docs/installation/linux_and_mac.rst +++ b/docs/installation/linux_and_mac.rst @@ -92,10 +92,12 @@ Install Script Options Flags: * `m` - Install Miniconda * `r` - Clone Tethys repository + * `c` - Checkout the branch specified by the option `--branch` (specifying the flag `r` will also trigger this flag) * `e` - Create Conda environment * `s` - Create `settings.py` file - * `d` - Setup local database server - * `i` - Initialize database server with Tethys database and superuser + * `d` - Create a local database server + * `i` - Initialize database server with the Tethys database (specifying the flag `d` will also trigger this flag) + * `u` - Add a Tethys Portal Super User to the user database (specifying the flag `d` will also trigger this flag) * `a` - Create activation/deactivation scripts for the Tethys Conda environment * `t` - Create the `t` alias to activate the Tethys Conda environment @@ -103,13 +105,12 @@ Install Script Options * create a conda environment, * setup a local database server, - * initialize the database * create the conda activation/deactivation scripts, and * create the `t` shortcut, then you can run the following command:: - bash install_tethys.sh --partial-tethys-install ediat + bash install_tethys.sh --partial-tethys-install edat .. warning:: diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index 9fa02ef74..d040f2e82 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -33,7 +33,7 @@ OPTIONS:\n \t \t\t c - Checkout the branch specified by the option '--branch' (specifying the flag 'r' will also trigger this flag)\n \t \t\t e - Create Conda environment\n \t \t\t s - Create 'settings.py' file\n -\t \t\t d - Setup local database server\n +\t \t\t d - Create a local database server\n \t \t\t i - Initialize database server with the Tethys database (specifying the flag 'd' will also trigger this flag)\n \t \t\t u - Add a Tethys Portal Super User to the user database (specifying the flag 'd' will also trigger this flag)\n \t \t\t a - Create activation/deactivation scripts for the Tethys Conda environment\n From f2d22cfc7da6424402291db644abcc7b56369b71 Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Wed, 19 Dec 2018 10:09:08 -0600 Subject: [PATCH 205/215] fix bug in the install script --- scripts/install_tethys.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index d040f2e82..ed503a78b 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -406,7 +406,6 @@ then if [ -n "${CREATE_TETHYS_SUPER_USER}" ] || [ -n "${SETUP_DB}" ] then echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python "${TETHYS_HOME}/src/manage.py" shell - pg_ctl -U postgres -D "${TETHYS_DB_DIR}/data" stop fi if [ -n "${SETUP_DB}" ] From 8821f2b4408d775b6a12f75bc62464f39f14518c Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 19 Dec 2018 09:30:00 -0700 Subject: [PATCH 206/215] Updated update instructions to follow Scott's new install script workflow. --- docs/installation/update.rst | 150 +++++------------------------ docs/tethys_portal/social_auth.rst | 2 +- docs/whats_new.rst | 3 +- 3 files changed, 27 insertions(+), 128 deletions(-) diff --git a/docs/installation/update.rst b/docs/installation/update.rst index 9e891701f..61a8ab354 100644 --- a/docs/installation/update.rst +++ b/docs/installation/update.rst @@ -7,128 +7,18 @@ Upgrade to |version| This document provides a recommendation for how to upgrade Tethys Platform from the last release version. If you have not updated Tethys Platform to the last release version previously, please revisit the documentation for that version and follow those upgrade instructions first. -Upgrade using the Install Script --------------------------------- - -Run the following commands from a terminal to download and run the Tethys Platform install script. - -For systems with `wget` (most Linux distributions): - -1. Upgrade using new Python 3 Environment (recommended) -======================================================= - -.. parsed-literal:: - - . t && conda activate base - conda env remove -n tethys - conda deactivate - wget :install_tethys:`sh` - bash install_tethys.sh --partial-tethys-install cesiat -b |branch| - -For Systems with `curl` (e.g. Mac OSX and CentOS): - -.. parsed-literal:: - - . t && conda activate base - conda env remove -n tethys - conda deactivate - curl :install_tethys:`sh` -o ./install_tethys.sh - bash install_tethys.sh --partial-tethys-install cesiat -b |branch| - -2. Upgrade using existing Python 2 Environment (discouraged) -============================================================ - -.. parsed-literal:: - - wget :install_tethys:`sh` - bash install_tethys.sh --partial-tethys-install cusiat --python-version 2 -b |branch| - -For Systems with `curl` (e.g. Mac OSX and CentOS): - -.. parsed-literal:: - - curl :install_tethys:`sh` -o ./install_tethys.sh - bash install_tethys.sh --partial-tethys-install cusiat --python-version 2 -b |branch| - -.. warning:: - - Python 2 support is officially deprecated in this release. It will no longer be supported in the next release of Tethys Platform. Migrate now! - - -.. tip:: - - These instructions assume your previous installation was done using the install script with the default configuration. If you used any custom options when installing the environment initially, you will need to specify those same options. For an explanation of the installation script options, see: :ref:`install_script_options`. - - -Upgrade Manually ----------------- - -1. Get the Latest Version -========================= - -Change into the directory containing your Tethys Platform installation (tethys_home). The default location is ``~/tethys/src``: +1. Activate Tethys environment and start your Tethys Database: :: - $ cd - $ git pull origin |branch| + . t + tstartdb -2. Upgrade or Create New Environment -==================================== - -a. Upgrade Conda Dependencies - - Install new dependencies and upgrade old ones: - - :: - - $ . t - (tethys) $ conda env update -f environment_py.yml - (tethys) $ python setup.py develop - - **OR** - -b. Create new Conda Environment - - Remove the old environment: - - :: - - $ .t && conda activate base - (base) $ conda env remove -n tethys - - Create new Python 3 environment (recommended): - - :: - - (base) $ conda env create -f environment_py3.yml - (base) $ conda activate tethys - (tethys) $ python setup.py develop - - **OR** - - Create new Python 2 environment (discouraged): - - :: - - (base) $ conda env create -f environment_py2.yml - (base) $ conda activate tethys - (tethys) $ python setup.py develop - - .. warning:: - - Python 2 support is officially deprecated in this release. It will no longer be supported in the next release of Tethys Platform. Migrate now! - - -3. Generate New Settings Script -=============================== - -Backup your old settings script (``settings.py``) and generate a new settings file to get the latest version of the settings. Then copy any settings (like database usernames and passwords) from the backed up settings script to the new settings script. +2. Backup your ``settings.py`` (Note: If you do not backup your ``settings.py`` you will be prompted to overwrite your settings file during upgrade): :: - (tethys) $ mv ./tethys_portal/settings.py ./tethys_portal/settings.py_bak - (tethys) $ tethys gen settings -d tethys_portal + mv $TETHYS_HOME/src/tethys_portal/settings.py $TETHYS_HOME/src/tethys_portal/settings_20.py .. caution:: @@ -142,21 +32,29 @@ Backup your old settings script (``settings.py``) and generate a new settings fi * SOCIAL_OAUTH_XXXX_KEY, SOCIAL_OAUTH_XXXX_SECRET * BYPASS_TETHYS_HOME_PAGE -4. Sync the Database -==================== - -Start the database you have been using for your Tethys Portal if it is not already running. Then migrate the Tethys database as follows: +3. (Optional) If you want the new environment to be called ``tethys`` remove the old environment: :: - (tethys) $ tethys manage syncdb + conda activate base + conda env remove -n tethys -5. Create Shortcuts -=================== +.. tip:: -Use the new install script to create shortcuts for your new environment: + If these commands don't work, you may need to update your conda installation: -:: + :: + + conda update conda -n root -c defaults - (tethys) $ cd scripts - (tethys) $ install_tethys.sh --partial-tethys-install at \ No newline at end of file +4. Download and execute the new install tethys script with the following options (Note: if you removed your previous tethys environment, then you can omit the ``-n tethys21`` option to have the new environment called ``tethys``): + +.. parsed-literal:: + + wget :install_tethys:`sh` + bash install_tethys.sh -b |branch| --partial-tethys-install cieast -n tethys21 + + +.. tip:: + + These instructions assume your previous installation was done using the install script with the default configuration. If you used any custom options when installing the environment initially, you will need to specify those same options. For an explanation of the installation script options, see: :ref:`install_script_options`. diff --git a/docs/tethys_portal/social_auth.rst b/docs/tethys_portal/social_auth.rst index ea9776aba..5220ed1a4 100644 --- a/docs/tethys_portal/social_auth.rst +++ b/docs/tethys_portal/social_auth.rst @@ -340,7 +340,7 @@ For more detailed information about using HydroShare social authentication see t Social Auth Settings ==================== -Social authentication requires Tethys Platform 1.2.0 or later. If you are using an older version of Tethys Platform, you will need to upgrade by following either the :doc:`../installation/update` or the :doc:`../installation/production/update` instructions. The ``settings.py`` script is unaffected by the upgrade. You will need to either generate a new ``settings.py`` script using ``tethys gen settings`` or add the following settings to your existing ``settings.py`` script to support social login. +Social authentication requires Tethys Platform 1.2.0 or later. If you are using an older version of Tethys Platform, you will need to upgrade by following either the :doc:`../installation/update` instructions. The ``settings.py`` script is unaffected by the upgrade. You will need to either generate a new ``settings.py`` script using ``tethys gen settings`` or add the following settings to your existing ``settings.py`` script to support social login. :: diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 4fe76b551..5cf580868 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -70,9 +70,10 @@ Partial Install Options * The Tethys Platform installation scripts now allow for partial installation. * Install in existing Conda environment or against existing database. +* Upgrade using the install script! * Linux and Mac only. -See: :doc:`./installation/linux_and_mac` +See: :doc:`./installation/linux_and_mac` and :doc:`./installation/update` Commandline Interface --------------------- From 43cba708a8c20747747f817473eaece4e04f7361 Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 19 Dec 2018 10:03:59 -0700 Subject: [PATCH 207/215] Added step 5 to upgrade docs. --- docs/installation/update.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/installation/update.rst b/docs/installation/update.rst index 61a8ab354..35afa6baa 100644 --- a/docs/installation/update.rst +++ b/docs/installation/update.rst @@ -54,7 +54,26 @@ This document provides a recommendation for how to upgrade Tethys Platform from wget :install_tethys:`sh` bash install_tethys.sh -b |branch| --partial-tethys-install cieast -n tethys21 +5. (Optional) If you have a locally installed database server then you need to downgrade postgresql to the version that the database was created with. If it was created by the 2.0 Tethys install script then it was created with postgresql version 9.5. (Note: be sure to open a new terminal so that the newly created tethys environment is activated): + +:: + + t + conda install -c conda-forge postgresql=9.5 + .. tip:: These instructions assume your previous installation was done using the install script with the default configuration. If you used any custom options when installing the environment initially, you will need to specify those same options. For an explanation of the installation script options, see: :ref:`install_script_options`. + + + + + + + + + + + + From cb94ee4c2e8bb8adc0d513cf1f6beb07c0eb0e11 Mon Sep 17 00:00:00 2001 From: nswain Date: Fri, 21 Dec 2018 14:30:31 -0700 Subject: [PATCH 208/215] Remove hardcoded src directory references. --- docs/conf.py | 2 +- docs/installation/linux_and_mac.rst | 4 +- docs/installation/windows.rst | 2 + scripts/install_tethys.bat | 18 +++++++-- scripts/install_tethys.sh | 45 +++++++++++++-------- tethys_apps/cli/gen_commands.py | 8 +++- tethys_apps/cli/gen_templates/uwsgi_service | 2 +- tethys_apps/cli/manage_commands.py | 7 ++-- tethys_apps/cli/test_command.py | 6 ++- tethys_apps/utilities.py | 22 ++++++++++ 10 files changed, 87 insertions(+), 29 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 3de8a1f52..0c0967bda 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,7 +29,7 @@ # parse the installed apps list from the settings template with open('../tethys_apps/cli/gen_templates/settings', 'r') as settings_file: settings_str = settings_file.read() - match = re.search('INSTALLED_APPS = \(\n(.*?)\)', settings_str, re.DOTALL) + match = re.search(r'INSTALLED_APPS = \(\n(.*?)\)', settings_str, re.DOTALL) installed_apps = [app.strip('\'|,') for app in match.group(1).split()] settings.configure(INSTALLED_APPS=installed_apps) diff --git a/docs/installation/linux_and_mac.rst b/docs/installation/linux_and_mac.rst index ade294004..3cd4d3d0f 100644 --- a/docs/installation/linux_and_mac.rst +++ b/docs/installation/linux_and_mac.rst @@ -43,6 +43,8 @@ Install Script Options * `-t, --tethys-home `: Path for tethys home directory. Default is ~/tethys. + * `-s, --tethys-src `: + Path to the tethys source directory. Default is ${TETHYS_HOME}/src. * `-a, --allowed-host `: Hostname or IP address on which to serve Tethys. Default is 127.0.0.1. * `-p, --port `: @@ -94,7 +96,7 @@ Install Script Options Flags: * `m` - Install Miniconda - * `r` - Clone Tethys repository + * `r` - Clone Tethys repository (the `--tethys-src` option is required if you omit this flag). * `c` - Checkout the branch specified by the option `--branch` (specifying the flag `r` will also trigger this flag) * `e` - Create Conda environment * `s` - Create `settings.py` file diff --git a/docs/installation/windows.rst b/docs/installation/windows.rst index 9bf5a7337..c4f3be48f 100644 --- a/docs/installation/windows.rst +++ b/docs/installation/windows.rst @@ -35,6 +35,8 @@ As long as the :file:`install_tethys.bat` and the :file:`Miniconda3-latest-Windo * `-t, --tethys-home `: Path for tethys home directory. Default is C:\%HOMEPATH%\tethys. + * `-s, --tethys-src `: + Path for tethys source directory. Default is %TETHYS_HOME%\src. * `-a, --allowed-host `: Hostname or IP address on which to serve Tethys. Default is 127.0.0.1. * `-p, --port `: diff --git a/scripts/install_tethys.bat b/scripts/install_tethys.bat index 156cb0bd1..3748b788b 100644 --- a/scripts/install_tethys.bat +++ b/scripts/install_tethys.bat @@ -6,6 +6,7 @@ IF %ERRORLEVEL% NEQ 0 (SET ERRORLEVEL=0) :: Set defaults SET ALLOWED_HOST=127.0.0.1 SET TETHYS_HOME=C:%HOMEPATH%\tethys +SET TETHYS_SRC=%TETHYS_HOME%\src SET TETHYS_PORT=8000 SET TETHYS_DB_USERNAME=tethys_default SET TETHYS_DB_PASSWORD=pass @@ -34,6 +35,16 @@ IF NOT "%1"=="" ( SHIFT SET OPTION_RECOGNIZED=TRUE ) + IF "%1"=="-s" ( + SET TETHYS_SRC=%2 + SHIFT + SET OPTION_RECOGNIZED=TRUE + ) + IF "%1"=="--tethys-src" ( + SET TETHYS_SRC=%2 + SHIFT + SET OPTION_RECOGNIZED=TRUE + ) IF "%1"=="-a" ( SET ALLOWED_HOST=%2 SHIFT @@ -242,8 +253,8 @@ IF %ERRORLEVEL% NEQ 0 ( :: clone Tethys repo ECHO Cloning the Tethys Platform repo... conda install --yes git -git clone https://github.com/tethysplatform/tethys "!TETHYS_HOME!\src" -CD "!TETHYS_HOME!\src" +git clone https://github.com/tethysplatform/tethys "!TETHYS_SRC!" +CD "!TETHYS_SRC!" git checkout !BRANCH! IF %ERRORLEVEL% NEQ 0 ( @@ -343,7 +354,8 @@ EXIT /B %ERRORLEVEL% ECHO USAGE: install_tethys.bat [options] ECHO. ECHO OPTIONS: -ECHO -t, --tethys-home [PATH] Path for tethys home directory. Default is 'C:\%HOMEPATH%\tehtys'. +ECHO -t, --tethys-home [PATH] Path for tethys home directory. Default is 'C:\%HOMEPATH%\tethys'. +ECHO -s, --tethys-src [PATH] Path for tethys source directory. Default is %%TETHYS_HOME%%\src. ECHO -a, --allowed-host [HOST] Hostname or IP address on which to serve tethys. Default is 127.0.0.1. ECHO -p, --port [PORT] Port on which to serve tethys. Default is 8000. ECHO -b, --branch [BRANCH_NAME] Branch to checkout from version control. Default is 'release'. diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index ed503a78b..2d6d1cad1 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -9,6 +9,7 @@ USAGE="USAGE: . install_tethys.sh [options]\n \n OPTIONS:\n \t -t, --tethys-home \t\t Path for tethys home directory. Default is ~/tethys.\n +\t -s, --tethys-src \t\t Path for tethys source directory. Default is ~/tethys/src.\n \t -a, --allowed-host \t\t Hostname or IP address on which to serve tethys. Default is 127.0.0.1.\n \t -p, --port \t\t\t Port on which to serve tethys. Default is 8000.\n \t -b, --branch \t\t Branch to checkout from version control. Default is 'release'.\n @@ -133,6 +134,10 @@ case $key in set_option_value TETHYS_HOME "$2" shift # past argument ;; + -s|--tethys-src) + set_option_value TETHYS_SRC "$2" + shift # past argument + ;; -a|--allowed-host) set_option_value ALLOWED_HOST "$2" shift # past argument @@ -294,6 +299,14 @@ else resolve_relative_path CONDA_HOME ${CONDA_HOME} fi +# set TETHYS_SRC relative to TETHYS_HOME if not already set +if [ -z "${TETHYS_SRC}" ] +then + TETHYS_SRC="${TETHYS_HOME}/src" +else + resolve_relative_path TETHYS_SRC ${TETHYS_SRC} +fi + # set TETHYS_DB_DIR relative to TETHYS_HOME if not already set if [ -z "${TETHYS_DB_DIR}" ] then @@ -346,12 +359,12 @@ then echo "Cloning the Tethys Platform repo..." conda activate conda install --yes git - git clone https://github.com/tethysplatform/tethys.git "${TETHYS_HOME}/src" + git clone https://github.com/tethysplatform/tethys.git "${TETHYS_SRC}" fi if [ -n "${CHECKOUT_BRANCH}" ] || [ -n "${CLONE_REPO}" ] then - cd "${TETHYS_HOME}/src" + cd "${TETHYS_SRC}" conda activate git checkout ${BRANCH} fi @@ -364,9 +377,9 @@ then then echo "${YELLOW}WARNING: Support for Python 2 is deprecated and will be removed in Tethys version 3.${RESET_COLOR}" fi - conda env create -n ${CONDA_ENV_NAME} -f "${TETHYS_HOME}/src/environment_py${PYTHON_VERSION}.yml" + conda env create -n ${CONDA_ENV_NAME} -f "${TETHYS_SRC}/environment_py${PYTHON_VERSION}.yml" conda activate ${CONDA_ENV_NAME} - python "${TETHYS_HOME}/src/setup.py" develop + python "${TETHYS_SRC}/setup.py" develop else echo "Activating the ${CONDA_ENV_NAME} environment..." conda activate ${CONDA_ENV_NAME} @@ -405,7 +418,7 @@ then if [ -n "${CREATE_TETHYS_SUPER_USER}" ] || [ -n "${SETUP_DB}" ] then - echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python "${TETHYS_HOME}/src/manage.py" shell + echo "from django.contrib.auth.models import User; User.objects.create_superuser('${TETHYS_SUPER_USER}', '${TETHYS_SUPER_USER_EMAIL}', '${TETHYS_SUPER_USER_PASS}')" | python "${TETHYS_SRC}/manage.py" shell fi if [ -n "${SETUP_DB}" ] @@ -500,17 +513,17 @@ centos_production_install() { configure_selinux() { sudo yum install setroubleshoot -y - sudo semanage fcontext -a -t httpd_config_t ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf - sudo restorecon -v ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf + sudo semanage fcontext -a -t httpd_config_t ${TETHYS_SRC}/tethys_portal/tethys_nginx.conf + sudo restorecon -v ${TETHYS_SRC}/tethys_portal/tethys_nginx.conf sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}(/.*)?" sudo semanage fcontext -a -t httpd_sys_content_t "${TETHYS_HOME}/static(/.*)?" sudo semanage fcontext -a -t httpd_sys_rw_content_t "${TETHYS_HOME}/workspaces(/.*)?" sudo restorecon -R -v ${TETHYS_HOME} > /dev/null - echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te + echo $'module tethys-selinux-policy 1.0;\nrequire {type httpd_t; type init_t; class unix_stream_socket connectto; }\n#============= httpd_t ==============\nallow httpd_t init_t:unix_stream_socket connectto;' > ${TETHYS_SRC}/tethys_portal/tethys-selinux-policy.te - checkmodule -M -m -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.te - semodule_package -o ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp -m ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.mod - sudo semodule -i ${TETHYS_HOME}/src/tethys_portal/tethys-selinux-policy.pp + checkmodule -M -m -o ${TETHYS_SRC}/tethys_portal/tethys-selinux-policy.mod ${TETHYS_SRC}/tethys_portal/tethys-selinux-policy.te + semodule_package -o ${TETHYS_SRC}/tethys_portal/tethys-selinux-policy.pp -m ${TETHYS_SRC}/tethys_portal/tethys-selinux-policy.mod + sudo semodule -i ${TETHYS_SRC}/tethys_portal/tethys-selinux-policy.pp } if [ -n "${LINUX_DISTRIBUTION}" -a "${PRODUCTION}" = "true" ] @@ -558,15 +571,15 @@ then sudo chmod 705 ~ sudo mkdir /var/log/uwsgi sudo touch /var/log/uwsgi/tethys.log - sudo ln -s ${TETHYS_HOME}/src/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ + sudo ln -s ${TETHYS_SRC}/tethys_portal/tethys_nginx.conf /etc/nginx/${NGINX_SITES_DIR}/ if [ -n "${SELINUX}" ] then configure_selinux fi - sudo chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_HOME}/src /var/log/uwsgi/tethys.log - sudo systemctl enable ${TETHYS_HOME}/src/tethys_portal/tethys.uwsgi.service + sudo chown -R ${NGINX_USER}:${NGINX_GROUP} ${TETHYS_SRC} /var/log/uwsgi/tethys.log + sudo systemctl enable ${TETHYS_SRC}/tethys_portal/tethys.uwsgi.service sudo systemctl start tethys.uwsgi.service sudo systemctl restart nginx set +x @@ -574,9 +587,9 @@ then echo "export NGINX_USER='${NGINX_USER}'" >> "${ACTIVATE_SCRIPT}" echo "export NGINX_HOME='${NGINX_HOME}'" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_user_own='sudo chown -R \${USER} \"\${TETHYS_SRC}\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" echo "alias tuo=tethys_user_own" >> "${ACTIVATE_SCRIPT}" - echo "alias tethys_server_own='sudo chown -R \${NGINX_USER}:\${NGINX_USER} \"\${TETHYS_HOME}/src\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" + echo "alias tethys_server_own='sudo chown -R \${NGINX_USER}:\${NGINX_USER} \"\${TETHYS_SRC}\" \"\${TETHYS_HOME}/static\" \"\${TETHYS_HOME}/workspaces\" \"\${TETHYS_HOME}/apps\"'" >> "${ACTIVATE_SCRIPT}" echo "alias tso=tethys_server_own" >> "${ACTIVATE_SCRIPT}" echo "alias tethys_server_restart='tso; sudo systemctl restart tethys.uwsgi.service; sudo systemctl restart nginx'" >> "${ACTIVATE_SCRIPT}" echo "alias tsr=tethys_server_restart" >> "${ACTIVATE_SCRIPT}" diff --git a/tethys_apps/cli/gen_commands.py b/tethys_apps/cli/gen_commands.py index 66bcc52ab..67f761be1 100644 --- a/tethys_apps/cli/gen_commands.py +++ b/tethys_apps/cli/gen_commands.py @@ -12,7 +12,7 @@ import os import string import random -from .manage_commands import TETHYS_HOME +from tethys_apps.utilities import get_tethys_home_dir, get_tethys_src_dir from platform import linux_distribution from django.template import Template, Context @@ -79,6 +79,10 @@ def generate_command(args): """ Generate a settings file for a new installation. """ + # Consts + TETHYS_HOME = get_tethys_home_dir() + TETHYS_SRC = get_tethys_src_dir() + # Setup variables context = Context() @@ -156,7 +160,7 @@ def generate_command(args): context.update({'nginx_user': nginx_user, 'conda_home': conda_home, 'conda_env_name': conda_env_name, - 'tethys_home': TETHYS_HOME, + 'tethys_src': TETHYS_SRC, 'user_option_prefix': user_option_prefix }) diff --git a/tethys_apps/cli/gen_templates/uwsgi_service b/tethys_apps/cli/gen_templates/uwsgi_service index e97801170..54b0e00ff 100644 --- a/tethys_apps/cli/gen_templates/uwsgi_service +++ b/tethys_apps/cli/gen_templates/uwsgi_service @@ -4,7 +4,7 @@ After=syslog.target [Service] ExecStartPre=/bin/bash -c 'mkdir -p /run/uwsgi; chown {{ nginx_user }}:{{ nginx_user }} /run/uwsgi' -ExecStart={{ conda_home }}/envs/{{ conda_env_name }}/bin/uwsgi --yaml {{ tethys_home }}/src/tethys_portal/tethys_uwsgi.yml --{{ user_option_prefix }}uid {{ nginx_user }} --{{ user_option_prefix }}gid {{ nginx_user }} +ExecStart={{ conda_home }}/envs/{{ conda_env_name }}/bin/uwsgi --yaml {{ tethys_src }}/tethys_portal/tethys_uwsgi.yml --{{ user_option_prefix }}uid {{ nginx_user }} --{{ user_option_prefix }}gid {{ nginx_user }} ExecStartPost=/bin/bash -c 'chown -R {{ nginx_user }}:{{ nginx_user }} /run/uwsgi' Restart=always KillSignal=SIGQUIT diff --git a/tethys_apps/cli/manage_commands.py b/tethys_apps/cli/manage_commands.py index 42c76add2..4d542f31c 100644 --- a/tethys_apps/cli/manage_commands.py +++ b/tethys_apps/cli/manage_commands.py @@ -13,10 +13,9 @@ from tethys_apps.cli.cli_colors import pretty_output, FG_RED from tethys_apps.base.testing.environment import set_testing_environment +from tethys_apps.utilities import get_tethys_src_dir + -CURRENT_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -TETHYS_HOME = os.sep.join(CURRENT_SCRIPT_DIR.split(os.sep)[:-3]) -TETHYS_SRC_DIRECTORY = os.sep.join(CURRENT_SCRIPT_DIR.split(os.sep)[:-2]) MANAGE_START = 'start' MANAGE_SYNCDB = 'syncdb' MANAGE_COLLECTSTATIC = 'collectstatic' @@ -31,7 +30,7 @@ def get_manage_path(args): Validate user defined manage path, use default, or throw error """ # Determine path to manage.py file - manage_path = os.path.join(TETHYS_SRC_DIRECTORY, 'manage.py') + manage_path = os.path.join(get_tethys_src_dir(), 'manage.py') # Check for path option if hasattr(args, 'manage'): diff --git a/tethys_apps/cli/test_command.py b/tethys_apps/cli/test_command.py index 1f4f2fb04..32f03ffce 100644 --- a/tethys_apps/cli/test_command.py +++ b/tethys_apps/cli/test_command.py @@ -1,7 +1,11 @@ import os import webbrowser -from tethys_apps.cli.manage_commands import get_manage_path, TETHYS_SRC_DIRECTORY, run_process +from tethys_apps.cli.manage_commands import get_manage_path, run_process +from tethys_apps.utilities import get_tethys_src_dir + + +TETHYS_SRC_DIRECTORY = get_tethys_src_dir() def test_command(args): diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index a6ce1f509..0d9138bfe 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -19,6 +19,28 @@ tethys_log = logging.getLogger('tethys.' + __name__) +def get_tethys_src_dir(): + """ + Get/derive the TETHYS_SRC variable. + + Returns: + str: path to TETHYS_SRC. + """ + default = os.path.dirname(os.path.dirname(__file__)) + return os.environ.get('TETHYS_SRC', default) + + +def get_tethys_home_dir(): + """ + Get/derive the TETHYS_HOME variable. + + Returns: + str: path to TETHYS_HOME. + """ + default = os.path.dirname(get_tethys_src_dir()) + return os.environ.get('TETHYS_HOME', default) + + def get_directories_in_tethys(directory_names, with_app_name=False): """ # Locate given directories in tethys apps and extensions. From 5439b870af1a862f601d78b5882dff76f83fba8e Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Thu, 27 Dec 2018 15:56:26 -0600 Subject: [PATCH 209/215] Update jobs api docs --- docs/tethys_sdk/jobs.rst | 159 ++++++------- docs/tethys_sdk/jobs/basic_job_type.rst | 45 ++-- .../jobs/condor_job_description.rst | 2 + docs/tethys_sdk/jobs/condor_job_type.rst | 82 ++++--- docs/tethys_sdk/jobs/condor_workflow_type.rst | 218 +++++++----------- tethys_compute/job_manager.py | 27 +-- tethys_compute/models.py | 21 ++ tethys_sdk/jobs.py | 13 +- 8 files changed, 256 insertions(+), 311 deletions(-) diff --git a/docs/tethys_sdk/jobs.rst b/docs/tethys_sdk/jobs.rst index 36a4bbdf2..a08da0b77 100644 --- a/docs/tethys_sdk/jobs.rst +++ b/docs/tethys_sdk/jobs.rst @@ -2,75 +2,102 @@ Jobs API ******** -**Last Updated:** September 12, 2016 +**Last Updated:** December 27, 2018 The Jobs API provides a way for your app to run asynchronous tasks (meaning that after starting a task you don't have to wait for it to finish before moving on). As an example, you may need to run a model that takes a long time (potentially hours or days) to complete. Using the Jobs API you can create a job that will run the model, and then leave it to run while your app moves on and does other stuff. You can check the job's status at any time, and when the job is done the Jobs API will help retrieve the results. Key Concepts ============ -To facilitate interacting with jobs asynchronously, they are stored in a database. The Jobs API provides a job manager to handle the details of working with the database, and provides a simple interface for creating and retrieving jobs. The first step to creating a job is to define a job template. A job template is like a blue print that describes certain key characteristics about the job, such as the job type and where the job will be run. The job manager uses a job template to create a new job that has all of the attributes that were defined in the template. Once a job has been created from a template it can then be customized with any additional attributes that are needed for that specific job. +To facilitate interacting with jobs asynchronously, the details of the jobs are stored in a database. The Jobs API provides a job manager to handle the details of working with the database, and provides a simple interface for creating and retrieving jobs. The Jobs API supports various types of jobs (see `Job Types`_). +.. deprecated::2.1 + Creating jobs used to be done via job templates. This method is now deprecated. -The Jobs API supports various types of jobs (see `Job Types`_). +.. _job-manager-label: -.. note:: - The real power of the jobs API comes when it is combined with the :doc:`compute`. This make it possible for jobs to be offloaded from the main web server to a scalable computing cluster, which in turn enables very large scale jobs to be processed. This is done through the :doc:`jobs/condor_job_type` or the :doc:`jobs/condor_workflow_type`. +Job Manager +=========== +The Job Manager is used in your app to interact with the jobs database. It facilitates creating and querying jobs. -.. seealso:: - The Condor Job and the Condor Workflow job types use the CondorPy library to submit jobs to HTCondor compute pools. For more information on CondorPy and HTCondor see the `CondorPy documentation `_ and specifically the `Overview of HTCondor `_. +Using the Job Manager in your App +--------------------------------- +To use the Job Manager in your app you first need to import the TethysAppBase subclass from the app.py module: + +:: -Defining Job Templates -====================== -To create jobs in an app you first need to define job templates. A job template specifies the type of job, and also defines all of the static attributes of the job that will be the same for all instances of that template. These attributes often include the names of the executable, input files, and output files. Job templates are defined in a method on the ``TethysAppBase`` subclass in ``app.py`` module. The following code sample shows how this is done: + from app import MyFirstApp as app + +You can then get the job manager by calling the method ``get_job_manager`` on the app. :: - from tethys_sdk.jobs import CondorJobTemplate, CondorJobDescription - from tethys_sdk.compute import list_schedulers + job_manager = app.get_job_manager() + +You can now use the job manager to create a new job, or retrieve an existing job or jobs. + +Creating and Executing a Job +---------------------------- +To create a new job call the ``create_job`` method on the job manager. The required arguments are: + + * ``name``: A unique string identifying the job + * ``user``: A user object, usually from the request argument: `request.user` + * ``job_type``: A string specifying on of the supported job types (see `Job Types`_) + +Any other job attributes can also be passed in as `kwargs`. - def job_templates(cls): - """ - Example job_templates method. - """ - my_scheduler = list_schedulers()[0] +:: - my_job_description = CondorJobDescription(condorpy_template_name='vanilla_transfer_files', - remote_input_files=('$(APP_WORKSPACE)/my_script.py', '$(APP_WORKSPACE)/input_1', '$(USER_WORKSPACE)/input_2'), - executable='my_script.py', - transfer_input_files=('../input_1', '../input_2'), - transfer_output_files=('example_output1', 'example_output2'), - ) + # get the path to the app workspace to reference job files + app_workspace = app.get_app_workspace().path - job_templates = (CondorJobTemplate(name='example', - job_description=my_job_description, - scheduler=my_scheduler, - ), - ) + # create a new job from the job manager + job = job_manager.create_job( + name='myjob_{id}', # required + user=request.user, # required + job_type='CONDOR', # required - return job_templates + # any other properties can be passed in as kwargs + attributes=dict(attribute1='attr1'), + condorpy_template_name='vanilla_transfer_files', + remote_input_files=( + os.path.join(app_workspace, 'my_script.py'), + os.path.join(app_workspace, 'input_1'), + os.path.join(app_workspace, 'input_2') + ) + ) -.. note:: - To define job templates the appropriate template class and any supporting classes must be imported from ``tethys_sdk.jobs``. In this case the template class `CondorJobTemplate` is imported along with the supporting class `CondorJobDescription`. + # properties can also be added after the job is created + job.extended_properties = {'one': 1, 'two': 2} -There is a corresponding job template class for every job type. In this example the `CondorJobTemplate` class is used, which corresponds to the :doc:`jobs/condor_job_type`. For a list of all possible job types see `Job Types`_. + # each job type may provided methods to further specify the job + job.set_attribute('executable', 'my_script.py') -When instantiating any job template class there is a required ``name`` parameter, which is used used to identify the template to the job manager (see `Using the Job Manager in your App`_). The template class for each job type may have additional required and/or optional parameters. In the above example the `job_description` and the `scheduler` parameters are required because the the `CondorJobTemplate` class is being instantiated. Job template classes may also support setting job attributes as parameters in the template. See the `Job Types`_ documentation for a list of acceptable parameters for the template class of each job type. + # save or execute the job + job.save() + # or + job.execute() + +Before a controller returns a response the job must be saved or else all of the changes made to the job will be lost (executing the job automatically saves it). If submitting the job takes a long time (e.g. if a large amount of data has to be uploaded to a remote scheduler) then it may be best to use AJAX to execute the job. -.. warning:: - The generic template class ``JobTemplate`` allong with the dictionary ``JOB_TYPES`` have been used to define job templates in the past but are being deprecated in favor of job-type specific templates classes (e.g. `CondorJobTemplate` or `CondorWorkflowTemplate`). +.. tip:: + The `Jobs Table Gizmo`_ has a built-in mechanism for submitting jobs with AJAX. If the `Jobs Table Gizmo`_ is used to submit the jobs then be sure to save the job after it is created. Job Types --------- -The Jobs API is designed to support multiple job types. Each job type provides a different framework and environment for executing jobs. To create a job of a particular job type, you must first create a job template from the template class corresponding to that job type (see `Defining Job Templates`_). After the job template for the job type you want has been instantiated you can create a new job instance using the job manager (see `Using the Job Manager in your App`_). +The Jobs API is designed to support multiple job types. Each job type provides a different framework and environment for executing jobs. When creating a new job you must specify its type by passing in the `job_type` argument. Currently the supported job types are: + + * 'BASIC' + * 'CONDOR' or 'CONDORJOB' + * 'CONDORWORKFLOW' -Once you have a newly created job from the job manager you can then customize the job by setting job attributes. All jobs have a common set of attributes, and then each job type may add additional attributes. +Additional job attributes can be passed into the `create_job` method of the job manager or they can be specified after the job is instantiated. All jobs have a common set of attributes, and then each job type may add additional attributes. The following attributes can be defined for all job types: * ``name`` (string, required): a unique identifier for the job. This should not be confused with the job template name. The template name identifies a template from which jobs can be created and is set when the template is created. The job ``name`` attribute is defined when the job is created (see `Creating and Executing a Job`_). * ``description`` (string): a short description of the job. - * ``workspace`` (string): a path to a directory that will act as the workspace for the job. Each job type may interact with the workspace differently. By default the workspace is set to the user's workspace in the app that is creating the job (see `Workspaces`_). + * ``workspace`` (string): a path to a directory that will act as the workspace for the job. Each job type may interact with the workspace differently. By default the workspace is set to the user's workspace in the app that is creating the job. * ``extended_properties`` (dict): a dictionary of additional properties that can be used to create custom job attributes. @@ -95,9 +122,6 @@ All job types also have the following read-only attributes: \*used for job types with multiple sub-jobs (e.g. CondorWorkflow). -.. note:: - Job template classes may support passing in job attributes as additional arguments. See the documentation for each job type for a list of acceptable parameters for each template class add if additional arguments are supported. - Specific job types may define additional attributes. The following job types are available. .. toctree:: @@ -108,55 +132,6 @@ Specific job types may define additional attributes. The following job types are jobs/condor_workflow_type - -Workspaces ----------- -Each job has it's own workspace, which by default is set to the user's workspace in the app where the job is created. However, the job may need to reference files that are in other workspaces. To make it easier to interact with workspaces in job templates, two special variables are defined: ``$(APP_WORKSPACE)`` and ``$(USER_WORKSPACE)``. These two variables are resolved to absolute paths when the job is created. These variables can only be used in job templates. To access the app's workspace and the user's workspace when working with a job in other places in your app use the :doc:`workspaces`. - -.. _job-manager-label: - -Job Manager -=========== -The Job Manager is used in your app to interact with the jobs database. It facilitates creating and querying jobs. - -Using the Job Manager in your App -================================= -To use the Job Manager in your app you first need to import the TethysAppBase subclass from the app.py module: - -:: - - from app import MyFirstApp as app - -You can then get the job manager by calling the method ``get_job_manager`` on the app. - -:: - - job_manager = app.get_job_manager() - -You can now use the job manager to create a new job, or retrieve an existing job or jobs. - -Creating and Executing a Job ----------------------------- -To create a job call the ``create_job`` method on the job manager. The required parameters are ``name``, ``user`` and ``template_name``. Any other job attributes can also be passed in as `kwargs`. - -:: - - # create a new job - job = job_manager.create_job(name='job_name', user=request.user, template_name='example', description='my first job') - - # customize the job using methods provided by the job type - job.set_attribute('arguments', 'input_2') - - # save or execute the job - job.save() - # or - job.execute() - -Before a controller returns a response the job must be saved or else all of the changes made to the job will be lost (executing the job automatically saves it). If submitting the job takes a long time (e.g. if a large amount of data has to be uploaded to a remote scheduler) then it may be best to use AJAX to execute the job. - -.. tip:: - The `Jobs Table Gizmo`_ has a built-in mechanism for submitting jobs with AJAX. If the `Jobs Table Gizmo`_ is used to submit the jobs then be sure to save the job after it is created. - Retrieving Jobs --------------- Two methods are provided to retrieve jobs: ``list_jobs`` and ``get_job``. Jobs are automatically filtered by app. An optional ``user`` parameter can be passed in to these methods to further filter jobs by the user. @@ -232,7 +207,7 @@ API Documentation .. autoclass:: tethys_compute.job_manager.JobManager :members: create_job, list_jobs, get_job, get_job_status_callback_url -.. autoclass:: tethys_sdk.jobs.JobTemplate +.. autoclass:: tethys_compute.models.TethysJob References ========== @@ -240,4 +215,4 @@ References .. toctree:: :maxdepth: 1 - jobs/condor_job_description \ No newline at end of file + jobs/condor_job_description diff --git a/docs/tethys_sdk/jobs/basic_job_type.rst b/docs/tethys_sdk/jobs/basic_job_type.rst index 5016dcbef..9d4dcee1b 100644 --- a/docs/tethys_sdk/jobs/basic_job_type.rst +++ b/docs/tethys_sdk/jobs/basic_job_type.rst @@ -2,45 +2,32 @@ Basic Job Type ************** -**Last Updated:** March 29, 2016 +**Last Updated:** December 27, 2018 -The Basic Job type is a sample job type for creating dummy jobs. It has all of the basic properties and methods of a job, but it doesn't have any mechanism for running jobs. It's primary purpose is for demonstration. There are no additional attributes for the BasicJob type other than the common set of job attributes. The only required parameter for the `BasicJobTemplate` class is ``name``, but it also supports passing in other job attributes as additional arguments. +The Basic Job type is a sample job type for creating dummy jobs. It has all of the basic properties and methods of a job, but it doesn't have any mechanism for running jobs. It's primary purpose is for demonstration. There are no additional attributes for the BasicJob type other than the common set of job attributes. -Setting up a BasicJobTemplate -============================= -:: - - from tethys_sdk.jobs import BasicJobTemplate - - def job_templates(cls): - """ - Example job_templates method with a BasicJob type. - """ - - job_templates = (BasicJobTemplate(name='example', - description='This is a sample basic job. It can't actually compute anything.', - extended_properties={'app_spcific_property': 'default_value', - 'persistent_store_id': None, # Will be defined when job is created - } - ), - ) - - return job_templates - -Creating and Customizing a Job -============================== -To create a job call the ``create_job`` method on the job manager. The required parameters are ``name``, ``user`` and ``template_name``. Any other job attributes can also be passed in as `kwargs`. +Creating a Basic Job +==================== +To create a job call the ``create_job`` method on the job manager. The required parameters are ``name``, ``user`` and ``job_type``. Any other job attributes can also be passed in as `kwargs`. :: # create a new job - job = job_manager.create_job(name='unique_job_name', user=request.user, template_name='example', description='my first job') + job = job_manager.create_job( + name='unique_job_name', + user=request.user, + template_name='BASIC', + description='This is a sample basic job. It can't actually compute anything.', + extended_properties={ + 'app_spcific_property': 'default_value', + } + ) Before a controller returns a response the job must be saved or else all of the changes made to the job will be lost (executing the job automatically saves it). If submitting the job takes a long time (e.g. if a large amount of data has to be uploaded to a remote scheduler) then it may be best to use AJAX to execute the job. API Documentation ================= -.. autoclass:: tethys_sdk.jobs.BasicJobTemplate +.. autoclass:: tethys_compute.models.BasicJob -.. autoclass:: tethys_compute.models.BasicJob \ No newline at end of file +.. autoclass:: tethys_sdk.jobs.BasicJobTemplate \ No newline at end of file diff --git a/docs/tethys_sdk/jobs/condor_job_description.rst b/docs/tethys_sdk/jobs/condor_job_description.rst index 602cfd05d..7f852dc22 100644 --- a/docs/tethys_sdk/jobs/condor_job_description.rst +++ b/docs/tethys_sdk/jobs/condor_job_description.rst @@ -4,6 +4,8 @@ Condor Job Description **Last Updated:** March 29, 2016 +**DEPRECATED** + Both the :doc:`./condor_job_type` or the :doc:`./condor_workflow_type` facilitate running jobs with HTCondor using the CondorPy library, and both use ``CondorJobDescription`` objects which stores attributes used to initialize the CondorPy job. The ``CondorJobDescription`` accepts as parameters any HTCondor job attributes. .. note:: diff --git a/docs/tethys_sdk/jobs/condor_job_type.rst b/docs/tethys_sdk/jobs/condor_job_type.rst index 42df3f530..d94e87c9c 100644 --- a/docs/tethys_sdk/jobs/condor_job_type.rst +++ b/docs/tethys_sdk/jobs/condor_job_type.rst @@ -2,60 +2,68 @@ Condor Job Type *************** -**Last Updated:** March 29, 2016 +**Last Updated:** December 27, 2018 +The :doc:`condor_job_type` (and :doc:`condor_workflow_type`) enable the real power of the jobs API by combining it with the :doc:`../compute`. This make it possible for jobs to be offloaded from the main web server to a scalable computing cluster, which in turn enables very large scale jobs to be processed. -Setting up a CondorJobTemplate -============================== -:: +.. seealso:: + The Condor Job and the Condor Workflow job types use the CondorPy library to submit jobs to HTCondor compute pools. For more information on CondorPy and HTCondor see the `CondorPy documentation `_ and specifically the `Overview of HTCondor `_. - from tethys_sdk.jobs import CondorJobTemplate, CondorJobDescription - from tethys_sdk.compute import list_schedulers - def job_templates(cls): - """ - Example job_templates method. - """ - my_scheduler = list_schedulers()[0] +Creating a Condor Job +===================== +To create a job call the ``create_job`` method on the job manager. The required parameters are ``name``, ``user`` and ``job_type``. Any other job attributes can also be passed in as `kwargs`. - my_job_description = CondorJobDescription(condorpy_template_name='vanilla_transfer_files', - remote_input_files=('$(APP_WORKSPACE)/my_script.py', '$(APP_WORKSPACE)/input_1', '$(USER_WORKSPACE)/input_2'), - executable='my_script.py', - transfer_input_files=('../input_1', '../input_2'), - transfer_output_files=('example_output1', example_output2), - ) +:: - job_templates = (CondorJobTemplate(name='example', - job_description=my_job_description, - scheduler=my_scheduler, - ), - ) + from tethys_sdk.compute import list_schedulers + from .app import MyApp as app - return job_templates + def some_controller(request): -Creating and Customizing a Job -============================== -To create a job call the ``create_job`` method on the job manager. The required parameters are ``name``, ``user`` and ``template_name``. Any other job attributes can also be passed in as `kwargs`. + # get the path to the app workspace to reference job files + app_workspace = app.get_app_workspace().path -:: + # create a new job from the job manager + job = job_manager.create_job( + name='myjob_{id}', # required + user=request.user, # required + job_type='CONDOR', # required + + # any other properties can be passed in as kwargs + attributes=dict( + transfer_input_files=('../input_1', '../input_2'), + transfer_output_files=('example_output1', example_output2), + ), + condorpy_template_name='vanilla_transfer_files', + remote_input_files=( + os.path.join(app_workspace, 'my_script.py'), + os.path.join(app_workspace, 'input_1'), + os.path.join(app_workspace, 'input_2') + ) + ) + + # properties can also be added after the job is created + job.extended_properties = {'one': 1, 'two': 2} - # create a new job - job = job_manager.create_job(name='job_name', user=request.user, template_name='example', description='my first job') + # each job type may provided methods to further specify the job + job.set_attribute('executable', 'my_script.py') - # customize the job using methods provided by the job type - job.set_attribute('arguments', 'input_2') + # get a scheduler for the job + my_scheduler = list_schedulers()[0] + job.scheduler = my_scheduler - # save or execute the job - job.save() - # or - job.execute() + # save or execute the job + job.save() + # or + job.execute() Before a controller returns a response the job must be saved or else all of the changes made to the job will be lost (executing the job automatically saves it). If submitting the job takes a long time (e.g. if a large amount of data has to be uploaded to a remote scheduler) then it may be best to use AJAX to execute the job. API Documentation ================= -.. autoclass:: tethys_sdk.jobs.CondorJobTemplate +.. autoclass:: tethys_compute.models.CondorJob -.. autoclass:: tethys_compute.models.CondorJob \ No newline at end of file +.. autoclass:: tethys_sdk.jobs.CondorJobTemplate \ No newline at end of file diff --git a/docs/tethys_sdk/jobs/condor_workflow_type.rst b/docs/tethys_sdk/jobs/condor_workflow_type.rst index 806767974..d89a5d789 100644 --- a/docs/tethys_sdk/jobs/condor_workflow_type.rst +++ b/docs/tethys_sdk/jobs/condor_workflow_type.rst @@ -2,155 +2,113 @@ Condor Workflow Job Type ************************ -**Last Updated:** March 29, 2016 +**Last Updated:** December 27, 2018 A Condor Workflow provides a way to run a group of jobs (which can have hierarchical relationships) as a single (Tethys) job. The hierarchical relationships are defined as parent-child relationships. For example, suppose a workflow is defined with three jobs: ``JobA``, ``JobB``, and ``JobC``, which must be run in that order. These jobs would be defined with the following relationships: ``JobA`` is the parent of ``JobB``, and ``JobB`` is the parent of ``JobC``. .. seealso:: The Condor Workflow job type uses the CondorPy library to submit jobs to HTCondor compute pools. For more information on CondorPy and HTCondor see the `CondorPy documentation `_ and specifically the `Overview of HTCondor `_. -Setting up a CondorWorkflowTemplate -=================================== -Creating a `CondorWorkflowTemplate` involves 3 steps: +Creating a Condor Workflow +========================== +Creating a Condor Workflow job involves 3 steps: - 1. Define job descriptions for each of the sub-jobs using `CondorJobDescription` (see :doc:`condor_job_description`). - 2. Create the sub-jobs and define relationships using `CondorWorkflowJobTemplate`. - 3. Create the `CondorWorkflowTemplate`. - -.. note:: - The `CondorWorkflowJobTemplate` is similar to a `CondorJobTemplate` in that it represents a single HTCondor job and requires a `CondorJobDescription` to define the attributes of that job. However, unlike a `CondorJobTemplate` a `CondorWorkflowJobTemplate` cannot be run independently; it can only be part of a `CondorWorkflowTemplate`. Also, note that the `CondorWorkflowJobTemplate` has a `parents` parameter, which is used to define relationships between jobs. - -The following code sample demonstrates how to set up a `CondorWorkflowTemplate`: - -:: - - Example job_templates method with a CondorWorkflow type. - """ - - job_a_description = CondorJobDescription(condorpy_template_name='vanilla_transfer_files', - remote_input_files=('$(APP_WORKSPACE)/my_script.py', '$(APP_WORKSPACE)/input_1', '$(USER_WORKSPACE)/input_2'), - executable='my_script.py', - transfer_input_files=('../input_1', '../input_2'), - transfer_output_files=('example_output1', example_output2), - ) - job_b_description = CondorJobDescription(condorpy_template_name='vanilla_transfer_files', - remote_input_files=('$(APP_WORKSPACE)/my_script.py', '$(APP_WORKSPACE)/input_1', '$(USER_WORKSPACE)/input_2'), - executable='my_script.py', - transfer_input_files=('../input_1', '../input_2'), - transfer_output_files=('example_output1', example_output2), - ) - job_c_description = CondorJobDescription(condorpy_template_name='vanilla_transfer_files', - remote_input_files=('$(APP_WORKSPACE)/my_script.py', '$(APP_WORKSPACE)/input_1', '$(USER_WORKSPACE)/input_2'), - executable='my_script.py', - transfer_input_files=('../input_1', '../input_2'), - transfer_output_files=('example_output1', example_output2), - ) - job_a = CondorWorkflowJobTemplate(name='JobA', - job_description=job_a_description, - ) - job_b = CondorWorkflowJobTemplate(name='JobB', - job_description=job_b_description, - parents=[job_a] - ) - job_c = CondorWorkflowJobTemplate(name='JobC', - job_description=job_c_description, - parents=[job_b] - ) - job_templates = (CondorWorkflowTemplate(name='WorkflowABC', - job_list=[job_a, job_b, job_c], - scheduler=None, - ), - ) - -If the you want to use the same job both as part of a workflow and as a stand alone job then use the same job description in setting up the `CondorJobTemplate` and the `CondorWorkflowJobTemplate`. This process is demonstrated below: + 1. Create an empty Workflow job from the job manager. + 2. Create the jobs that will make up the workflow with `CondorWorkflowJobNode` + 3. Define the relationships among the nodes :: - from tethys_sdk.jobs import CondorJobTemplate, CondorWorkflowTemplate, CondorWorkflowJobTemplate, CondorJobDescription - from tethys_sdk.compute import list_schedulers - - def job_templates(cls): - """ - Example job_templates method with a CondorWorkflow type. - """ - - reusable_job_a_description = CondorJobDescription(condorpy_template_name='vanilla_transfer_files', - remote_input_files=('$(APP_WORKSPACE)/my_script.py', '$(APP_WORKSPACE)/input_1', '$(USER_WORKSPACE)/input_2'), - executable='my_script.py', - transfer_input_files=('../input_1', '../input_2'), - transfer_output_files=('example_output1', example_output2), - ) - job_b1_description = CondorJobDescription(condorpy_template_name='vanilla_transfer_files', - remote_input_files=('$(APP_WORKSPACE)/my_script.py', '$(APP_WORKSPACE)/input_1', '$(USER_WORKSPACE)/input_2'), - executable='my_script.py', - transfer_input_files=('../input_1', '../input_2'), - transfer_output_files=('example_output1', example_output2), - ) - job_b2_description = CondorJobDescription(condorpy_template_name='vanilla_transfer_files', - remote_input_files=('$(APP_WORKSPACE)/my_script.py', '$(APP_WORKSPACE)/input_1', '$(USER_WORKSPACE)/input_2'), - executable='my_script.py', - transfer_input_files=('../input_1', '../input_2'), - transfer_output_files=('example_output1', example_output2), - ) - job_c_description = CondorJobDescription(condorpy_template_name='vanilla_transfer_files', - remote_input_files=('$(APP_WORKSPACE)/my_script.py', '$(APP_WORKSPACE)/input_1', '$(USER_WORKSPACE)/input_2'), - executable='my_script.py', - transfer_input_files=('../input_1', '../input_2'), - transfer_output_files=('example_output1', example_output2), - ) - job_a = CondorWorkflowJobTemplate(name='JobA', - job_description=reusable_job_a_description, - ) - job_b1 = CondorWorkflowJobTemplate(name='JobB1', - job_description=reusable_job_a_description, - parents=[job_a] - ) - job_b2 = CondorWorkflowJobTemplate(name='JobB2', - job_description=reusable_job_a_description, - parents=[job_a] - ) - job_c = CondorWorkflowJobTemplate(name='JobC', - job_description=reusable_job_a_description, - parents=[job_b1, job_b2] - ) - job_templates = (CondorWorkflowTemplate(name='DiamondWorkflow', - job_list=[job_a, job_b1, job_b2, job_c], - scheduler=None, - ), - CondorJobTemplate(name='JobAStandAlone', - job_description=reusable_job_a_description, - scheduler=None, - ), - ) - - -Creating and Customizing a Job -============================== -To create a job call the ``create_job`` method on the job manager. The required parameters are ``name``, ``user`` and ``template_name``. Any other job attributes can also be passed in as `kwargs`. - -:: + from tethys_sdk.jobs import CondorWorkflowJobNode + from .app import MyApp as app + + def some_controller(request): + + # get the path to the app workspace to reference job files + app_workspace = app.get_app_workspace().path + + workflow = job_manager.create_job( + name='MyWorkflowABC', + user=request.user, + job_type='CONDORWORKFLOW', + scheduler=None, + ) + workflow.save() + + job_a = CondorWorkflowJobNode( + name='JobA', + workflow=workflow, + condorpy_template_name='vanilla_transfer_files', + remote_input_files=( + os.path.join(app_workspace, 'my_script.py'), + os.path.join(app_workspace, 'input_1'), + os.path.join(app_workspace, 'input_2') + ), + attributes=dict( + executable='my_script.py', + transfer_input_files=('../input_1', '../input_2'), + transfer_output_files=('example_output1', 'example_output2'), + ) + ) + job_a.save() + + job_b = CondorWorkflowJobNode( + name='JobB', + workflow=workflow, + condorpy_template_name='vanilla_transfer_files', + remote_input_files=( + os.path.join(app_workspace, 'my_script.py'), + os.path.join(app_workspace, 'input_1'), + os.path.join(app_workspace, 'input_2') + ), + attributes=dict( + executable='my_script.py', + transfer_input_files=('../input_1', '../input_2'), + transfer_output_files=('example_output1', 'example_output2'), + ), + ) + job_b.save() + + job_c = CondorWorkflowJobNode( + name='JobC', + workflow=workflow, + condorpy_template_name='vanilla_transfer_files', + remote_input_files=( + os.path.join(app_workspace, 'my_script.py'), + os.path.join(app_workspace, 'input_1'), + os.path.join(app_workspace, 'input_2') + ), + attributes=dict( + executable='my_script.py', + transfer_input_files=('../input_1', '../input_2'), + transfer_output_files=('example_output1', 'example_output2'), + ), + ) + job_c.save() + + job_b.add_parent(job_a) + job_c.add_parent(job_b) + + workflow.save() + # or + workflow.execute() - # create a new job - job = job_manager.create_job(name='job_name', user=request.user, template_name='example', description='my first job') - - # customize the job using methods provided by the job type - job.set_attribute('arguments', 'input_2') +.. note:: - # save or execute the job - job.save() - # or - job.execute() + The `CondorWorkflow` object must be saved before the `CondorWorkflowJobNode` objects can be instantiated, and the `CondorWorkflowJobNode` objects must be saved before you can define the relationships. Before a controller returns a response the job must be saved or else all of the changes made to the job will be lost (executing the job automatically saves it). If submitting the job takes a long time (e.g. if a large amount of data has to be uploaded to a remote scheduler) then it may be best to use AJAX to execute the job. API Documentation ================= -.. autoclass:: tethys_sdk.jobs.CondorWorkflowTemplate +.. autoclass:: tethys_compute.models.CondorWorkflow -.. autoclass:: tethys_sdk.jobs.CondorWorkflowJobTemplate +.. autoclass:: tethys_compute.models.CondorWorkflowNode -.. autoclass:: tethys_compute.models.CondorWorkflow +.. autoclass:: tethys_compute.models.CondorWorkflowJobNode + +.. autoclass:: tethys_sdk.jobs.CondorWorkflowTemplate -.. autoclass:: tethys_compute.models.CondorWorkflowJobNode \ No newline at end of file +.. autoclass:: tethys_sdk.jobs.CondorWorkflowJobTemplate \ No newline at end of file diff --git a/tethys_compute/job_manager.py b/tethys_compute/job_manager.py index 235cd567c..398abd89e 100644 --- a/tethys_compute/job_manager.py +++ b/tethys_compute/job_manager.py @@ -38,7 +38,7 @@ class JobManager(object): A manager for interacting with the Jobs database providing a simple interface creating and retrieving jobs. Note: - Each app creates its own instance of the JobManager. the ``get_job_manager`` method returns the app. + Each app creates its own instance of the JobManager. The ``get_job_manager`` method returns the app. :: @@ -70,9 +70,9 @@ def create_job(self, name, user, template_name=None, job_type=None, **kwargs): A new job object of the type specified by job_type. """ if template_name is not None: - msg = 'The job template "{0}" was used in the "{1}" app. Using job templates is now deprecated. ' \ - 'See docs: <>.'\ - .format(template_name, self.app.package) + msg = 'The job template "{0}" was used in the "{1}" app. Using job templates is now deprecated.'.format( + template_name, self.app.package + ) warnings.warn(msg, DeprecationWarning) print(msg) return self.old_create_job(name, user, template_name, **kwargs) @@ -210,6 +210,7 @@ def replace_in_tuple(tuple_value): class JobTemplate(object): """ + **DEPRECATED** A template from which to create a job. Args: @@ -239,6 +240,7 @@ def create_job(self, app_workspace, user_workspace, **kwargs): class BasicJobTemplate(JobTemplate): """ + **DEPRECATED** A subclass of JobTemplate with the ``type`` argument set to BasicJob. Args: @@ -254,6 +256,7 @@ def process_parameters(self): class CondorJobDescription(object): """ + **DEPRECATED** Helper class for CondorJobTemplate and CondorWorkflowJobTemplates. Stores job attributes. """ def __init__(self, condorpy_template_name=None, remote_input_files=None, **kwargs): @@ -267,6 +270,7 @@ def process_attributes(self, app_workspace, user_workspace): class CondorJobTemplate(JobTemplate): """ + **DEPRECATED** A subclass of the JobTemplate with the ``type`` argument set to CondorJob. Args: @@ -289,6 +293,7 @@ def process_parameters(self): class CondorWorkflowTemplate(JobTemplate): """ + **DEPRECATED** A subclass of the JobTemplate with the ``type`` argument set to CondorWorkflow. Args: @@ -343,6 +348,7 @@ def add_to_node_dict(node): class CondorWorkflowNodeBaseTemplate(object): """ + **DEPRECATED** A template from which to create a job. Args: @@ -380,6 +386,7 @@ def create_node(self, workflow, app_workspace, user_workspace): class CondorWorkflowJobTemplate(CondorWorkflowNodeBaseTemplate): """ + **DEPRECATED** A subclass of the CondorWorkflowNodeBaseTemplate with the ``type`` argument set to CondorWorkflowJobNode. Args: @@ -395,15 +402,3 @@ def __init__(self, name, job_description, **kwargs): def process_parameters(self): pass - - -class CondorWorkflowSubworkflowTemplate(CondorWorkflowNodeBaseTemplate): - pass - - -class CondorWorkflowDataJobTemplate(CondorWorkflowNodeBaseTemplate): - pass - - -class CondorWorkflowFinalTemplate(CondorWorkflowNodeBaseTemplate): - pass diff --git a/tethys_compute/models.py b/tethys_compute/models.py index 3da4849e2..95c8fb50c 100644 --- a/tethys_compute/models.py +++ b/tethys_compute/models.py @@ -569,6 +569,27 @@ def condor_workflow_pre_delete(sender, instance, using, **kwargs): class CondorWorkflowNode(models.Model): """ Base class for CondorWorkflow Nodes + + Args: + name (str): + workflow (`CondorWorkflow`): instance of a `CondorWorkflow` that node belongs to + parent_nodes (list): list of `CondorWorkflowNode` objects that are prerequisites to this node + pre_script (str): + pre_script_args (str): + post_script (str): + post_script_args (str): + variables (dict): + priority (int): + category (str): + retry (int): + retry_unless_exit_value (int): + pre_skip (int): + abort_dag_on (int): + dir (str): + noop (bool): + done (bool): + + For a description of the arguments see http://research.cs.wisc.edu/htcondor/manual/v8.6/2_10DAGMan_Applications.html """ TYPES = (('JOB', 'JOB'), ('DAT', 'DATA'), diff --git a/tethys_sdk/jobs.py b/tethys_sdk/jobs.py index 561a5ce68..17d2394c2 100644 --- a/tethys_sdk/jobs.py +++ b/tethys_sdk/jobs.py @@ -9,17 +9,16 @@ """ # flake8: noqa # DO NOT ERASE +from tethys_compute.job_manager import JobManager +from tethys_compute.models import CondorWorkflowJobNode + +# Depricated imports from tethys_compute.job_manager import ( - JobManager, + JobTemplate, + JOB_TYPES, BasicJobTemplate, CondorJobTemplate, CondorJobDescription, CondorWorkflowTemplate, CondorWorkflowJobTemplate, - # CondorWorkflowSubworkflowTemplate, - # CondorWorkflowDataJobTemplate, - # CondorWorkflowFinalTemplate, ) - -# Depricated imports -from tethys_compute.job_manager import (JobTemplate, JOB_TYPES) From 86d0ec677f94fae61d4a9ff7ab349833365b31eb Mon Sep 17 00:00:00 2001 From: rditlsc9 Date: Thu, 27 Dec 2018 16:34:15 -0600 Subject: [PATCH 210/215] fix test --- tests/unit_tests/test_tethys_compute/test_job_manager.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_tethys_compute/test_job_manager.py b/tests/unit_tests/test_tethys_compute/test_job_manager.py index 8df2ac173..a71cdd8ef 100644 --- a/tests/unit_tests/test_tethys_compute/test_job_manager.py +++ b/tests/unit_tests/test_tethys_compute/test_job_manager.py @@ -70,8 +70,9 @@ def test_JobManager_create_job_template(self, mock_ocj, mock_warn, mock_print): mock_ocj.assert_called_with('test_name', 'test_user', 'template_1') # Check if warning message is called - check_msg = 'The job template "{0}" was used in the "{1}" app. Using job templates is now deprecated. ' \ - 'See docs: <>.'.format('template_1', 'test_label') + check_msg = 'The job template "{0}" was used in the "{1}" app. Using job templates is now deprecated.'.format( + 'template_1', 'test_label' + ) rts_call_args = mock_warn.warn.call_args_list self.assertEqual(check_msg, rts_call_args[0][0][0]) mock_print.assert_called_with(check_msg) From 82d92cc9e17f6b32a42f6fba507191d7017c730f Mon Sep 17 00:00:00 2001 From: SarvaPulla Date: Fri, 28 Dec 2018 08:17:08 -0700 Subject: [PATCH 211/215] Changing version in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 79a00de6d..9f4a35b67 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ requires = [] -version = '2.0.4' +version = '2.1.0' setup( name='tethys_platform', From bc94969a09eee6a7369bcebc02e626baa18c793a Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 2 Jan 2019 16:40:53 -0700 Subject: [PATCH 212/215] Changes to speed up docs build on RTD and prevent timeouts: * mock dependencies. * install minimum required dependencies for docs build. --- docs/conf.py | 52 +++++++++++++++++++++++++++++++++++---- docs/docs_environment.yml | 22 +++++++++++++++++ readthedocs.yml | 6 ++--- setup.py | 7 ------ 4 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 docs/docs_environment.yml diff --git a/docs/conf.py b/docs/conf.py index 0c0967bda..80b73b40a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ import sys import os import re +import mock import pbr.version import pbr.git @@ -22,15 +23,55 @@ from django.conf import settings import django +# Mock Dependencies +# NOTE: No obvious way to automatically anticipate all the sub modules without +# installing the package, which is what we are trying to avoid. +MOCK_MODULES = [ + 'bokeh', 'bokeh.embed', 'bokeh.resources', + 'condorpy', + 'django_gravatar', + 'future', 'future.standard_library', 'future.utils', + 'guardian', 'guardian.admin', + 'model_utils', 'model_utils.managers', + 'past', 'past.builtins', 'past.types', 'past.utils', + 'plotly', 'plotly.offline', + 'social_core', 'social_core.exceptions', + 'social_django', + 'sqlalchemy', 'sqlalchemy.orm', + 'tethys_apps.harvester', # Mocked to prevent issues with loading apps during docs build. + 'tethys_compute.utilities' # Mocked to prevent issues with DictionaryField and List Field during docs build. +] + +# Mock dependency modules so we don't have to install them +# See: https://docs.readthedocs.io/en/latest/faq.html#i-get-import-errors-on-libraries-that-depend-on-c-modules +class MockModule(mock.MagicMock): + @classmethod + def __getattr__(cls, name): + return mock.MagicMock() + +print('NOTE: The following modules are mocked to prevent timeouts during the docs build process on RTD:') +print('{}'.format(', '.join(MOCK_MODULES))) +sys.modules.update((mod_name, MockModule()) for mod_name in MOCK_MODULES) + +# Patch mock the harvester harvest method +mock.patch('tethys_apps.harvester.SingletonHarvester.harvest') # Fixes django settings module problem sys.path.insert(0, os.path.abspath('..')) -# parse the installed apps list from the settings template -with open('../tethys_apps/cli/gen_templates/settings', 'r') as settings_file: - settings_str = settings_file.read() - match = re.search(r'INSTALLED_APPS = \(\n(.*?)\)', settings_str, re.DOTALL) - installed_apps = [app.strip('\'|,') for app in match.group(1).split()] +installed_apps = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'tethys_config', + 'tethys_apps', + 'tethys_gizmos', + 'tethys_services', + 'tethys_compute', +] settings.configure(INSTALLED_APPS=installed_apps) django.setup() @@ -315,3 +356,4 @@ # If this is True, todo emits a warning for each TODO entries. The default is False. todo_emit_warnings = True + diff --git a/docs/docs_environment.yml b/docs/docs_environment.yml new file mode 100644 index 000000000..4935bc3b1 --- /dev/null +++ b/docs/docs_environment.yml @@ -0,0 +1,22 @@ +# docs_environment.yml +# Configuration file for creating a Conda Environment with dependencies needed for Tethys Platform. +# Create the environment by running the following command (after installing Miniconda): +# $ conda env create -f docs_environment.yml + +name: tethys-docs + +channels: +- tethysplatform +- conda-forge +- defaults + +dependencies: +- python=3.5 +- tethys_dataset_services>=1.7.0 +- django=1.11.* +- pip: + - sphinx + - mock + - sphinx_rtd_theme + - sphinxcontrib-napoleon + - pbr diff --git a/readthedocs.yml b/readthedocs.yml index 1462dfeec..723d8585b 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -1,7 +1,5 @@ conda: - file: environment_py3.yml + file: docs/docs_environment.yml python: version: 3 - pip_install: true - extra_requirements: - - docs \ No newline at end of file + pip_install: true \ No newline at end of file diff --git a/setup.py b/setup.py index 79a00de6d..334008e87 100644 --- a/setup.py +++ b/setup.py @@ -50,13 +50,6 @@ extras_require={ 'tests': [ 'requests_mock', - ], - 'docs': [ - 'sphinx', - 'sphinx_rtd_theme', - 'sphinxcontrib-napoleon', - 'pbr', - ] }, ) From b12977c03844dc6ac00dc1ee19d54b1d3204e854 Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 2 Jan 2019 16:45:02 -0700 Subject: [PATCH 213/215] Fix linting issues. --- docs/conf.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 80b73b40a..10f65dec5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,6 @@ import sys import os -import re import mock import pbr.version @@ -42,6 +41,7 @@ 'tethys_compute.utilities' # Mocked to prevent issues with DictionaryField and List Field during docs build. ] + # Mock dependency modules so we don't have to install them # See: https://docs.readthedocs.io/en/latest/faq.html#i-get-import-errors-on-libraries-that-depend-on-c-modules class MockModule(mock.MagicMock): @@ -356,4 +356,3 @@ def __getattr__(cls, name): # If this is True, todo emits a warning for each TODO entries. The default is False. todo_emit_warnings = True - From f80353bba32ac3eec662deb5a3ede4867b6fbc0b Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 2 Jan 2019 16:47:00 -0700 Subject: [PATCH 214/215] Fix linting issues. --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index 10f65dec5..764309ef2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,6 +49,7 @@ class MockModule(mock.MagicMock): def __getattr__(cls, name): return mock.MagicMock() + print('NOTE: The following modules are mocked to prevent timeouts during the docs build process on RTD:') print('{}'.format(', '.join(MOCK_MODULES))) sys.modules.update((mod_name, MockModule()) for mod_name in MOCK_MODULES) From d7092e12d88164066922e5d9fed42063aaf59a7c Mon Sep 17 00:00:00 2001 From: nswain Date: Wed, 2 Jan 2019 17:34:05 -0700 Subject: [PATCH 215/215] Remove unnecessary mock.patch line. Mocking harvester out in MOCK_MODULES instead. --- docs/conf.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 764309ef2..c866fb30b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,9 +54,6 @@ def __getattr__(cls, name): print('{}'.format(', '.join(MOCK_MODULES))) sys.modules.update((mod_name, MockModule()) for mod_name in MOCK_MODULES) -# Patch mock the harvester harvest method -mock.patch('tethys_apps.harvester.SingletonHarvester.harvest') - # Fixes django settings module problem sys.path.insert(0, os.path.abspath('..'))

zrRGUrDuZlAyN!>KdEP=(zRfF2?z==5u!%x(<-OSCkcTb`gZgOopI)(mmg9eNl`kxM zhJ0X_#C{g<8(_Tx=`~wfY4zWyxXZ1K*?hZ*bsT z4*~oigCZZsHGlN#txLdpydm*xC^=h!l|7P1s4k19Eqofhs?pVIQYg(b>sj|7$HA_}17pojq`j0qlZ;q> z&iAe(mERb)*>k^fDo`tz?Zq#UFXvEbdH!Aha&j=tR>G4$+4f`E>2`~DSh%|efE@HH z!DFnfwEo_~T+g)Yl~|;_9mPU=rh&=GyHbDeUv7=3y_kp+OMZ((XFGK^=%H`cd0kiL zgQBWrl>PrAG5$N{^nZsFjG$oR5Lx1kLSOu)qq6SycD@&bN`xECOfJ|%_}Fy^b_`K) zbZ*z1O|S?E#RQ!~uHD?hNKB{#j+O{OKT}= z8R{y=aQ}hl-xlBi)RU3iVZ4qMnj!&f<9u$zq-H-49Z#?|0t=|piToTcNPRPRno$PT zx0s+B<=wmTkYD<|RyKN{I;^kGgSsCOj@drX36PH~ zedsg9c3L?FkCbD*+;wXwCKdW@ue%_ZiCn+&?~TjOwEBDRO>Rt{Yt^N{6(1R6Q1H)i8wT%?jR1)4SNeh>Hbh?rHDErf$*dizvPtm=cXs3_^>@%rZD4_S zk_2s{4DeBid&$S%XCNO%Vay*4@FpMthx2n25wuys)CJ}(MwN-c)nQb8Sji%mki@CMhK zVT~7jR{iDY{vZdO`SF!m?hcP~g`rwGC-w?xzlFuk?37_YZxA-Qrfz*c&Uq$Hc~g){ zsO3{J`x}zSLT&gXEaSDF=sF%ZnJmgmtNAMC5Cl>=4fPDFaqNtVpAtOhm(cxg3lb7d zh`mNTDYEl^Q72z(!;!j}sMO)(2A?Sj@zn*if0;<#y#%P{B0b?Uk7e)|>M->MS+{H_ zv=cUW$3Mc{%Dququhpwu8)&2Wd zA85wGQ}34@w9#_2_-8w6OA819=n(OCX`YzTLJmB;*x`3bgXeF3V+ zo)<;M&v~T=jMNxTB@Odvw5m-hQ5#>^7fD*h_d7jHS;X8Dz4yK3E_FHBcfI&5X14k_ zm1b}sW39e6p4GKo5XO=^ajcvQOglbbtxR#(tGMSu@C@O+-lwi%gdIaQI)+xfqZf9^6AsUt9=_yl%# z-Z)akUE{|qEbwuT;k{)yi?CO~jm#{P(*&T^xo&NFT-#a$YE9dD|8FYE|A{9d{VTLv z5wRykZJr-?zoZmK=^*5@TOcZR^FEte)ydDbsa@IWZEyE9bmF&u-}iV%^8V%IxY^0m zV9L-eP@2Y&hs(sZjsdTg!i*!#C>N9RDeUiSj7~bmRS2S+b2+TvTySI{PjMIabzIoZ zl1lk7hAgG@LDlWEC(r@}^pAk&+VgaD@VD72$;5AnJQ(S3r5NCYaK>wV6oHCsFa7;aB7$P_KOINNQV! zl$HLmM^qp0uE_a zLJo&IYby(xZ;L2BJriBvjJ5rXB#p7p7rME|2aB-YsOCO{fXG@z%{-UW%Vd0RuJvO<=L(@mXz_7L|E?k!_Zw$tWj?5?^h-} zyW&r!XQL{9QHZYkmORMXs^VAqQPKZqUkZDV};@Xlvid9 zT+f~1R&BlX8wssiW!4*I$+~Iu_zMu=b7e$uMKfZ(6mvk2j}czleZml<{k;71%IMVz zG3qY#VGyxf6?HXFxzbn4VC5Uxeeq5?daG|L#{DJ>`y%kIClj?qdga$;p>PCsSk~R` z8j;lfe8OMXd|z9Rrf&>-T<`>0>)d%d3p`mA7=PGS z5A=&aUQcOdRQVcR9&HJ}lh9j=QiSeZ8VHyvmgUh;>MrI*Ei;jNq`qDj#w9S~L<5G6 zgVYHqlX0?f2q@>Xyt)qd4+dex`y!E&EP*~glJry*{F_N%L-TWU0e0>)F>_R&M)dnV zaR`%Mr8daTQ0c^de_!0zv**&x>@bn;~Qvf$ePf zTcF0+$b&ez&3Kt0mPV~#eeLbY*&uo}6!e3m+6+e|I%!^Ox&pQ3YQj>SPN}bGg0kjL zq-F7{pgS$J#MoewA%DVWO@2IYw@PAZ7D=?;4^10Pav9bo_GYw|CY9w@q5ELYjt3L4 zE@)%KjrGon3#~|N?PTG$o&&(KySuwf*@^7;%Dd1mQoq?|$!TZ~7@~o9TFGw1DS_v3 z=u5}vs2hYBRJ4+cInb#bAz=Ca7jBS@ftdf3i0fY8mk!2Io2znk@9IRWrHJ5i z+R@$31Au%67%f_!{1!~P%%#@?PJEj<-ZaW%Jzaf$8F{`W6|XL`7FKZQ^7pegORBGi zAoCyzRvhd(hA82l+#KDV{=A+ZS_o8`o55|gsEXXB*9MlbD>2`7?|t6i0Jc zrD70m2+$*54_-m(UfoG!8BOg(O}lOPd|T#}#!Q#Hk<7)tP>Qp`hW_)6mOXyzVq3sz zH=%qkpyptJZ&!4s&Zx+dP}Kh8wgi;dJ>Yg>=gdPx^d(>i1{;Fyp2SbsE%&m~VLm<5 zH0EmhN*D3Zhf1tabUE=v^chZ!k|tB3O81yz2Gu;~W>Z1vSv`MJK^H+LMln(+FoxzJ z#IIN6LeyaDV~UJ~&@FPTd7bWJDxg#!PFg=_SZ3n=^823&K>z{&nH5B&Be%bk+gvnG zqIH#|Lx*tL!7k_~oM>$K;ZY1(FqP8DiG@PxSRREZrJLs?W5}Pm0=$Q6M zS<)I3pzn6jQcZ0Q8=8befHGQGgwKbZQAyx7->5+F8#Ror^I(r z+1hg)ic0Bv`c~rZ|K5Uo?hXQj`5YCqSfE2Dj+srw5gZ23zzuD`uGkfdyktqojby1; z&pT{mWFUz1Yiuhyi-;N(du`t*DOs`0!3tVwnYvHeXk0GEfWgr%hjMP+&oo#+mCnZ={}wc(&^ZaGhsT4Tew+2xek?Z0+&24Aglny9d$45!1x} z18M5+A18CJe4M$MFinnVc>Q#BQSAAR<|?O|cJ>qb@1Z5C;9U;E5yKinMm-jl^_flk zY;DX8vQ~ybiK^hesLxqtk}$t)0zO;dCLV=^l)PO0j!a@h)4H1Z?%Tfq26SEzP|h%SoN1B1rf(QKGXWoMncb6{t7NQ-HUaU8 zi6=`gbKx)D5;)-X%J3-~ipVYN&#lVk$J+w^9e(_Kw2n;mrG`^|D)3j@_6)|hJtnZX znB*zAmc_4`=`*ysfCLXSCwG+(RG_|y?ta+ggz+#O6cT>;Z)?uL1Ho&s!p=g?e?tBS zp3?=9v%+X1rygQ@UsAle1q#O6<~78~p=D;z6TSAXud%%{0ZuQc1*b6LZ~kdou?E?W zLU!u1&`3mn?l~39;qkBgWc97nxYPm0eq^o5NA6zlUl2oeyKQYF+ z{Dua~DocJn9Rh-LCLaF3rO9x|M?<4Ax^XhJI4Th z74tWupSiJ&%Zp`EBTR8bAEoPm^^Cv~jPo;p)i*jO7y?}h9 z*6fMB5=^fYO$Rm8e&|C11p*Ld4HKlRVFLT}@#VAIGt{mo*X;2VMhq}4%RM#vq{e7E zN~c_?ufwj#MntVTmP}#yXovF2*&aOEk`QIoA7B%v^3}00ziCcj_`&zru3W~mNv5Ne(weu$B zJ?~$1_IbLqq3bc}Ld8re%0uM2&8hktzB9b^_DJ7yeiiIkzT!6{mOdyjJuHm25n>E- zn|QI8s`p~uzi=T7c}%f_Hw3x~Ht0?keTrnHfayj2c=p1y?Sbua@>-wv0gUI@W_|og zyzmS;j>$?^yR72fw$w^GgGKeh*?)EWQhT`s6r=JPGX9v4#?;SQXVm(mhIdB8x4#QL z^p>&Rl2_H^I-=k)p3XU?ZP{);%^PHu*YxcKzc)ihUf^ z6_c;MF@}yNQ&BGT74)v-4jua&n#H{9zv>`%P4+)h@BiyH36k(ry8rCBt70UuqGT5M z9noH}QlVr=!L5LIJmPRFO7z{}uq*1-7MJguqO&YU>>v!807#{ghZ+d6lcOx-Clt_D z3n;z+D?qA)*1*IF7fcz8xOb0%j=vmdr;(vxa;7wCff)rmAOp&p6A!G2Y2OFStLKCy zSn3`8ypFAF7Id|sc-0d3vy`p$Of3C5o1~y*NE~5@@RVl5adfp+y=Jc~fDfM2DE@3e zeHn4ZF$B0DPpoP_(r9lP)NZ%yg|ZM%#|kA;NO!fuKu;Rk+Op!2oEkjc{2M)>)K*?? zJnzuUbtj~O6=#h=56d!%^B3--ir5ssKIYyP3g8kdFrUzmA2HZv9K^?{xNswyw+$}4 zqo}ZT5%l;9bjLr%xmJTF#cM3(#uebr+LUgu2@365}cQliDX>b;WCP4VIzqP%7RWUZgU5k_01}O;wn9#5Gtf;&;3_pM8 zcGEr<6Y6GD+GaCv;?&=n+{x%0=Dauv{UNaQZ9uQ+ljbRcA=O%E!$>)O`-uXP*sV$3 ztVBrw@o-@ldy6Nq_{$2EUyoJpUM-+3cpZsMjOV!f8!>un@3s`xHndjeBP8gnaJZE; zfd5)EO3wL?UHcrWTU(D{&Vc=3gTaRpKB zV53VuV&PiKO6*VVF%N>9<>-FP=f5|)0^sCcl*%ewyicaxUKG$(@|D3RU=~S*-E39B zLlMHw%Ck17uUzt2TB*^J6u(?ib~n7R7r!mazSr zQL@*QrBT=I6~Mluh`v4q!&f2N!BAvm>2mgByza2jtpr9iXF}-J*_1l)oHi&4FByMt z9RJFrb9K`H?94_@N1D>+&3^F;6CTvRR zm*1CgcWwz}v(uv!pDseQpH;a$#0FbPXW`OGq_r*Hjq)%rS1U;=N3PY3KXsV-MJ00| zRLOHG8=0zCSdG$aNdt|7#9;<$6PAJSwu0Umu+YscVzHhvlV<)$u9?u0_ku@HbcHsg zonXqX>t-U;#4_Y1JO(u7Hh{q`PX;=IsPO_EbJ6j{lSHQeg4Hk{r<4#`SrLOF6Zmb% zaf2QZ7Tk0iB;_6>KaG3ojmG@wx@S>gw%)mz4$9Ex;}_NGnCcuUMSkPKZXB%*rt);n z^>|%11WkB3S;N=R`*a0iOBp<_jg5ZV)!8j1BXdGhd4aSx#JHjSZBBYEcE%DuJgg@F zs0*!gZSL=R-R>^>r_@v`_w+%cvoYkb-Na`oZv`LOZB<<+9QBWzsp_E$8}=`n>Z0ph z2Ibu6&oREn{ZCFF_!&P#-q*!M$2X@+aElAesX1BdgPY^$iJ+w$VJ`{AdKcThD`(z z#gs#d41V`T2Ini#PQt9C(4AnSkU?&js7zccV4*amML-oA%ToQ-UbpiC^yG8^OQRQ76kGn z6%AfHpUzLG(xcKBySK3Qx7DbF9XEUXuM^-@_~*~zdfxmoB^a@Ai1L`_U|tv#mMU0d zqh&uZbi(4-8fO0taL0T9L~uDx|2aE9KDZcX^oz?krsL+4y3JAxGuIA-43r<>o4PlL z>dGLW7|gdhc5_L&Xfd50C$OCZI*dBfA3E$bhsqra3IaS|=FRMtE`6RST^ zJ-)$-RXx3kh>Pm}r+=rLuY3dRA+r?rN@c%5KJ}W56Ks55K=#2{O#dVyL2-ro)^EH@GzV_9T=*X5>`csp}gI`3R z+?=brjB1%#uWtSc9Wt%cuZzf_`8vErk2Uh72uh9OQZ?&{8}eK^>H5`rpPzR#T|5H# zhY|b_*rSh`oFP9DEPdO1WJOzxDrEP;E|VTt#dOLp5E+XUSHs%?XojXKv)_1Mxh#%wUpykP?*dT)lsHA%6Z#! zNFbO)xKnsjcxzKY@Jw;$)5mHot!k|-#ywXz8k?D$rL9oFrj(c%2Tr*(TLAJQ^c)W& zlr${Ez5_-(xO7l4-bFgBKKh^Y3yx;tvOKKt@JXIp2^Mh27gU!CL3(GALU->h{fGHUa8;10@Z2(V@yn1rJh^H z)s2D%4rUp%!V>-)B7(<1eN^;1Wre&p^h~ogjl@lZ!479F3}tq6 zx&A;m$2NK{QFAd?POjfdlYfaVPRsT2s6(L0{Z5bL?mO*%bLM*Q+J1fDfpKpzF+3y& z6H2ygytvMu9~?IuSkbXu*8k(sINcX)Ja|70=)AlVS?SGswLU(}o;Xf2l|3Va%bKV8 zdOTw?wDSDQHVlsor+&6$te|*zr z(ezkVUT^4gGAa%4w0GyzJ+KfiUf=%;8MIp*A!JdV-`x0b_nhk?mNVX4h_|H!U@^ar z*QT(iuIXLt$%WZpU7-r%QTOaI+~$Y#@;u@=;O5}1)|?RQkYc;<{X_cv*5LKh{bRDS zjs5mL_|b?XC`gA>3q{J138vyxTY{9(^0}D)tvl4Q2g17APt&I9&zQx zGlTsg2rr17NVpR8|CX2AX)2)!O5>8dV(GBaC`tQj5dbwKdTGQ`juyO{#T3)nRI(zr z{LAJk0>}MBxX0oD`Y|Y`th*ZO;ZWjEt;*uGk_5H9gT{iV^xh<{kiDEt!cy1Y>SH&@ z^)lIKE8~yRQZ^>4xt<|KW@#uSSrB4l-J(XN9|H6*sT9D)Rn?f7;l>-gdf@0EMIR|; z?8dxGNzKl^B2rb!mYhY?HHE8ZuI(W6uJClj%R-zZZk_Saj=$LoU?7u_G_rs zHhj8xv>!b!3s_sT5s%q~(MmA99#+b`frWP`DSBF8Q|{+bt%~`Hm_Bm0C?v%3eabljrhJ&mVfc^qUTbn{+U$*Wrq^rNX{1Ps$;k zvz{R4FUtZao~JjvFajof5D;$+^3dXS;vZmdmjPAH#|l^t4#xg|&cb=Vd;~pRzJU&! zw`aL}_Q-rZ#XF6iW|b>JV;RJEb8yBX7}IbPuE9T_HH(*rz&h#hDfmWl>8u#pxC=-2 zSosG;K%D*xZ}H3G1#1@e5&D6G+6?S2Et)ZW?GUxO2-3lB9(z*=uZp~OHboYHadGaw zyMBIMY4+UTQb3SC-{=bfBBG4zlA&hNTfR1by%Q>&6!vVeV?yUyQO^UYp8Mjz3-SRWD|v%jl3d8NjJiUw-gH}3v2%nJ8W z+9(+1KCSyk-hG@5ZP?Y#<+5?u$uaDY+zxdmhBx?l8Ty;^z`& zmCu`<(80aN@H_diRph0)6jf^rI66_6i~kaZEG!Y?(Z@hPpB05a$v&XG{tQ&$=;(7}yZrxXjM=5_4f3+^RTz7dRNmza|w%_XGQ8yFRa^)=HG zq^t52DB$`+s^0(=Z4S$&{M&!*{cXCR*D*a9^5%j9j^!pE?dhEVP4Hd)Z27p^SiCw8 zc;q7aaKsKX8Vp~L)8J9%HS^v%7(!=Tfmg!eRi)9fTF1qc%ePavAO6wf-7X2hTs7FI zMdseA&A=>X(VwZZu|YdCV4&b~CX8^ESOj@+RSuoZSzOyyJs{bg$Le$_^hrppY=mW@ zi~T_+Zya`cF=)zQKZaC65y+PNz5%Q8WI4NQ{EELEoJRgQvn$X5I4r*>#|6(d1Aabr zSyIQ9@0X>Kf#-O*z25#f<2sO3{J_z67V8<|5e_ux=Q-$z$amrwjim$12D_^r_LbbO z!JU(lI{hTo+ewVtVYevw?n}2>u9Tj~i=`aN%K9fN1+rPht6t}#Xnw4B&8M9t_cBA= zpw&(d!{B}1hSskeMVCt;9mSz9k?b9u-_{=8m9+eLa(t!nu2k9Sy7llpt$$eAub zZ$EV4hv8-N4n8p<#IjP=mvQ2K6l7t>{j&-QhP>rte^H!0ecWgyw91Q({`jkLXpi2X z`U)W}w;x!@{dOr5{jQ(us&^iVYGxCb4|eZa>cx9C{U07Dmt(bdIJNKSAlWyc$)xzF zz5E(xp-eTK9OT~&*#CSC>L3k{g~n1L2eI%7KdP&{>98=6Ya4psnVL0>Z;n}|x`v=X zvF7}o#ig7Rtq~;6a_*Ps=9;C*qGoVVIGuw{umiAWVn#_x;Eq~e%94MPUG}$gFC)@O zXC|=M-sMmfLqT8?(yQd80UXt3u&KN1tZvRZP6izN^Qmu|*U9X2*l;t_6+YMsb-ZtQ zdL>!vAH*SyYVPkN5}`!36u0!MElk(Z;7+ost*gqcLyoB;_fDVXl^9%SGbM|Yy~VJV zO&lU&1j=%Bb*rzo;^*`8jVe%_)g(y*^24&J*tTthKmkS|Se-*Wkr5zuh#?W?ZZDe6 z-rq)<4KL{?$$?i1Fr&gl7?w%fjo$B>ARspSG8H_119dK&E;1c_TRB%H5-+-}a#sPD zjlYg-+vP90LIz*vaqqdBO_M8*R*{5q8QquHTGM|3dueg2ozt>27L2!WDtqzT>zE?q z0A?Ibl0YQz{dzFp65^C4qR1T%`{c0~BY(f&hA~bI3#rnA3%p}avxl^g?>AYE(pK7f z8tfqRFsNX`Cqpo~SnJ$Hc~Dgej?PUO&cNImz)lH(pM&<}roS3N=HA&kSbx*ionr9F zM#MTTty0%O9`tf?;8nBsddU-dG!jP~ytBXWZn{!}uZ6Os9&Y=^*70qyoOWUm^s3H$f7SZ~(jLJH&Ba5?2(fAJjbEVW z_3v>7Q+v9b@JSr!;l|N8Q5{7mDHA2jIs|x0jhqM~s^KTj$6MSm5g|DlZ!3&i%ZbFB z!Jl0dOop+f7~7njoGp!?Cr9(%#16sU4`C89E5d|jn=XE39?yLtcTc(~P4d5I`a|w- z)v*SGYQ}=owtmIQgwVo0opuV4rL7!{muTbb^Lp|&1B#e(kTzj?52fs$ZEZ`$y$_zw z@1GaoY&DL^V!^`D3RpPZF^4C*`B0!9u0zt;O-cS9rz-#n8A^d_(Z2cYEj?g0Ts9(I zIU%c>YAcbYGMlK_ps&O8-_B{XwK=c;-t5~+xaU_@T^Br^Hn>+dO#4QsbMWW{IuA33 zjke`PgJG1Z%$A!98VSJxgRP@)JTUmrTFm8LAB~S9irZ~-yXZK={P-f;+#NxDc*7RL z2Rn&+U_o_@aaURqN&GnYkPOi0wQcp4@j}nu-BV`r45@t@Csuy@;d_7alc- zOBEGM6k?NDK-Wi-Gp{9h5v|53FWfCu>7jCD~Gf%@Bj9w0zXes)U?idp^X zJYY$aLIHj=jk%icI)xv;o=Q5^KM+czkOTjVhC5rK%3Sz!p%lp=3oVU(oK)=e;z3y2 zp>>2RySDfsU{;lm#5J{ZRWH5YYOZ!UU9EO~B`fk<$#dXhnu*v{?|`9ZR)fhGliUYi zpZolz>bJ+8Dn7Kcmw&rQ5%Xvfs~Pq;+3z2wwmWah>{ z5N%iaFu9s(ubaU?S1BFkLiCZcXo>FG3L^CC6@;^Gp~Fu2biSQ?N-{dSSJv! zN8{Yg^;zXQJnZ-d+)U~YNw_-;9L-xyCiA=ZXtz78_R}Eld$_gA9Brcp4ure$by?Ml zl<#M_##yR0eBIkP;XO^yp1pf`|8~TB$v-C68_w!yhIc;?^c9!c^QaPG@fZ#cZIL{P z^F0hI-pySCyB1^_-W?u8Od~AzQnBWQa_QY`wke2jCT!&(fqwqODu(-Oi_1@~a1w{Q zG8)_gdc~vjvxk?DUxmKI`UF&8!29QNCjNog-y58-F2-*O!cQ^OULjDx^wCydFXw?O zq@HxvFgX$+`oM-d#P6Gh8>j~lslm!Dr(9iRl(@Tsj0W1*LgPuNGB~*iMb3k;Z}^IJZa%=yPx2+G;esa~}Ft z3_J<{PEwSy$+&W7PAIHSyvIC_sJDlim;eEh z?kcem`6%^$pT12jJF8=Bck& zZZ?~DXb6x@>i_;MC6_bTXHQist3_Fixp&URzTlu9f(z@p?y6%@p4ja{OM86AaPxIP z0o^=^M?U&FeBLz(=E~vc;<63dOOqJn8XJyD>suO~;=8I^d+$M#HpdA@pzh6!Zp;85 z){A&(gbl?N$_1Ngubl$8B&%nX4ADkHQUeXS0}Gez;P6cb0N4tNJX^}`{M!@!?0tL> zzG)DM2W$NvVt%55Uk;%!ony8}b=&V$UjiEsN4Q-1wQTJ_q9yKzqXa*VqND2c=_6D!0;T*_C=F)u^Pq(+u*B=0soRg}qRvx@muRJgq#^Kmw>#LszF1rmV= zKkwl@F5?0otr&g9uz_r9gKA*I)l8>v{;)150}a8?uz}oA@8Fi^Z4a=iZ3ZVGuxt-3 zY`oEQwUF0iL#34&nhwA7K3Knt_qa>z35PA_#W0?~9r1kIJcaaMq3D(3(b0)B3*yU3 zB@OQJvn4)0W&Cw}i9NkEU!XZPUbU_6@DN>Qo*FZP3ei`yo@70R1eeC8rV_O`(gaH$ zuAns44E<9{MYLl_HnUA(DZ~QWSbmB8EGK;$aVfuu8V6~C$757)%FBR&Iv<2M(kz7o zm+QjKzLXj#Pb=$nb6(V&2S*;`O+Af*ug*Zl%ke6rfg98f4ltDbS30|{-^d$PV1|n-RGG&gL#;w0MJzeI`*+GQq>8w>ak>MK5#)6ah_f- zK3LvH|8iFKqw)Z*%zOlF|H)`FcuVuBT{Y&^+)y?tEXnsz*hBQ{Uo6J}VJcoFmA6;5 zanSwjbvF}rJ1K@74r8dot&dkE`onvr1Zif%GXV771iLcRMOv}Es)wg zRObU9?W&eyOU16!j{SPZ!r!JGz_5ncZbXvYX8>ZdlQKg8{RwS=-uOxO)qeXj)BCrv zasFc`KliHd-sum0bqI3_%X%4GWpgRn$!`<65uvW{md|2`<8KX_KR6YFuw%5=pC`4R zq4J2SCXATFFf-~%ckucl_;MoH>mA!0x{zce4qL(b*Ut$93~Litnbw`6VyB3o2=69s zNS8TZBYvhCXi0iOE&(4025daScRj17LR#8+1D^eH z+x3^RfYs=gyc5xo7tTO8S>=<|gOn2SwqP)PVUL1lFp`hG!{rUITzs3l*aHv8pgTJo zwvI6VQGsp|l7+>%Z%Q(n+Fh0a`{*W+UX{buj#usHn=r|S5P?zNziC;!pC^8Iy`%v? ze%U*14)Ts$+^Y!XW!5`BUC79pI`dsafgA{!`g2|0Kbi62?@j|x4HzKau`OY<0V&3? z=m&4G{=FXkWx&^n{Jrg&wh-<04(SW6UOuJen;eVhi7;r7uXjU_>=5<6a)ukRR#yNt zku-r8W{COq(Rja6mx9xb_1o>V!*DR-D&IlRSf!1py?uhkUQoA~hb8ONNiFm3{Mh2N z$DTHKsL}zdpG+;RN8MK~yy@h=H|$8215$KUS9AA;ir7PECyZFeh5%P+T0@s4PTL(^ z90R>apAgCOJmj6z5K_Onzg%?}h{%CPs5VN-U5MqmrZsK|fzmG;B`F{){$C3{g5&(HB&Q#|~!19-BcnGm7eO4Ved;8*Zq zTrav0soX*}`>g$(-Tv0&J-WMlJ@0dNDS%J@4S}SXxZ%<))}2eoeZcMoK9K0u#H!0c z=OLbTAKa~JjOcEy!rydq63@4GIseF)`f~FW$Yp8n3==ykB-nVKt4=c1iNctvZ^%z$ z*MZx)zy6s+o?B4<-x4BdJgWxUV5-1eH- zM9twqCL)jV5Q1+Ju1NO}!5J3n`u)PKxYQUw2O-|ygYmG$O;sY3Px8r2J0IhE9Ze|# z@BGNgywd~{Z8&Tr2W2Baw|5Guge_420q-Oof9bsftWHF-GSCmA-j$E0c^V+7EvPp1 zlM#O$q0w05zl{7lPh4p|oQ{1E8Hdc%t5h@PF)RAIbB@JoZmP7IDjSmK`D&ehX{DcR z6uOx|Mp5;E^J+9>wb<^y9T-tI!?muf5X4-tB;_Owu`m!)uPds}ON z+Om+;ZO*Pt3uy{IP5*sEz&EXTChZ1TaOst}nGte)3Z7@K%coNO2U`CD%EfKxzP}$% zV&Ym8k;gY!pky(48_K+es(lH;FV(B4Oiup*2URU=r=%>ub z?&Kt@@~84IoWIIjL*K=%oF6GX%dQk{<`NZ-DHJ|`n50OF4Q&A;_A;W1RtAWk%l`2J%Yth!~YO4HCyP>nU z|MlkKGB}50U7gwMMHWE@j(>mIzf}YI{9r1CgiQU$Pm8&Al8zXke^*#vtM7*cOdRrY ziXoxO>SDpQq3m0!;1eSd>7OS0Ga@kJgTK)>T2PKcnx|i}r)n)b_Io$zWuv<|nP3g? zo1*quzdkM^7!WXi_k_-l!|h2Y>961Z+kD>Eo8%urL|XkI zn0tN90d+aG{A%4xnM^z$lAa4cf(z|}?oD!PsgJ;5$KP{Stl`likNz#ocZjz@tfzq&>1$B33-iy(*feGj1)DYP1gK^oTUt%K=VYB^lb@;suB7wWwSs;iy%vxCaeqpfR~ z;0-nyWSBU#iDD+q9;>kz#cv1G)9pVLcDJ2Ge|B@`eqL|W!&?r!D&+cAJ_LTP1~bZY zNW9&@rY3fz3e}q?&kjeAFmWXEE_UV@^A*?4M0*t6=QpEh80E4qb{4f9X;^kApqpml zXY>2JAliuhx_;Y+9nD(z6@>4oxA9peD1i@2htARky$E=rB3BPNuR^6tSz;;;HbM+a z$k1HB9iQn+%si&5_j8EC-a83=RvSwzQceMCwHIt+BR|Wp-Vao3S$msn8E%<(KB(U? zlv!9LO@5g{X=hmow0dwhxz<%$S7@8gmk>+V4tPFVbaH1@WnoB7eVAE zBL3VdMslA15pPV^OhJPa-4(~$5n+grb;>n0TU}mV*j7SST;`^5s3#%OiR5ROz;7o9 z?}kI0~|ED+i4QPVoKg!DsgldUObS`Bmc1>KpmouEu%)}4Ug z`}`aFPeymhep;qLwC)fDlwu5zfOYP5eV33p3S`qfcTL1+lapI(pD8q?`m5Zn@^-(| zvFTpiBpEDO2R~|O*fXR+dZj@&z z%(JFQ|HCHyEw4gm4C>2iG&1mkD!$FR3PK5n@vUoYygEJo&0xQpqMD&ztk&Cs+F~JG z!CKh*T0x03w3+;@p|aIQSGcd>$frWQR$AYKo?a`vn<=G8?0nS^!Y#XWJcx_Txd3W$ zl2r3FmifEUgh@b232F1W`Pz@*^L+TKj1dDTsAGgnb`z5E=46d< zxTwnBMD+Q>=zmfqS*u8N9ZQ`Dr5a<~TFdPPK)jVCH3$IpldRoztuXVdk}qq0$RC{% z&{1e*=wvT#fQls)?#>wr^(GDW?(0D@4-K1@=$*lR6oW+{i`FIok3$f!6WTWK&*p|~**%TPmzp&GY5!>bijMG1mbwebWcO%2x^ zyrXxu_r5Fo`~l87KkW6*XRUM2=j`ui@3X)A8~^LpxYoM^G)SdPj*dxLeTBA>B(AXN z9j=hyeuQr}&jN`}Epyh)4;OA!J1R?4Qu-xnQoQn~blc{J zj{5{S`8i3~VC|m>!y*4Nv@6%w*VZcWbPKF0`;oWY>=ixNpWbi6QzsXxZ);UuFx+a#^s0+JG% z_8VswuRU1a(*}utOQ2u6NsYyf-6yG)m6MFb4cJta#4P3=tx2FIVs3Tj&P)YW58o8r zKkE0(DdjsLIjqppc0JDtIMy*c+<@`XN*Xzwfrz#a$?S}OJ`y%byP@WsrQw?gbfEwR zzp<8OJ1rTX+YzgNwvr*Zps0O$A=P;Balh2#q{L%=7pk)pbH}$Ty6h2 zP2zn9v{0Z2C;04aptA>}XR(MZtv@7kPNk#e;0*ca%4uj6rZtlk(NJV@(cHjb5BV~J zx9DNB;z01eZn*f~Te7wf&{2m)WmG8*&hQ}2Zxep5Kj;+a`aRB}x_GQXmZpBp9gE#} zh-trpRc21F$5KRrSOxtO9tRV~8n^_mZ>7mE#$7(|5x$_6sjFgpOLT_miSy%17R>a2 zU)8OE+XMh>X6I$7tMOomezPdUz;og`FH0e`;sk-117aEh^iNWyV~c{B_z->+fe_?y06d<{q^*Q7PC41rS zffHa6NwWgAc~#MZ9eU)ENi2 zadJM}$8e6*ebVr@8bj$-ob8#RhWBTPY1~{zZO)D^Dcv?p0q}*tF97}RtEsX1S^2Z& zNY$3kv*2Hak>Xy#dhX+I3V>#0#Vh;LjxeQHc$lJiT-Q(G18CFYf>U?&OA@Ja*%r`z z?K74Zx`=;f6z@T3#6t_OL(SE5^MWX$IY7Ui&_SAszbq-!fS@x?0r3H?Y#W2A%k4^H zq8Jar9U4S{e#!@avvW7&U#nu+UlevTwfh`1BQIPw$~)qwLS@hh3SS zLg>dwN&EHsyfocwkXo8Zy3BZ$6s#c8lR#o65>!ZNxEjgnS2eb}`O7g>x5GrnK%TS- z{fwF;|80co@bXN(vSHS@XJ_hW6BJ#@*+zfYtjuhnu=3%$d8PTSwJfa8L&R}F1I^MANtt+ zs;0C>o+P29WiuRye3wf#XTfhxD!m(^R;-|cg7bgN0;cmlNcJd}6glVje`ZjZFfTIg zq`{*TL>CVP#GW}{O75eGk$s=8dxU0odc>d=V+_Pu;f>i4DY>D(5moKRzJ5yg`zEl0 zT9r>D&jDU7;7`ob-qxhjwdALe-txyR=k8)MOVq8i&$A7eV6`Xh?ChX?ZWo~=9-EO> zJqbKOFIk$s$(CH@wzmJ*yS0Mx&~Im_J*bW{D6D?nii%}~vK{IbL+xSf>`ooxLT3zG zP$2`0h*tQN!7I_~10pulh>utBl{NUBVtjggGepLh|C(*jfIY33Hh6Nmf%)Z%4YGVy zvm9vj={LCVde8@qq3(b`-KS~jc@^%8y09U8!xCdXpAz+3Bm89{M zYL^CZunT{E-fe9Z1N6w+Z)5QOCWAXT38!`2dWyrdWX-tLSGQgT4-1Je$d5g-x-+=d zH2{tfzG#;DUHZOi#XmBD&H__Oy#>H4zm9#!C`kq21c{(bZ7yw7-*%dOGpsd%sfO1H zV_n=CTor&@K{?vko$x4H?gQ+3P`D2o4Bi?1E&wm}U&79AC7H_z>LZW_*skzbZgBbW zf1}(c0M)k%pP!x^DKj~!4aGAzNR+`-0B`%$u*S)JeYbpZf$4qU5DrLioU!?>s^Q6Ms^x^9ez96*;V-M zf^7M&!fz92Lv|H@yR2q6rP-Ry{4uhd(rla06HZTWWSXRh1QRK#Jiu|q2xdssM}_?t D+xZ+%niBMIRK|>}+hJu1ZlarNHhk}C5hJu1=K!k_1pnORFa{(40E~kkI z`S~H5heOVZ+@wCbX*gNBd78LdKv_9DIasg&%v>!j90Ar&ZWl1!qEJw8q2we#XnJLw zu6gUvX}vrE7uL26d%i&v=WIil(C3vVwj{MA2_`Dru2h#=mVtODJZbfciY9E!K*~>? zGn_1zg`aF~)9liUwj(z~k?3=nNAWrc&L2Twmp4mn)n_VIY zewvytPiM_c%EdU8!Ix#5q&WjSsgQWK2nurC)&`4 z-!+S=nW-KPJEi1G%U+V6Ea8uyAdD9k->`T8neVoaI1U*ZxFP%J=K)h24;s%^Z#uJ~ zD2IVRebZ1=!+!qZ&u~T>@t?lK$1>M9bvUj|K~K>`k}_OYcNz2FSTe7wR6>u_87jtS(2hgQ-1Mq6dD_SY@+~>lyBI-Qr+svC7LBau5rF< zGQvrz8F<{WH5XI+zi)RUFGsDM zh}{^<#l>F@7sIicBz6eqvu>qy{&bFZx!hXdXK-n0iwJp6*OIh@T6UxWIKQP(1r~lF zWZUC|w|~i83AgU4Et(POxXA}we8Av4nbcXqE!^&>i?vG&otSxG z$voIIYTOWs=!q zb>Lymdejkixa&FhxzF)Tnda(xIaQoA3!9TzlyO&GPM))AMJ8EQY)Y9AGj?99H`p!l63LDcWu>oYke2h z<^TBIW7BwVH@~UE$s?Ox+Wq&IsLt+Fva$@3nII^EXf2g3c6Y9M%7c~$yDhbUGvIq zjy8^d3(8FEPRW`F)T*u6B;0KewqWq~WTn9s5LE$7bS5w2LG~IBzlRqgg zV#2okPS1GaalTS-!`NWKz|$eiU~ zwvX*(eyJuSUTYHP`2&Bn$y(6EVN;hP#HfC$e3N+-3r!X!=txL7YUO#CU;b(66hoc9 zPSD00&E&-Sdv;fHXX-rhl%DKLOFYX_60c)-<=ldpQ3dHzYhtHBlVy`{Ym|{jOD*1> zhU#erumfjh>D^>v>k3U-TAEvBx#nWy8`29NlvltfH=#ScF9R}azNGYwJdyIz(zP>N zr<85rI;c!AYO09S&r;r;9NX!8q74kPTo)bAhW6UQYqG;^FET3A4!fuo-q5?l>}nYV zQ|zjOz?M=pQ`?EQ^p$QDAmZD)*Y}U^DB^SHwO$krYMm;tV6`ltt2mE~Y~fu>4DVqH z@~iJddk^mm^XwL?_%j{#SqU7Q+to0V$3n)AAZ@a$rNk8&o1r9C)z@zxDlXb4Y27Te zh3`0p(S^vvGOg~&%$d3rb1alK zFxvbow|wbMEt%@qH1|k)>h%=p?OP7#G%6l8LMpG>olC8hJy;W-H2K4(??vEhsoj-` zsp))AwhN9R zk7E=o2~mY7vf@Fk+b?`uZ92I0h$PC^lRnUuL~}8vB?c-731X&4zruQJ><(RBVcav+ zC!*N`##z*E$wbB;y#u#98y;isw+9hkWXDwktz;T^%UoYBc){dW=KT~#yBHB6LLMIv6#Bq?(gs6k&4xppDbC|L=)wnd3ahva$yUuY*RoTH4Yqh}q!ar<6NyXy z$SIvuS8R$BGn2$e+pBX>@YGaDrKOfGCDYx3*(`%!{Sz#4x~ zUn?Xr(^$L)yu9q=VYI@D;c9hb?CCW!H zYlRk?2^ZDNAl2}6e7M?=HQY`ac``&IaUt=<5E|PKWZ7YdQoiCMj?pOvdKyeM+dPh} zzVV-=HGI98`uNKPIeNeBqWrT!zb|VZFT7$6H)bk7oDTocMFP*2_tD~ ztd!ooa1K2NTT`J*j;iP*BfVIcHF*K=UE^5FiU9TnvjR$l}gfjZwqR-cJ z&1srOdR@shx72_3f@xkiK^9h>nOBXO9bnGP38NK0kEYQ8YDt?WX?b;*GXLe`*n5HO z@yI!Aknc9nv;Q?duBLFJ7aG{$;F5oIl0dWcs6gFzPDcwgPRF|FlwPiWokF;P)C_GI zC{of~zMWMK}0 zMwIb?Covmu4R)IAW~B6P6LLK{TGD1@ha}eDAX&2Az6jagb$gja-fzrzQe6Im!H;A% zjtr-A89W;&B8KN*e7d9X7Vt-%>flX3yz;3ac^v^$2(?@{c4ODyYrNE(Bc#IdvWH@G z?WsIF_-Kk`BGSCuas?6?QRI4pMiXj`7#$&Wl7bL{E%Zsv{n@9LH8)FQjCH zR)Vr!3OdMl9$GsBN&Fs%bOik73|0Z)_ols3{1tHKFPG$jPshn^L%-9;;$|ZQ3TphD z;jZOeXql2;k>c9*wtmYIBAXB)tmK)$%63M{>e1;I9oT3V8oS1`WOc?rKa z$(&Ba6Z!SA`;*{?>?=bvhuzoa*N@1HFLT{qW)fhq-6Ww-zq1q_PtrKp{_zn7|4aY| z7E{QEabeJ{C`c`6MO%FGj)r+rTXslPhl`sx>TRa@Qfoc#OI>>Q%oOyCJSM@b#BYeim^ZH%42N#6G!z-8OoF39I2cOk zG6;v`CYsbLh)TxGKPZEbCgZy-u~0Sw2(HIeIA|1A+{8)SDiLJIz8r>MHT{m=j7kxY zy7gUu@j_@T@b!Affm;EGdn7?Z(WDlOMepn*_STZsJI-67f?BaVK}_akSjBK8&(n}P z+xuBy8ug>>Msu>_H8*)nF$HZgnw2&GcP*9I9#WBvqx&e@@q(eW*3c=j!0cv;_Uo@x zrP?;nH9Q0cz2Nci>)IdH>%e?0j*lZE&Yv@CY=oVy#1u1V944TXW=p65Bj3jL%qbn0 z?O#XSrwunZwfOmN?cY^EpRknCUIc0s{Mr}r;n@)Tp78ehbUZHqa)^M~%R z0?DA7t7^U_#Ep21NAog5Sbk#}` z@Uia!D5sb$T3P#3$9B}c+U3)ctL+Y`PRj{u(~vcS?mR*g~ND7^I+ z>qT&WWWRcSFM*Uygm&C?{`L6_AXao}oAlxn=wxFrk)i_$#V&vqdHu*HCJ%;LuPec? zICZg>4=_XV5|%gKUC%%CVKmE)lBVoOeVUMx?Hiu2ZeE`*W{rpzWBCh6hkq48v8|V$Cqpwq#3ah6q1gJ zn8lVak{`PcGGp&da$#r{SdrIGjwmt&>JqEP9 zy1EVnVoZC&<0K2KsPgB_c#O;p>m7{_P7Z-9GG3>AmgsD+X$u-CZ~1X!aC0!#onF&~ z2enKe`1KT;?VQYyFV1rwS_5ktr((}L^N<9ZQwPRo7=RD?VxI7YGw%oWalW{3OxceM zhsm6Tty?fTNtNC;=kd)LnbX*ZqHLRLG8L{ zk2x+}*~jH<-Q!YLo#|!Gl|rh=n-oh@&u3*OhqH`XPu@a-Xc)BXnifToy1Bp3tJI9# z3H~V+**LT-Pq= zq&t~2*lT3J?_UyFMnH-T*;@rCnDUyEX9eFFOcBk(d=69xq=aOBH(~D1>)B~Wq8Lo* zUD`Ys>-n;euuR^w%KUZ+Mmv=WwqU|9jtCA8kd#UNToStDrupnJ5T8DoMDuO&1l7V` z+|!x5-c*X#MDF?lFfd-)5|D!YGQOg)@I8S)C~{Z{3rvWw;~P}o_E;P%`?hww^w7D& zHvu&Dg_%@?(NbfsFJ3AJ?p>bG&dHpO^)oR^5JLk+ANJ@;xa;>9)kMM3AzJ<;2J$o_ zgHM*V~(RYge6JOmMp+sk!W*P>hU=%4Avg}FK4d`cOp8~Bakh@i z`JJPk0sHZ{%i-6{=FEy_(N3`a^}LIgpA)Cy$OMk~$itP|y1`kdRDuto=_~lH4o2tK zfu;2sviSsabMv{f$<+D;cd^1vSPzQdSl4%FsLF>u+o$!59v9SeAR%~&G+Uht^jbtT zM|`&xg}|U1>?So8Psl74?`FxxVq;F9th1z%^g4gy&$jxC6#(vYwfXVG`Nw&&Atod8 zTUy0C(gEUUjPvdQY?1juxdCO}$!8e*4Z!!bmAQpEF{?q=fHL}&v=EzLnCCGOgC!XS zx}v4~4+quTnVx;#hn~&Xqv;8Y-C&vdN<+_pivsBwZ;Q3ANwJyC^g+*|x<>)Shg0jy zuDONoq8OJ6!rw9UH(q?lNTl-0n z=j^SD$hGMhG43BI=^^pjYjZr6>*481i}uT8hQLfo9Wh#lsKzdMykyph;`5kR;8p4N zNB`S1Cg88`d}ys>J#Ed=9f_)f>Pk%9kfAXBk@z44!p&jA^|m^^gw zfM`M2nUMyfqhw5jz;V$Hm}o^&ygk>lajBy;&xeIrK~x_Y!A^9sAc`;ZoM~=W(uu@) zO6^aX${V^*);uq5&B6K+@&`s|%kDd2G>j3-GWhvG{^1MGuRVD|hnWL8Ud~6*3~1rI z`p(>xAuH(=Mv~ldf@CJmU<#uRZK^7EK=k#=i3aG}3T3!iyrLAw_>CUtin`OmOL-{f z-5KYV#1rS+2j-`H^Ij|j1r3~*9k$oEuJ_syr5kH&_$r7^x92`IzMToGXUulrrLb7@ z&A$9ejvy&BGh@DUnWdPK$e}B%G!e4WhGVB`J1$yQPQmk3Ol7|~u^O<0`52taVcJ)^ zkSkUp5i5R6QaoS9W;GTj+_7V48XCRdW)}vSQkZE06=Qg9O=OSU3+(godfdAQNE(3e z=eh+4`-TX0qJN%w>^Sq+2xZ`UAj3{-mzK*5c_YXQEQNf?r!^UW47s^68fIi3&~R8C z9s7}4*uReSif)Np9{rnBh*f2$nyPqAZqMPmY;Hr71)RF}aJ8P%G9HTkM~$tKVu$)l zH9&4h*r7xD+&&w{k#n@(-XI7D$r!)}$*ugK)Q>C2&OG=&Zoq#_OCL8@6;31xdv#D1 zKF1>Ar|2`-#eAv9izRt~*# zo_N%Ayw0edP(?>0YtZ{3_-NgMB~AhwM-er$w97ADhSH(y*SK8yDvOD`*hW`N$;(dX z6v!Rmhpik|?+UL8uoUCjYg=fmXu*y3OmJ!R3}j5)hPPr6EQSjc4d&wDbJ@4&CH5qu zM|P~FAn!JIJ8;$I297*Ak+h5X#KmgZ6$DJ;|7Ab-7|)UB?8{$8Jyys;)9Hxp4v zpZr`uxqL7iUTf*+e%o0k2o;_-*oP4#(G0eGx!!tnl}TImvS!rM8gq3U(|yex*37(f znPlN7a>v&35Z2x*R-8E9kOOtWcd6;ZHwpPK8y zNtb8UM!{7YsTPH@RW+4-3zOW?cUFB!jiae51jqQl7j97ZSN!3uaSGx>zW*hz^sUHn5^%nvp(CX|o(UWIz6CTWLnBM<_C~pz`V69%QF;L^Vqi?(0*~t~2w1Hai&=O%;!bjO^7Pq7=_R za@3c5TvrVL#u)fpURRekm}WZv=O-2S`>D88xfrfU0j~JV^H#DC$_SGx*!+Q(WSQqO z+w5F=P0SW@`|mBKdDztE9|(yt$(36s#eHxNO{w)9tPlfWb$c7xx8EsIC=XZi zp;B7SMwP+wN1Z9IREX)}X642nRB|TN*cNSLp;r)-3OUqLs=3f07Lg;h)7%$9OY$Xw z1E_?NkNKI`rJ>hPSCHuOS^wmm$9^<0>SS=x>AEAV6Ot5iqZ8OHiBh zI=RSZ%_G5TQ^facjduV+3BHSj(vb3o3sdjXQ==LbbGkQFX8&GOgZaauYp_Flt;>)q z?A|NZ7VOj#$g=|D7gc+a(iu2MY6QhcBXD#&3JU4-$0owVlRMlJt|QaJ{-`zDa#px_ zT3F&1Py+O4QA$neZ#0vsbp+EUu^N3Mnu44_GS^Sj{r=hie?UFB<>qp%DJqyrUf(Lw z*qL^mr(G`Kzt0WvsQf33vK^NG#VXm3F8&HEg73HmrHKp@kxCw!W@~ak4te57dxi$W z*aliUnt7tf8h?@x*jwkt>mK(N#-l*^8HAjv8?p`bvXUz0Ic#9}O{t!!_Gd@idMt3> zuk_2*apOz)ff+gCosN(GpT6(vAQ>bNR@dY-5!q9~*ayimud zJ|Xf6jE`L_I6gAcZ_Slu=s90_j6+z-Uw^Up{ zVfKDRa;2r?V5Y^zQI2%|E$4~8J9*?@G+j3R6qV~@#wF^)z*g;-*{sAxh-238YR-aC zq1t*9|3{BO_j_rSdY@W+kv52>xmt>e#8ilAcmv-(Nn(DELw}=NcNG(l^*cRAmrhv; zlk$VosRo*m$Ek5|FmTmvCpvLzBF!CzxH6PX$b(w|G>g0cY%VW&ClvTJezl7^3Oe~% zIPG(xdgQUyDe)CKaY#9=x0A(gwJFi!gzkF0|HE3p)v-2qEmY@3nCXjz^0Z$wOg~H)CItD5 zo$hJ+??LAiVwP?ze&DdMh57kK*SWQX%d>(9`>@!U4Y0lcO!mpY)zul=ZsB#fj?vIt zv-MvtKz&BniwBWoZ2He2oU{zTzcB@7GHuw*Km^XxkEA3l?BBJnMEV1PP}*QYHn!~E z-WmRk+l`-9jVJX&1|5Lq_en60=ndOCwNi=$fvQOhI-#KOeMMhr6D0&MxA_p$}X#_RS z6VU61HhraBI9Lgiit5nDri-V(gs+p(+8uZA6x=NH=r&|bGcI=JLc%!`rj_i*_ zDbZ)^0C>Oc(l9-A^n~J&?U%q7i8&+mr3swT+U}c;^Z57n@?2cIe!=mVV*j~M;>m0OWbiP+aw<%b2${LSAja@zx;dS6ECi>-XX?B(z6+< zeXG8IZ+3K;=ElV5x!5y5t5mUsVf*@4PzIg4LIpPGATj`mdnP7-_XSt^k?4q!NMa}_ zi6z(X1?256Dh3!twVX_uzCliRUQ4f_V<$M>x8Jp{$%NMe!2=jow>mxWE@_K#;S#q$ zSR6|ZnU!BMIhbPS&M(u`J&E~!@s{#re`+HVW}gmUH=(?^j3s?^q%$_VE~enxRH;vW z+%#%kGTB-@#`xIYo+B-CdoT9%lz7^;6;RbwVY22(N1uZdy`4r9e~4u%8Jm!I%AM5p z1eackHmmje$Q$Dagq~Wt*o=|8zYHd-03F%;+{1|)8kzao4(O@8iW-cTQv|zS%o2^0 zZADMp);im#!-YYs_AFQHxbCf)wcLmL+xwbX?b*N0T$7G-q+eL$44tGU zt^QQA;j7(eX4rlnI(6R_%9czKcnDAR5oP#eVqY6r=ENUd27Z;J8f>vey{94S(S3jS zMX8{=Dh{erJ&d*nN90ikry3!-GirNAz`THT>qq?{2)U#IKI+ZO!;^pu|LNkH$EjoN zl-<$Nfl=u-$SJvEbA^V9m{mWy={c1JC;Kl`VmT_0e(_8h`3j}xBQq>Kyy?xoME-S= z)v7W9uO0)olKtUWbcojsaz+srPsZtV-m~<7fe?eUYnIK z2wkMoL3wSLwk53oe=+U+iz|MkB|{OQgyueIAMRm3G_ODmb_1K1RVYuIC?~ z8XqVH#JBF_Xn(}myOH$_Ea^>#q zj4Tv059xQT<#3(QKVot~+H726ce{=>QyLI;KTW0Qg>}F>?FbzuE6tG%glhx)SB^5R zV5#{#7a{7Zb01nI(KpO(=yXwkAK$RI|I7+=o3l6~CMG~PUt2?Sw!z=%}6`wj?sQza@;`-9iFhLz$**P!m zyy+8`K6j^bu_g4hDp`v9`m1orDvzF{H}nv?*BM#TKPw|1 zu8@JBV<$=IH463M_jBQl_&bFAgG$K$`3($wgD2r%n}QrzO#qe~`k)JSUzBqXx_`%q zKmcs<|JkzadFo%}_c|+c<>%JlApP^| zgE7w})ZXub^$O{|#OS{@QI8alrR1pxl@0FljtKoL^uSY|@;z5QSJO}W&P#R6rujRo zXWi*JXF-W3J)_cDzeYMYEs^QJJrW2QWgE=d;wHjNS=u)sK}tNSK-TIR{hYz?G=Bfi zk~JW4<<|qhOyQf@^d`f|W}6w>E~7@`^oh|Mn;9Y@55@GRg>`C>+ow{qR0F;Ej=#1D zdH!$(DJro(Mq4!c^DA(Uu2De1n~T6pN{rP&F({|*YxJ!x9ZV9E71}ccRLI>#YO{5b z>VIJKKjT_+B=WtPBj(Bp5b%idf4a-{`Y~GF?tV5pr(*=ybG0`Hx?7=olP!!b(3f60Xpc&f9+n&dj) zju(^TdllF^^-sJTbtk4)n%;a*O$-^@p|p5?8Qn1o>`aj%PaW%dc))KAthJsg<;069 z?(G#n_3+i>oqO9nCiUqPgOCTchOX}N{@K}5eOUMNZ8ft&Qxt@pmLcMcUp^;qt4loA z^%V089y3~M1rTJjee8)ZRM1<^%2S`2<#C<|XeaqM$2YHo_+mw0aR2$K5|4%K>SoH_1qdG<`_A?$iXbCD>I4YEsl)~WIjg>Nn>`-*F$e){@ zAFx&RnjH5M179d;XlV2U!MN*wJMQ>?qSG=)oUz8? z1FvVN4FcG@`j_2LfcMvCv%`|o(Te>WQ?ZRF^Vh4_Y&v5tyvnS4+76qSN3{5Mb!lh2 zbtW(Gc+DH`5PGp)Z;_LZi-*o zN85#tUKZQsVjjDNAFrKF4kUTeNo#nxxR|FsWb)FaN^EM3;^;sV0%M-=fEIB$; zS&+<&`yCZk8QIk2qzRBu0kqkt(gJQ;LWLv{C;i?da$C1g1uE|71L(9i)_!Zq1R@2AA zJ`*Ve!@mX-TQv=o{jvTSa*K7zY|gBRP-!b<&Z(TJQ3Wn?%%9qR z6u9gxcT{R;gEUoDRqX~fd}*^7HK}u!UEyc%H>!J&++63h`fO>k82uimWh#@spnuR( zXGJQ=KWu*saXXn$M0h>u8ttD6!T={CP&_h0#l>L>KZxB=SL*3>J(yAs4Af;*2JrsY zuHHV_o{VaBfd>?!)07yajPaVQ!himjpr=!NMR-HZ7gyTX$FSZWX_3ekqU)1!eLyB0 zBT~TEX=_=y9M2n^JL707uBeKG(xlOmOa^&w+o%Yh*dJ)$QoFC%Z&g0FWefsckH7tm zME1%Rl3B-n&p%1Wk`EkC=cnAS*1H}3-upVl0oX_Vqv;zj9+v6Nb7ik{260}DjErq1 z*eZC-KJc@X2JKKO{3ha=Lt;iXpKnk28szm7(`vRIcLpaQ^e7>x^q{gYg+36{l8|G1 zAIhs&2pS0=hr{s?OZeom$K-wBV_oL%GR#pNe^^*{$G2Qct(b1!*oEYC>iYio#pD{^ zHZOgsJUzJ}&+7wEjbWBAJtba6qBqQdYP9NNGK4U(woE&t$vy7OaHWhsoT)V_Kh;E! z`Ly@$aPesz3d?|hs?PGH+Hl78)#=@H`Kz`B`5PrM23ne2}u9cwY%3)*6&wm9<%5Y-mdVCw%oVZZ?> zO=-}wlbx(pwFhj@Kg`hhu)Bnks{aNEm;uZXk0w%(7O+>a-o`dkuob@D(zc`_JYl9F z#hP-*L6~Vg-mSF-X2GwTR|aJ3=)eSXJjSFGk_LOuFYGHl`!UCwwr9ogSS8Nsp!31C zcWrIxDb%R(z!S*m`1N|um$!Oa>Vr->NZJV|=>)uxyH$|4b|-Hu);+(zOZPmj%HoXv z=|29WwCn}QO6xE|93Pt;8@8VP>l_$GE|5}1OYE|`?kt?V$=|g0aSp(8s;lDqfuD-j zjn?}tqRps>*uohP;C4bAAef>MM-ufDoWgDftF|0ESb%i_m%Sj(A;ageU3{Y-X`jPE ze6&cD1=QF^%IByyBm2klFe}B?N>v-Tv0H+Jzd%Teqt&-QB0=ypm*D$y-Rih+isgT* zNGebvAtBGeK=PYUwC~>K&7FU3wm8wAOymwIQikkO?H*^Q{oz=ZzXME6D52EU)ZEFl zxqNv6mQ#+2O_O$hcMLo{iCH3fh)8Iqch4l%;QIO2++50=Q!FXlz%JMI-uWuq< z>78M*@mxd?f1PJ?oQ~}j;8oQ$UV04_k7>w&2G%2TMLG{s-yr}DO2`v_D*aq%vDfd6 zU<&XQae*fZ(_yn%E5(G5&J_Q#F8woC15aa zL_>$OD6M%(=~nP$D)626;tXJOktm%RQxnCqY2C&pY27$B8S6}}`wOZbhuVK62!L9s zq&XU$-m>bkGOT!BopB;GkGG_;SH|vyh;FyS4%JQ!%Ihtpyk9K&^o+ha?szwQ53S8) zMhEEQ>=G)n>bayIg#EHsMAtH^GWs|uc~TyTW=O)yx!|>O#wQ$c@|9=tcXREPG5SE2 zS8qeKFPYt)(cg%oAzpjk_o!mezVd)sDrw%K01Ka0zaJ9jr1HAkrcVXf&g~``7TdWJ zHMS`^wHzemHLSOnnDVOm3(Yk*KYtZ}yosp1^&5z9Pm?Pd9!BJKcq#Rc#&g`vFQh>Y zyrLM-+*k5IJKNg4Y`A~09;5IQ=z4wt7y)++dUOYZ9;5b_uL6f;GA-xI7UU*_Biz?5 z)K$E;VXMfnpiwEGZ{UjpTfX386CE?5`y&2n-^)b%z;`CU!R@cMy!3212uP7zY}q15t6+?d?K#_Lj17j))c| z;59f}M`jBVI>)BEO)H}NhMHXJptiqqEoSZ+^zzXT>e*<14{4~_(ekPIiMbqIB1QA0eG^ z!CEmu2@$|;5#lKM_9jxk*6umtbUm8sD-;D+7UQhYdfZ;E+tCM2PpV(h>zO7m2R77J zXeRP(6hn<@LjZ;tv-D^!Hit6~n*v*l3L@&_l|#`x>A~AdyQu&4bMtev`w-x(^z+`W)I2 zTj}o1POR8`)z~?97{q4R7xjZ$f2chbNS3_n9P6XXo(WpyTTVpwL9Vyv z0%8F`xK#fgqBP8vaHSR!!izSy(+}pGc?rT|MrFFBf$l0S0S-Pvk-=s6#qnIA~qT`wsg@6@$vO2Hr7wc~5 zPGy=OGGrBwieC&{w;pDugniY{o;10Dv&)_x?XH)`F%)8xk%>(HwAo#teZ9NlqTjU| zLnujp$Fm$Ms~HK=qGEgp)R}_t%_fq!{5RwnB6X3MVj@57BGeAa#!@^N>IRb+B=Tou zLO-5A`qA_)ux;mi8f~3RQE(irO{ECoZX|xhsCCpJH=tb~O*z*k#8i2n92nnd2+5yL zICQuflBFEbinnCdWAM0vC3>UlL(1Sw?Y_l+Vg zly(Nb$OUWeIeF_w-3Fzy4=9>P?mUi@63NEB*BMyAp zzxp|BS`>Exu3CcyBNrjW3!zgUBwl2>-e7{i>3H-^BvVmOJ2W#dZ<1(jZONSNDY}r2 zjSYlt)MP8v1<6{2AQ+JT_aG8l^yJQrG9k>k`8flGB96eYOyfW?|zh~b#ZY~LLO2B z#*zd3<3}ccF#0<071VGoytntmy$LUrnb_HjW@SRh-8W-FyAx5I6YmfE-f7Tl7OIMjCQUxfOas7%z}GDMJOnoR+UQ`?0yuSyN%=E zY$a&RY;L(kEaX#JW!YiPL&)v8dPiJs?QHdxn+6w;*2gNOwXR709y9vsWktxHm>s02rI%gJ6KI$0#lN_Gd^hS3PV`F0W zTwHV}2X^1Hs)z#lEXP|?(;eI5mg+5m&mG>vFNlC;;i zswyD?nq_^e??orVXBDyZsjqBZn>RKW(mmSci7*K0SJN`Ddm#j`Uxs$J?z_7Q>RYog zw4)1bdpX_f*Mq8Awt*GpE9H0;GT2a9)3=ciU2`n4c z?^-cy$hXrm#LQnN#SxD_5bW4^IIT}iYlQ4t71z6*sM+I<#Wolat@3^UdPcJ7x67Z3 zuu#uBC!HM3f020%$;lwjbLo(9E#EaY+Q|7Wtql5z-i&}t+4tvO>{C3PM8!@bVh3v9 zYgg@yiML!rVdELS*F!7%XI_-SOyWElib}LBOkv|xVf-d6v5m!9r*F0^yC}%M4n$IICFRv2b48j+O@$#=jwJ->_ zM6b!jB!Co%?R2OCql9BQS-KlTC(#GLNo6$jVze^NwiU=MMu(W}W?bjbr`~cs@ylRX ze%}ZuGVR(xK|?%N^5c83xMt*~5SWapka+fEzj!8<$gVa6JhJ^&9^%SGT2Ya^!~7_6 z5#)r@b27K5``DdY848EYL1L3XAXOHC$;iL4W@5#5&cah`PXFsY1a0uXHF{mpp5UPu z@TT+d{ge*Lrm4{>Vm>%+f-yvmL05RpMqPRH0@C?$8{l)+s@BJ2{N{xpA{m0Pa2$5? zilSiqH`U!hbiNJl%;Dj1TasuitAoqiZJG@JxQVmo30nG+Dv{LV-$AAKN)~f~3*dQD zX@?NY_kKFD&BBF!;CaUrs9NNENq@Tc*{v9s6BOd^@_{JilHt~xC&kvu3?^aXa39d@ z2!p*$n2yc1TAbZODX8(d4Mz2*BSA8`gzW8-a24+kf4^ zr)bhYg$1y&Ir7vUdP%u@zbqN*QK{b8M1oTkW4ab9@_aerae9b!{yTkSC)6vMB6lxx z3n$gsXlk&LX)zyZH}VB1HAA)bM{UTEO5wFaMCwD9R+6+wYuX5vL&Zwtmuk<}5BWoc zq~cCgpTCq8l&%^t_p7weJNN8M=ik$n05HC$*W9br&*8C2KSpI-f$6R3BZX~MXAERK6mGK5O7?q03cPObl}x? z?{4-hsaH${F1Lk((b-`=EF>i{di^HoZTo$51EjZUmHV4KP#8$GYZ0h&Kj==rhTj1N zd5d5F?jL#6nhldm13ow5wuu1Uv5-|~{XcbwLYaFeewSX~q4FsLzqK+nTnKJ;!!`BC zZ7IBZ)oPtMRDi8bt+pxBcX^@?$e`x5>r>}=;E>=B{ z^tNb9sR$``fnq3K`oR;nX7BY$vEtS-79k&dn;VS=MA1^!JEeYj{`{sAKi}FPl)irr z0BoYRo=s;K&SZh?ecz;ZK(Y!VV;z7gy$c@T;EE+-T1xkk;DVv z4l#zXf7WICLEpeq$!uCCeYol*rHii{mn5syjyAF2l;!`wcC9;orCUH_C^l1r1Ao1YrnH~)%$9zu)% zjGc!^bcktdeetK#6#hE3-Z?aFDq7tJa{6xvCHlZt>SilB z2!;-OE!<^6;WVrk{HJJy$Z<62jbZnTXVSeblRq_BrGC;FsxUjue7Fa+6#K7`3#*&| zDd$L=DEpl9Ie;z$A{HHsrvnFOi?)Mix{>#bZI69jrnS{F8zn{80Sw_5Kir^KVm3ssCG^ zqWyowy;W3P-O>dLA;H~)CP?EB!5RrpaCdiihb9E~;10pv-5r7lcXzh{jo;2E=bZoL zj&YxEKkPAj%d%Qht7^_#kpchXk20K+0RP1u`r8i!7^C6HIzlDR3DE`;yX6D@T!vY8$?~ubw{?}W7T}+eoo{!YB9=%L<{wL97 zcGfG4^oSDZ;6;h_UTFg{`lN|aGXqy3UZDuqKJ#`}5j89fl%~iaA^h|%isJIr$XXDi zE9qHY$&i05=T}aAK-r;^x5$4RCQh!Wkfo1zlzueIY_cB#d%s2fJC1n7n8*jB>=DAe zT+Xh|TmFY9gQt%uP^FTT!#wE9Ix;ID2-MlEgKH2gyU zILRObVTgQl+M;hkdZmFnT%|u{he9+4-=>=ozP2(uT;@@0L1|smOz(S#Aif^6G&AA?nhFTa`EvJT+L$j#u-d>Vn)U2_O&$n4r*I%}9%0yhe% z#bFz5o-yrR)yHycvruJoMWQ0pO7T3Zoz@K+Yc-Ry{rGr%%4e zlZpZD%l83am4+&V35B%i%Ygr)f2$`{5pE#*wh?J5r8|O5X=$J^8eFAEf^@dpZ-IH#nZiXcx~7 zN6FIy(XVQtvrVU;jgX%_FC**F&V~>A(}|7YHq{u=^E&_O*~J6s*z1jJ+!8~Gtr{b_qmk$vCS>{wo1a0uT` zhWGuHG3dEm6x*aP^RDB+gHk#k>63Sht1j#7{a7i@xWnZa4ZgcJFpKZfJTTv9%4MOd zG1TtqiX3$P877=jR{zwuGc~rYkLEP#ztY`z`7GO-mW10YH%D)5>&V{uSmYNxcM9N& zR88SsSQ->Qb~R$|vI(yN-)E>fKKgnK_Q#wRpQZkj!YB_wrQ23P0T~wC-=L(G-1K`$ zMXspxga1nKt{3_hz_I9NCIxzYoc&Fc6R*~&qPj7;p^hfnKVod(f>kr(%Bm%8t z?*w}r%G-Q?M$=RM1$tnqmoE7>=KXmUH(b(~jS#`&p^j*Sb(S$Xi`5_cWnXRYPY3ZW zw%j~KQJLm(C)h%>0M>4@v0@QD-I!QBMR#Tn#@U@^{eA}B&zX1&7#TL~F6wm?6Y}ng zdGW9UuE5Vp_W6g-DC}=K(93O^a3W8w*Vn~NqMyic{+eTI{b!xK6F=j|^_i+7+>G!| zOW;X^>$sR=I-AM-Z`Yt+>pH$M+S!-1vfC2-KxZyqyjL3yH5=ntv@W&WeO|hiTs2b_ z!<0e-m}-^t&4n(0PkyUuxpZOCX?b~53fwV|y zJ}s+{6FXj?To*75h!54)8YJa%mW)C zg3R-gA;OS-1MM9PJ~G&a{PZ&+smBx-L9)dzU!VKv(FM(#!Jb{^Xcr;;b#$d~4^^D@ z@!@zrH~o;@&Z1k1#leNl5~w2cc&qU)=^`?p`^jNKp~Z)-^`e%IGLVo_CXZ|Sa}CPV z^G5>0<%PP=P+|tN)F~4(yU&c#OyPu2+LYYnfrMhX6Sp@txdnI=$$Zs{?iPqr*E&1g zIAtOSx9-J!GXn*~vIM<_kD2Z@(oh%#C0a#9+%~7q5w3~Y16yyqZMx}(>SJ#S=TOX8HIv3kAumcW_7j3pApse24c?Z zw%9G*J)dUS8>%hrM!3{|@j6uS4K6KCI&Qg~OG_H4Nb>X}0O$p~woO;F(5iCH^w;%t zRr`G29bKMkH-A8R4RJa>H#fVs2rr_eFlnM556$P|fGzZCt3>;I4KgjX%0X2)DWW2Z z(q~yxkWm%NMhAqyk2CDoXZ`{0XOFI;Qmt-no+`_Q4J%58jcCM9Fv*V6sz4=tXcUzj zRWnY(e~~A1vl)u;skFB+aPe{nAYR+v64{QT6W1Z2LrU$88+9P^dx*~ScC#DRmE>qA zjQdhMA+5_-Wqg!JH8Gx@q|*iZ4x2}oI7TscEZq!pr;| zCI|_<#}72~bgzTk!P4=4x>+B(laT0hU$ti9=_!!k=QLt}FAwCg*zw9nqU9Bmu}}8| z|7d;!#R@%2F_@lR&XTH9%D8=LQtS`CD&YaVyaCY;mwJF3;bc8B<TVo10#>!kc~j_M+{a!2T?k z-lq>9E43eDw&HZH$iXe{&*iJYs^;sqV86ddV!GXX$neNHQc%V+|ER1Ys_+5*%BUvg z*KcMF;!#t!ZhL$_A|WM|0CtpI%RWnXEcmw}2RyKl|2rnKI(BOt!5zPA75y3M_wYa8@V@?{zY zH%xM~5KU){FQqq&D$-W(yP!nB#}k(|HV)IqSULp>=uCKyVmJljCnEdEI9W70*f z&~h;fkZ%S&ip_wJ0nES&q_flPV@l$d^_=-r?0_y?%0>R)4k94@UWtTrnTsErhAF5ZAiq-(OUQSyDdPofPFu4=L;=|kK$;we!N=SC7vC-CD6>|HCim8D zH8XFfdRj_U$9w12O}E&}&hcTpVgtea-SJ+ixkICe}P8z#W}HsV1t z`-&3768kTm3|k#SaH`5zNjd}DyXRM3RXA(F1ednem?1s{K_LPw#M>I9GX6f}sserH zc!Xw+MjP{bLEgI+O?G^s1-tUx)dk_KH4i2Mu6gp$QOP{#&pf5LO&8WBxvo#*-e(c6 zS5a_AAOf++OV)v__D88EDktXj%Vv$cxxyzc4WxSEY0OB~3T=Cq^-dbQ3lG_Svpc-$ zhZ%8e{M?}U+4o~8jW`$5*(e8vZDpzZBbwB9zqcE;h8%@#7AE#cI|nqOj0l)sjW4^D zyXCRmR`doQSYTS=c{yhl6dVzTA{p9r*M0^)GsHKhyECTKSa`qAjogE>QJ9syeLh+Z zyH>d_&3FCHf!Mm1s=mpk6(fs*2jn7@3w)h4rvUH-%^Ar&ZQgJmoL>Jh?9yk}XG?XU z26!H)Ei7EXsxfHv;HWY67_%73t2pZn8h$cjO@VBP@-)qUJ(uGrX%O-^=P=O!4BHN5 zfTMCjTv0{{B7d^b!+Jd}q2J0iRRkNG%$5hPaN+CLoza*rUf+?uPf@gE#Q3Fc;4Q*2+_nW^Q+*84OY@}C5 zKOb}q1bE$49gklCB);Y}>k}NpLI8o*@)%+!_AHMFO-n|B5q6`S=KIF|hHLe)s!g@$ zk4perJKN&z&gY`cIo#Kx+!P?g81Pmbem}ZmH&eMQvo8`!y3EJUblw2#k1|HT=Xfbn z92Zs|?B4ED!b>ig4NjF-o$THw{XNO4kfsuuw?JO^JF}ng+N`@QfH_q<;F|%n2s6Gj zWMpQ8NWBDJl3ywq_jKdO)61+KYuJXyhd*M5UUb8eUKWm}-8Y@U7-C`4(9=_3P9hu= znAW~mQGu*#!5S&?pzvZgtE@?>sf=Xhz?!Ao;!XTz{HPPD8(8$@ldD^ z013#%_<~(pjt%X#Inqz!r}SZs^c5gm3T5}v9UQ-5(WI-pveNTweQzhCPw7FSxjgf# zyX|8Ga~k2eG8_UgCoWof8?Ld^Vhol`UV2nFP4LF~L4j*3PIW_5jfa8C!{y;OlwB8V zf<ug6KgTy$3F&b#X-UXzHZwK53_|%fi;~j zsnQycs-J#VC6AnaTN}eG=3zuZ#b=>Vf*&b4Qn$kCr3DpYw)YZ8H{JBIdP^pBzHN<4 z1F*z$FTvuiV?i1$zvfm%WK1Z-8TGmv&mUj~p3Y#rjc&@li@~%V-m|kSmyNz7e0o~` zC4@!A+)&iiVHk0ftW}Dc75d6!_>ov1pZc7ct$GVb_WPzM@qWxY704TymfGD-tr6;f zR$W<$EDUXnMGk}QO$fTuTq7~^R9s#g*v@`iOheCYRCMj(zF1doGfKec$HO^D7tBp; z97)7>z~2)-+wJ$77OSr09khN$lTyabPg@k|;uRc7MZ&b|pQY)p>qRU(>Wa(QOl%4^BPMvh7= z_4#d1j)v1V@a1@o$ktJEgM2(XF^GT7?x4Uj7VwkH+7(;$vx*8bEOB^$KvYm+E0%L4 zWjJokmSc9HKUWUXLJqNcHq@j7Dk4*J{L_;+&k`(bSh6%AhZuFH`_S!E8cMFioa|JK z{@9Y4GsNCBrt|Fl_|5FbCv0;^0G@|u+GS3+Qb5EgI7y1pF>2Lav#N%|jG7Nov16Uj z)xcDbDU##^G_$BudP!A-*_+^~hZgaSPp+1{0)FT)fekW8)O5e@HC}a*Zhu)8K^a4Wp577&9^r9U86PAXqaC6hMmeeWa6W(eyfJac&&o*3Jl*} zIburY+{GunlY_6xEjvWk6PV~B0O7mqj4rL0{FyaUxSC8lB73u3f5nlGFf_xu&dN@Q zA4uYC+KbXbM$r9&PRikvy9=${8bS7Uzvr;&mwsdxqZ)+HOUe_tg)XJ<}>7blMT6%M>S*}3Qk=HgtU1q)DY15A%Tsb@*~so2>*8V zP4EJ@r2zJol5833{I3PJvxIdZLpwWjm9Lc$(Y=^C@2S&v{?pIZdEmWOr)5SFU<`UH>j;THCvo%a1Gx1Brl`gp}86wM}KygomDQ$JTnw zuaV)&!R1Y~_Vln?18VDc#j$vLX`?YpSIims_A_1G$Ep-JO_wgg$poh+-sYq%J9D!g zg0)HRLtwM~=2)co!qGeC?-@x0Zn$D>gW2d84+}MgDK7P&2fU?pIh1Oc1GTQ;#$CFO z&D8o?=2UOcyhu>AtIbjA+Q)#a#=E>yn9V&VJvo6z6&AY!!(QfdV%GB*V@LUtWzx0i zkz!6pR920RN-QU2lL<-P(9pg4ZCos!OJxNAfE0dOd?O(0|olDs8EY*yv zzi4`Kv6pm&$GuZ+x2A_eb$3y+T2L=K$&KG~HzNw`uIQ4x*qe!I)Nak95D|*5RF$Hq zBiCACyV>b8^0kpNd_ZxD)&8!JuGry_MR7@(AuHzO=Xn>1E@NH4^(BPrisZ8bc7z!Dk@1>sDWWL@ z>%&BVdUek+A6VWL83Q;t-<;?TsYKqKSDtG*cu z<jxwn)hBQH3JTR)Hk=?Jq5_h)Ckgl}tRj z+d04yv@)TU?Z|%24!TxJ%i==0JhMOy>H1;bM#6-9gT{W4+MgprIWT=XYYQUw#^ zevTn}x4P@=?v2ARtNqJxD`}#ay0}P`kC;|@vw9hWVQs?Rr(k-F=PesuTGT&oyMoJR6M^&smz_rEJ=e`oBEN#3@8R$+d1I9u}PTE z!EOrx2G_7_qhvxXxv=W2yJIhlI(6!Cxy>PELSO5~Y9M3RBT9R`(N;~{+ZTg`cOab! zUjJh@(SBa_bW+oi0@o6DK@K(n-o$$r(-+2pu5MVS-5ZP?Oe6J|exk5#)XSUlwQAwv z>|7QCg$Oa_T#FCvQ{Bd_ap-t*HFOv~7}71!Djbs@Wr6Bm*qIHe>86D3I;(kw#@}N= z7yy<2A0_x>0A*)nSeTL$SzU0R=6!2gtp<+mb~j1}JF0JFF>Z%rFVPu$!}Ud)E$Vp4 z{Q4*hWS`iokv~rgAPUuI?!rTg1;5-}tAAoTjQ@Hl4KYlIPk!w;2aFp4=(@WTxEXER zdBS}=c9EVu^w?e>_APH`vSeg|EOjBWl>!m7>`p-xn!&4Wk3QWj;#e6J*g0N3NcM6k z2PwlgH`Yv%M#TsLXKu`m766xIR^#L7D8;=3`*A?K?#izN**X^cDi;aroD66X>ore06xDcU-On z%VCSksK@j@n*8lhkWzJ*wzu=`Os)>5Tve@kCO=A9zgd^G+V;aEq~VnjC@h56`UD!l zwNYno8WCPU#jCXhAuD9LOu;2jD>Vt|<(Hg%SYPD$jqAwUIW)4zwsUC=0`@Prw+W1Q^;_0svIvSO$V2m$8K|mk8EyZRmNMDwzdo?AqoQ03 zTs5RzJK8A8dFOm{>(}eIP9p4Nc6WLDK2l6tEJ1>T6?{*zvR$J3a<)g*;SqzFcYT7A z4*vtzzwq27?JIj``+JgjC`+NSr3*4r2-BK!Sw{`<`~Q+g`Xn{-Z(o26*eu7rkiMoK2DR_=Qb9RUcM4*2(F9 z7Y$3}q36?C1|YEUGcS!j0le7YeM6v+`UdvYIAe0Ud4mY(;TPGNBr!O5|E{k3tlUy} zWm#;Onq!cDp58#k&*%t-4WjlKA{ygSDh4DKmckmv2y#}=2Hr3Bh=lP2h5AEoXK~7@ zdMLgMqFLR6n>?E1b|GPUuhMmvqoUCL0%dJe?DBIgci0OLMGEID{S@A7zn>os%b_;6 z+(_6n=nkBl7+3{}Ei z71tscZ-32BbVH3svp}15kfM;B?nLYKl7?1Q(m>stD$;*F9}VW|JF3EhfA<+qR3QWz z6cHM>l@qUgwXZAfyas$)C0Wv1wl6Z>VnAg~;YAz6=pPJ5i%Cz17m>$dE`gN>kQ2b{ zPnjnN)K4lW6dA+?ZPmiT6h-ngYNhbu15xTZF!SGF^2;P^5cB)oT!lBC9?@Ix4H3UL zW}&5LkTAS1WO(X5zpXC&5X$LBnm;e#@i~66sLEOw;^vA?=3b1;QYF=gtBOtd@zv|d z%1ovV{aCTqaVnEtLl1GyF*aC(=aB)!0?^TLMH$;qF@az5;q4laLelnYJyKcn&|ggq4y-B9=mghK?afu&ASiVljGr_5uSO6Pm2 zt02!1(UG~QzGa(<$a^^OxA4}Nz zif-3xFK2;fh}g%aY*tHVmWAl3)E9*U>W8A+E(S(krvn}Zr;>?@%^11Jz1$W;)q&Jd z*cHhA2=t7kv6iy9w1K9tRiSV3ary5TXW50rXVS{)Gtcw5`h~)TDZi+MMyvfWqZU+< z2<7}N_jN#NYVre|c>DOw!S5*G5YOiym1aBFT<&rlQOj%NnF>d~C^l#GwrMIV{Y8Wd z%=(|w5Z((Pyg+5khrsi=xVYqTZ(p?VF1C6xg-VKF{=Us#$mP;h6L+#>0;o_DAsf`7 zQV2n;>bWJTWzL-MITHPKLCqb|*Cq6-l&D13-mW|Xol@k(j$0d#QT3P6b`3Rl4}e9s zsWOx-vKHC&UZ86)!pza-^&p>2JfG^Yj^60*rNGI4`_U&dtS0Tu)v~Mkr2OkHyclzorEO-44yjS7h#4BE2w`yFM#K!B`HEJhM$BW0Pd{@*(Im{M;Kz_ME$=j>n}! zt$2fHJiV`5%pkHm?o(D~0kMiFAxDf>7gm}2CET3L1W(Fg&=PoECN03KmXFwP8hg{- zP@46zI_qqm+ef!3ZrmFbI)7f-paw)<$>12l$_>fZMtDMuq7I)~%>cEm&~HF+rIqiw z->v?<3Y5*{p7C+qyqsjz>BablU!@QAyVk1o=KA5`OG93BQ#)5oTH?fRV*a!Bit?a< zs&rwDyla>?qVr?%9F$78VE{>Ek`4z*OxM<0B-hYfRndv}ypE!_fI9Jjma}~*lwJ-& z={6XvK6zB^-IFdY0gyeiiGD@#-Iy8m4`UV~{h739_-^~R_$-LK>}iOo2`6uu3BJOg z&FMDOMLh-C!avH+1QD#L=?AUMA>$=v4%c!5zHgoGLL%9ma|RqB9gKF8#i{h3HH<;! z;x^C0jW5%5%k6;gi4L8kqs706Hgl@5=+~-G!dkJ@gb95hDi9#*!9)IKUluA1XUQV= zgKi_Y+EZYX5zo&0fo*(7nMar)3e44`i5^0vcRK!qwy4BUlF$I-_tnY+?6=YyaBDZz zmv4pFMsq=OYl6x6%QMPwp9pcnoOGTxvnfx2GegI_ktbFMTjqt)wszkk02TKu>TrdR zOO|9T146Eb*pwa!-Zv18iOy}@k?Kv>U@>SP&=>W*3P#dV_uJaCM&wk@>^Pg0#yJBj zxUcLO&c30u8rjltDYn@-&cvNNS4~?Z(QW=O+vdxY!9A9RT+B|9%QHGLr zrKZUimuKD>&BfF>ZGT(nezl4}cazip2v$NyaSH(2=QvKB?^s*I8B5!6TK|bWWd1%YNrH$TEm8| z>-9;RZKjJz?cM+piAIWysPN5@=tQBs^O~T%TT`2peqA(fPA(d~4+-eSU>^TtO4dB> zgL*B{4+oW#L$rGI@OYDIaGyf#-~h>=u4|y2!A$M(3)18a93?87sTgj0!-gaE2Ffp% zCxgMRqWnD!n|-bi8>lFCH*XI`KmM8-mf|%VejZ+|xkz9FFwZY{yMdgne^hZoA?^tU zt=b$r!?K64V4zXSOPk`oP!E966~G>hx3S;vR^LC>P|i$4~h++}g6q-a~c5J7bqVtPOVep(E6 zVI*==#*8{4#0~EloAc9ou^N9?0cK+^(}J@ATt_g1R=W+r%Xj_2mPFN|J~(zB>123W z#18DpZbTfVXdTZP_(f5&qB?jxMW=45NTd5*$W(5)ivv(~^+gJNdcCoXvZTOgm-5Vm~0T9xsK4;UAl!ek9n8an1 zT~5(cf&?!+V@l>0eTASWLJ+n?_7lCygGZL-5Hg`?Z=Dr&(npW(P1lyVojD8TL}MGD_P-<#5Fq+{8JT5Ii94@u;W2c?{AEULqcihFO|W%c2TW z;Luk_c`FP(Dt6uV98!WVB+4m1IZHaQc~g5blJDHQET|wUEk_C9jwEiq+vHrlIND%e z4xy&~C?h`2PLGS!QjkrH80V?SN%l2%!{Q2}02eppCzS;pRzJiWGJnUn`N+Z(W5p;d z5`OTu-tS|XricJPgB;5rpoeMhHw_heoZcoeLK4f2&r0*2M;?}1j8YZRyVwViY3p2tCU z4mo@ZOyFt~{m2KW{cMbLd6XNzG`zF1TysmOKj(%Re#sk?+xhYUzR#RSc5rRHR#jq+ z#-@yIcQVImg-=|e2YE68+Y6KSs#0RDYTTKTN*dYGkdy}2KXp?3Cs;CR5%>_D%`p!y zX?V!cAA(31ld{@CA#PE@(mW=-sU89X z;o@^U=#cPk4CNoIG?U{_^s7fEYPEvj*{#XyT1o5=S+OQ#5$bPKwNphZ9zFl2w!$0V z&L+G@u;w0a5ff-nmT+3sNH^PfMzJY;kC9(%RYe-TmI!^`ptu*c@g-`Xmu{N+;B2GS zOHA88xV)@`JFP&5!P)`^D}!i4m=h4QHa|3tBiw#ub~-1kS|PiQ*H{94^h-lT6X6{l z9dcCMvDi&1NdXm-{wSgA2@BuSNI2V$W*|@qoh+l3+^oUqmNbh@yQ>`DqRW5JZfmjq z?26*l=C2|Zm&_~LE`ZlkVI+@Jrpg;ODb2h^g~|G(^b>m<5!XscV`=#4j7ZM5;o;#S zrI9S^BY$ar|Ej39DKI-e5l!?^sJTve`saB(2SBASA z{p76RX#oK+Z)3yu%6};{%=#*H(1M!vNyfj?X@H?;ywUYaeVi5Ir}Q7{KC09koHCJV z(*Z>gGw^Hr=vSvnxN=huJ1eBeNhp-01j(d{BV_%kX$wr9!oPyH`i#-pDz{r5DHHEz z-p5mJ3@Dm8vmxaGCIdgNfht4iAgXplp_AO@MsK7{8LxAYT`iTvdSjUTW&_EvmF9x{ zqL`=@Q+X!(>c_x^0Q@jVMfFRoG&YWT@?opqEDHK znY2#R=!%lAnnSGji^|hdU*Xe&GI>8V=tG|a@q3lhju#gX3YU$^=w|z4x2(@-WXVSI z6l(YJ>7^-sdmb4eO1!k1s_lF2;?weJ-7wh2rotpRyNLbUIzT3rpwV`#xPbv~rLGY{ zG9oEz)wRF>(?)O1yvMc`p?rB+6Jtti@ap1kXD{`~;NISX#|$P!HQl(81YsYf;DdvU zY7;58<6CD1wkxv1>2Vk)arfT?>>ah9BxopnB&e4|nZ4u$R@RHt!ul>i1cpKPa*v&) zM~}lsj<;&24tFjdE8b7uBIcInrXwjWrxyEuE*A&G?3qtuSNfsf;AK!wz3)!Rt%(=U z3rsW3!}hwD8F#C@pMTdmUx9;qsp?(kNw%yi%`S{B$OJn0Zou2nozanm6JD%b2t94J z`Jp4W65DB|h+u#qm&E;`xJT8eP&}ky4wTyqCc96UX!H3O?JCvYc15RaxldAFJkgZ0 zL(tx~rs0wH6yFMp@p0H%Y(HT7*>eR95_aLiPfu;`?YdOf91I5r2oVr_(G?flb)(}* z6}97;8N#}H0W}vwCMvUOO|j#?K@+Q}Y7qWmR-H?uUe&Zu}*wYw!YA4YV^LEiJ3 z>`Nu-+z(GYC(w!%;l`rgklTp%Qwaqu9j!*7$5V}cU5C|7e1g+;<@3`;it<%PF8iM# zDyEbI6BVodwMKQ0YD61h+1wr${-k`iBs#}?2*D}rbWN@PzcXzU7r2(@Afal)+F`XJ z>YNz-0}Q)Yq5f>$fMs}PrTEfw&{#;xiWol-7iGh_5? zl8c>cwXBL{IEMj1$j%M@?!swq^ zV`x=t(gid#uKlrk@ut#ScvDD$N`-!r=Jb|kID6lasw#jt57Df6kX&7^!E~!nS>{NW z1MnGqA0ODi_G)u(^$09X4sCZH?&zHW6zP$zw0d272^-j*j^)G)!oOC=!TBzE9Bpfo zM!#Gfm{>WJT5aFR zw7Tmn-B~PwH9B2+-K?#0UFr&fc``CIXZBQ9BSG){Ki)0WM*t&%^)JlJ(eEY}%sLCh z8#xndPsB~ymRQ@x_%hH92M28E-AmH(=)4X{ZUz-{9~W43pKeSmUK}WS2k50is1M5? z8&oDwTDu38oW9ho)_nh} z7w^Xz?7#Nm=8WyFoY}1E;OV8(`K_GH1v|zYgQHc@ksKBTSS6FYcv`r|N9IltyrC~3 zO9(`%L0+D4AHVN%Iw^R?6HCkDH$MK*mk^9NzQl<1doH3-5mBnG>tki*xf-YaLL$DU zPyzi9_{tZ76X(M>h7fHk2BLC*_=6jsdWyeh({n~V;5iXE$}T&xpaow1b}zO$DQGYw+WgX#bW>?<^z6y2w%77AoW+1r zq_j2Zs>OCGD7t{VE;`WdoVT?iDBAwGF)Dra4r-NO0Mv!(tTrF5GeUi0bUlz^z1HNk zk#3BdiS;Ph%?AN(1R1R=*MX@_EtmFz6}6k($`ddXbJt(TAm{H@!wkZ>KeW%Vw-h@^ zvjLGB?a%LYx7VBwBU%FoGoP{KnzuA!v4m@MptG_D5^ZUtz+yARNYzHy;@Yi?BBoK{ zWJw9eykH*?LA0|HXvztFYu0RiW0vit#o=1d-kLvY{BPa?3*U@uatKo6^x9nHL?K4^ zw=rDtw=lYuS!ZWP6nz9${e4)(SzpwZSl&mzb!IcL`(9*@S27tih8tLde{t(|Uw8CD z!Ih9^h_MblTFfuP(*4K7hZ00n>Ev|UW;~;j%5Q;@v3ir?d~f>V1?Tso>w0!>vfpf5 zv_3fg+~Kr79#B==LSa!w?fzZnoFik_knYbrbJAVhDU1b^PFVJCHH&=1-**)P&4DvQ z+bNbVe}|-Vfc7}tCQ+qw%RNAB=?*ZgLjOgEFQb}c6es^R!%JW{wiyp2Ej@ICu+Jje za6G`=D`p1$+N2jzXWB#(aFlf5_9; zR34QHdN0F9%zYj1Ua-J&5qge4k=3By@&{NCX|Pf0$Y;dJ=O~2^R(2=~+YMnp!Kp5S zH-*S)Y$%|Jl#7?pL!-G0SI(XKXu{(23t1OVzzUUk%e`+api#aK43x&!_29N!%M$NE z5X^-fa(it2^8_2q8PIFa` zcmDZL%p4+a@MW@a8m>2BwboKP@SfHxRxNAB-=#DZ5_KjvX^@F)-jJ@`m;1sRZ95n3 z{boUNc6iw{l?9rR7CKMbWm zf`BlBo**zaZjIJ*Sus8tIt*Gj)$5<*#b?2P*KErg(i+d7QCjc6HFMy`6$#)2C~^m2c zMW)Djd=!^NLL{BKBfJS9$gYje^|SnvVzZJ~R@-`pcHmWOd)+O#u!ZjLZu6(^X1m{z zvY;%ZZmu?3R*9@n(~c_AV=B;o-=tXyn7%4ZD7*dleCDk(o$s4if*LlwjZcqJ7Fsa0 zImd0SulSApcl@oWZn7?Je--4f3x5)XMoE`{ikpD};&1 z3mM1%DFk?h@!$gCNB{QzI3EAjAxf4*-)Y*Ef;@&!;pFLd)>_dHVds;CCGg+CndpWC ztv^&-d5L``wudLE(snJlKHxhRF{-Uk?sSHcF1ejeY zOJFqXj-$4|Bw@BcWu=)t_(TNm5|vF%l-meDpB8p>fNjEf#syxS%-PeQz0S+SL5uj+ zZXN@|sd`KL@1f5SDb2)P9I`+76Lf-JneEQ_hjE;?ngL8PBemN!qxP}*HvL7JsX{u5 zID-N{H;P+CdvPT5F2BflPTN7W(e4W_3o?-Ahcx!oeo~C=f9jRoPT@@;Eaw}-pi*vK zX+b_`3Sb-_UD+{*5ZN4tL}m9V#A1ky`b`xiqidaxbFAw^#;eajnqF^+EKhf*0cct5 z&I8JqaHc~YrE?FAC&0d(o9CisnP`hhxM5k^Z&y`mMHHZd%k+Y)YxR$h#08BlkAZa6hai3pXYCi z54Ov&5d#Zakp8W5Ujzhf4EbLh)U7Y5fc+O%5Q4q|#98!AT+(dDEn4$;IOR^dD_&4+ zPWSy!b-27Bt}Y_~Zu=!DOR@}1{uF5d1d{Lv!&y|g*&X**9!pqdw)pJZ-_r)9V{Ipo z?G>N(l<70$NBI&V{!muoMN|U3YjKWLPR|fx-Ye zVc|04l7D7qNWJ?`I2{q!e(huJ#$Q-~f6*dj{C6lbB9n^?$;2!56mB9U{VxL zBPfD^|G!-!1Zl(rDQkRC_CJH?V;_V8{v0f>u=e=si^%(3XN0jTrtG_Z2msrkyBi-P zFC_TN;sPG`Mu-87p{2-Vj%IluLL`EL!`P1`#SNeIE;>6%X)}Z*LCRvGNnFvZB5y zGoD_k-g@3f+5|%|pqSE`JeUX)Y4sXEBiaMj2gxc>-eHaxLp zt{1%;{1Jk;9t3Cm)%iP4*Kgi~y!po)Ftz(5-|%)}mAL_+!ih`KWmC(#A;`(BV82Zi z&t>*qnKmShh8QLcaAQ5I*K&f(bQE9an}IN zTuVOqSb9kNM_$}76k)@QRa=yZr!W^Y$3;P2_;ImQ)o%cM9zR=4*TX_Y(xBYG-jTx!7vm3J?8d!ccDS+HbZi+r z{()tM2KjMO`Ru(nAaXm`{ZvuaS1}@*OOeVr&PPh<0MFWqln|ozRX2rw-2_lNk)cU{ zp-TL6g~wC~^kKJN7W^C-YSbk`lI)6>-K(i=tJOQ)h*ZQ(jEtYz|8@9H1VU{y8$#Le zo4K8`+#fT2_EaoaW=hRY_EHx4j%%JEntPSc-_H34)34x?1x%RZeMMx)VG-$`Hi!iG z0%)WObiEAp6TfHG21WMcB#RLhYbYhhCUm`|d+cDt!pHWe7Y4E1q_x zgOzIlG6v3y2Ms}ckB*d9SvviucIa!FgXzz4$ypgu1_{>+vZ#cJbj&E6k||j3`ig*R z!669X1Z$L{o;7Q1W&A0naVA4xysjLhZA!D`7 zYrTX&IyL?^yfjH13c0oE#Zby|7Tk!C2?6&(f1dcJT0^?}F5Pbb%?J_XJ z6}u|?$D3)EubXiNtTgQwZ_0RlS(r`<2ezk!x8HcT&et4u3S{?LZ4xl2BPdZ}45z6m zeXsrIQa?B%4V}8eWTLPzemSrHd==TGTvH(Z@mRXEh|exCUzdI#iq$TwFHZ}T*>b-m zc1EkJp+J!U7H}YxcOM&_~_DL3yu}FT*`2d z$w;zKYsQ3Xd4Vm$2VcsUywFjf%SIsg$HeF3xml1|wbvNpCs=W|$9|J1*FAIUp9VU9 z2MBs{7*(7rEteStgeju+7;F6?Z7Xoj`z6 zT#E-SQlz-MOK?gl8r> z$9wa6Qmas6!l2vwy0!o|kGs%>Wi)*!F|^;tWq|td^RAE9WsqMwb$6jpDqF1ka z$b}Qh@c4WZCiunDpv(-2)Bbtf4wYrI6o}o7OKLnzcuQVNlqdURs*h}@Q(iVRBfQrZ z>^?68PTknN%ExVNeSt|D>dVW_G41^kYN~YErc}jKiN9-YCXEEECHd2Cl!yUlq(mk9 zBXT2RsYK^D$2X|i53GV!uDwQ1swqQp7FdvN7R!;{pWf<|H^u!d?oN06OI7BF(&%U{ zXlaxq){oc4V~F7GA8Oq8SwnH!#60v1Lm;+A+=W@w#N(-7l!F-ekvBt0m5!gDZR{mg zS=RSL8huqSW$Rx0RE;g+EPYvTV)Tq`tw1 zp>vFij$G*>mJluU!oumeXxrYsFBn=*pZSt-F`G;Em&I zN^`zs%n||EE?GYFDKd|u2p!@+#?I=`EBTOaCE$o_u9J@uveLopL#psW;~l0kw-5+Z z$#VWP)Wq0RL*YWlvlTu^Wydhj9T~uh+RYzpQpD+#n3y7UVTAR{1JA6B15*f_M1ej8 z(BaOM>GRA7GjD0R%SczDgEtnfIH8N%wu>B>{+#PFm?{K0wnrSx$nr8nK?oaF`;!Q! zXVE#TC2d*+?Zf3)1|e)pOnQir4Ic|G7Pj2BfOvO@38@r+mfd2A%Z!ly38}Fsse}_> z=Bb^J7AV`&CI2kG#5L}@DXjH!B*Tr2#m7b3Y)(J{)Dgan&_!U#FkI60s@ezf1!!;WF{-t7z=nLr_*6Q@`n z7@%O!iWNa&&_h9b`I9<$&l}09js13t}rI0 z7s&bL93~gPjzlkz5vGfJ0lwI)KbB?AWXXBt`14g8x zDWgs=D-5Sg296*0>D8D3aj|OayWMjp!QbtyOqRfhjmI6*tt8$QNm%V4Qc) zmP<$}WI92XwEL|p2Vt&wS|8!1*xYJ^$vg+Ps$z%5mvXBHFqHr`+fy`v3KqRU{C=a_ zsnq6d+q}2rZQE0g7E5ZE&0)-kVZC8Jl|}5IibhzxM|=l}f!~=nCCdk;qsR{)e>6C> zla#LfI9ZH%i*(z@FBtymP`nvUHIDsWnv+}HeL74RESWSE*nH@*sq?G)4ITmiRe5)v zch;SId+)AWmO6EfS^X1C&%l}Ysa(*)g$b1VL~!WE&-vQujVr>ZTBozAl;LAN>rNAG z8z?G@r!G3xl|=0(PKARF-<-1)ZCh<61%0SsP^; zZMVPWAunco%mI*Io21I}LgX1+r+q!aIS8|^W;DhGNz2-3C}!H3;K-hnkwm0C--BK_ z(F)eNR$1R>T(3D=$8S5MzW=0|O7Ewdzj^`IbaEPZYc|-?-Hg(n8FwON8U-wWyiby*dsft@b(`9};Uy<6&Dk=+&!1lRrrja~7p*W| zG`V=HBs-$J(`G2g8L_^_Ktal8NU)fMXR^{Qc_>P_nFe=3DRMRFc|{~@ z_BUy!RrZZou2|!tPp(T!#>C8NP`r5R+`S(( z4>9)F?D!=&71UM@bHeY?I|4`yX@`wD67E^+e?zUOQoLt!v;CbSaX<6cLG_u3_@ZvZ z+KKuw+__&_5zkRQWiCL?gP{E@5?g#J}DL{94qIj_EvJHO3GW?Yo(dq>Meham@3 z5lW$`O~P_2cU-gY;{n`cQd8&5jfC;1pX+rY)hYhIwC~ZUi;YExs!^DdOpG1eTb?}C zF&66jzJ{HSi>lXkFq|peGT!n|Ax!*(?1Wq9$BDsfTXjrAF+~0cQLOF~^Q%5eEsoL7 zN=K0$T;|}N@@cM)bBvB1Yb)uM-Pu&fEr5@Bl+@6Jh`w0TnCxH=nHcXtm$pVnlO{qI zHM$8A6}Q`yOHMep!?#~VY(TM&s!~Wf_ZBlIm{lDi3(;GRTOF2Ir8n!2%U*}VxR33R z(4ACrPF`%rOleM9@BK1DUJV|F?3Bmymo?$xny1uCStBdeQ-}MNZ^E85PlN@?Tpaq; zCDtF{QmB1;9(`)Jo*cq?;@cba0ga-s+Dp&P4y7Z{$4FsL#IiVnA%n z;U=6^n<;mKlr(GUO~-iigsZXPBI}zwoJGny!MCP)jKj&c@=J! zQLd*MR>kS74^~sRC$Xu!`ObRa1Ft8=-ZXctdTKX1ZAdd2+2(P&qjG5xnCUE4iEY4{ z4(#8=+wvAVaRXLvCz^3t?KE<`+;P@e6gst-0a}6fb|p0@I#Uu;;({@nG@7Wm&owlT zNr^4j&~0j4UMAit?d`U5_(?K+&urZrU@xd~2d3Vg&g$>bF*4p$Ts^m!hiXUIBp8YPV|;^Na9n2N#fW8MUL{CP8{>(;lb!*&CitX^gz;!$hz_>1}A<8w9Y-)PL<}MP9 z7h{=%SdZ_FBG%(yNArrhbt=87V_FW7h7@-5fUwGURVDUPff z#Q}YYkH_bzaQiwi#khS~iZel9M%+>5G`eP0SLLmK{r+0=Q9K4J(7?@3NKYG4q@bX3 zWN0(cF>cPtzS*92{bCP@8z+lLKVbNo%NV(G{+4Tc{>)((u z1xL}{IboA|M4E}c4DBI>+dMMt^P0GI(g#9x@a2-&mAr(293sKT_hrt@HC|-xl(xu_ zb632yrA(;V#w`1=s@3&x-UnkUTxCT17UeXd3KdLDdd7HHvlndCXFT?skotw%m-{*= ze8O5<5i$I@ma7TMNt?rKUvY4`Ins9sA&Z{h?*t-My3@v1g>9dvfR&**%C{=pQEtTk zChrh)?~Nhh1Rka&q_2UY%iYs#87+UYF8uPmoQFIJ4t z=LN^u1Tr;!Eapb>weo;GCoyjCC&ih*UwlGPC-_J`w-D}dq&F^u;hJZjz(7>xE z8m&9OyWLrHu^LzEw1GTO*Cg*SrCm=Q(U>t0vuci~mvyF#v1wxnuf?tuw{;_p?)R2@ zIt*wHqcYii*?X69EEu}UR)*!RUBVt>QNXe-?!~iY50kB^iVL@y5JH@4QV>nEb!-+B zwCJv_`9y%Cm02y?y^zis*+2l12517ctSRpFG!Ut!nU-siR^dIdL{?b$#;558q@hK1$R@ETOXZZHdkPAY+_95ahS%ci^b(g}( zVyP+OBV6qW1-9bLJEm;CTBpg!uJe^@Y)VdO@12PAP{>%`f?cG=)Zz5kCcTvGj3WIj z(uh_Z5 z5-%x-y0*>&2qM^&(JO9F2fg-+wpgfIV;G9(;wMp~C?vboKDqSN-try7F@}mvMad<1cqJ(#h20dndcaAeG>y^`YXHX!F z44bmd+oO|o$DGS6Xx}|y)%v@Htdat0E(EI=cd|Mjpzr!EtkcmI*uMKl0KD0K;ACJJgUEKoIzO=mVzIDZVZZo(z*ACWp_8eSNv!#R1seLWl^N}k?3s(Df ztJm?ThIMHa)cc8t&wUV!Bws;#!8^*jblJ4JEwSKIs}wK|6Lt3sr8A@2QW^v2kHA2! z&5X_4($G-p4>16@LV?_xCLfjAp z9KIn+Sw4<5pL3AqRc~+bYbSNbJ`#{%IX5G>M^{cCXjo~QiQ*JhDB2Mc8^IIk&QSDj zdRSGfk%f%rA|P>)$sUa4ip!7efLl5*Uj0;o9B^#D`1bPZa&i&H-}YrLGY)n;b)3F& zW!@6uCcHCm+`Qhd*9}u{>)jtXy#9FtcKE-e#Q!j60z$&KFMnQNR3Z8eCK^glPmlC} zVbJ5n+FS_aO^z^tA^Z>fE;nq=p$h&tQXU!=)q8a1a(!&FfXwI|hIk02gb!)^k{YmN zQG=b1grxQ04}xE(L*#!FjEjpaEGx^;%lnd=nra=wKu034tc>02eL=^<5@x^9BqNGA znD{>d_%FmAx0eS>OKrZnRN)AkET6VrDf%Cz`~Qf*|NOYWe{{0{9cErBBkHd-i8v=W zm+JE$YW;`L7igCoj^nj@(q z$CZ=jj6Gavo~X7`^S;;(G9mqQDbL^^>fb1@lhoT8h=E9_s*gwsKXY*TPr1ItUq*Ns zD8)Qu;SYCg$c%I(FaB{Ea6XqqB*EHn9))=`pE<3#i0u4sAQJ6=AIFa1$b ziS0ObYj0-DDw7aV=wHvE#0N<0Z>qURkOGXcj4(Lv1CXfN53j1v7Osl61tCw)cpWpl zoi2T@Y2oXz2A0L`<98zUZ+$A9S!6DyS1GL|YZ;?0ZVje?t$fr&1pSDP2vol4Ij1RD zzyT&zF_uR*0P5rRt-oUnJncK_ncd@kh%S&*ae{)v|kD+|H>C8y-!3|?Zk^~9b<#dsr ziK|K=&opdClil-_#!my(`z}>r3e80vm{frcwhlQ9?HXwJ*QJXLOqp+2G3UtkuB7fu z?@w3s-ug+z4yA7})sI{=l4Z0gh4E!R4(CjJ9@bv$4|}GhjUM>DMAWy%lJ5=UAU_H+_NW^5)po8I)BX+~l{PI9FNzY8L5GG!a(^|C4lC9cW7>lUs ziZbQkHOp1p3VKE1Tw~I_N5m^p)A8J;mwofR5Iqb^(}%P2$_;t^Mc-&Ij6;jVz}DFs zPsSIy)LWgA>ay8%4gfYb*^}!)vJa(xnYCKzfj=b7+3EpTD+DvJ7a*(my&>&);vv86 zgAR_W&(x7Xt52D%mShT_fy1ZT`)&^`C@ZmV8J30(;*Y+Sd3UnbZE}!@hhevR zJiE$oOd~eatY8#OzKXyl;J)Ry9yVu+A(IMBSJ9+yzcZk==tRH%J-Pv1256YtVklkc6s2I;odgw~KvkgcE}S6SR>g#WVS_LYvs z^Sc5KmQe-NZlFf%O9sw*^sG5lj!ijaZS0Svbe=MVwZ~0&>9BMZTuy% z3!(y4WT3X;z#mHV9vbt>*|=EN_(`3=$iUSgD~ zWYaz{>}Nv|6H{9)zJ(L6j7UDj#g@Yk=cG0|MyZB2-@tA!fLEwG`?}3>Ez70D`p3_? zhDo1fZB>D{AW!%|f6ye0Ooj7dZK_&IO*XHWaM|5{dDC#3_F~L#qKa%BD!ZBz;&q>C z3;1!W(^mC#>l&(i{>$eu95ysJ&ynqB-IC_XQJ(<58s7MxyvZxv7VhD4R`?Qf!b@## z?LZM`W_5bK&WFu@50->WwI1Jt@8Rmd13CzBeGR1@djgfye8flHq156~CAY?9c{dyxo3mS4vePsKMEvHCouMe+MD9XT8q7QHxL!$TX(+{y@% zPZNv6aEJ`0uY=Xv$uW_Q2tw1$kyIjr^Xu{xcp*cBp;qLY(zK6smkucKT;HH~_tk;*HAI->A2!Svm0BPh9mEEna4)|owuky6?DlN2dP_%ULG6X(b$8Qol0DQt1{|} z_{)fYX#w_i(}sw-Y6LJMLgj~|TQA8Gt+C0_>4}q4vU4x**w5ab^>C}q?6EjTSGQ(i zyVPrE=NzgZq54vP(AHFdiqByTGe>t=`;TxbeV!97GMSK1f)&iXeDt^>D=!ak_Y~Hj zzA!`zw58GS$=RU~bs;5nns&BJ@BK;-we_g^DrAa1-9#lcZxh+hPw=>Sz-LI^G}Ju(pH6dK?jxjqRt&ry2ixlV;{Ud6Pu%w=8Gt7|0_Rg80)y&6zO?S>5h zw0-LaK;BZB(B*r4%`=Z4OrZ9$D&D_7oVR(-JI`q^)H6Le@LCV*(%KrCArnyPLe1=W2O&2j2`lf9)kB_^}-lz_~% zI1KzP;P`o`R#PsEB$?SQ499}+CXyBLJj*{9pY0FWU|VnKyZJ=c$CS#EoAu=Z zUM{NGs?pWe!s)gGuR7f8fU{jm2cn3XgW#O{TbJ6wJ(^8@l&L=|wgX(MgRI;))-`}n8vOA410$Wj$--4DY5p(d5hs>7#um}te<_fh(nnT_Gm zE`sez(I!a5R$Ba*M_wWG7qFtIbCs~qXwhhuMiV~5qe6o2?OF81R$NIiJ@6U-a;PK{ z^y*aAUe|!GgN!7`1o^}9&Rnfe1r*C&v~Jl6r3T+zGy}9x5*}rHn6jA9pY_J3)wnRM zRi@=@b7l@}1qDE*g;q6@RAo1$alC*&zQZP8crjb zF)-yl)y^G32@(sGjkLHgoch?MmPmuLDCS%|nobeeAOzYzEOVc17yr0ZeUUmKKl&1I zjf=rL_>{83Mdk9xJ9>e5 zSsp*Qj=PV1xRZ|VSIHj$E#X9Wz9r?qTadLmIBl5w7eII2?N_Jcet|chpNA0|>(;if zh4x=%#HnSZ{DI1%jkcG8e4{ElqTTz}FDfOh*rsM6!|nXIs9ZFh;AjpGJL-z3=~znmypI><{Bi263woKk-^klIg}gstF*TS3R}m(X((^lzyqXc^ zmCV{~ll|(;Q%w@Q_)4b|u1hS~=2oieFLkhBjx%#GW2gF@d2DkLdF04TLI;^*z(Zwg z0K{?sT~s%#I&NcAIkSacV%YExe0HJ-59|Y?-UALw+i_5_s#QD=zZ_NL2(|{-^sE6F z&S}B0msN;li8!C<5A+H};`&2-1Q>`!Mf%1R8Y+KB-$tZ+*vFM0sC+AY*4~QYj&l9r zX!(_S$l-Z~s3SSLKr>bGU<_vM!|#}Bw2%o*N?}mHr#d-_q%bwNfROC?Q zXM5&#BwxCP-t5PBpbZC2r*C}c^RXLv{;e3QsN8HbRTg*`N!gv2fl|xE%&j{l7@D9r zw)@6Or#Hxc5k-Y(1fVSof~TKXTuqs4jNJ^!r*TGQj;{^tjxZ;u#ExmRw1gGf^2fu+ zM_-#W+_KDl_~2o9{~e>yn)A_$R0_$jIZN=Qn)gaLbBv=aCcs6CXP}kVQS9d+*a@5b zciIm%$#p3^NYWJ&#+wWQp9c!)Ze(t~)0e6$b&k?-y6_(#RZWBUs&RxH1WIW%Wk8kR zE%5QMNku}9#}NlZDceq@UFJ-vb;o?KFq_gF%+x4fH0B~O@rt-Uo?uOXc&|BD2;qYi5Vhul4&t+gx_S0Q(foOzVW*ucBLoEYB z9|bCyAGU2AJL$Po1$H9IwgZ$Q65kYZ3Rl2^q`WzTY%bJW#G=4rNM}rIb z4F^z3xi7OmMOPTP1gYmyD6JCpo5@tiNaz5bMkP3ArS6%8*1BlF#4L^NHj+Ghx?x9eh-slY;=+=-GFkn1ju z+ix6eCmMpzC`hF?va?Mp$sAo_#-jfa<1k>cu_Vjwv01vk;o*EpKI$g`a#)=>q`NCa zChbtcQ_Tl@ah_S0LRFFIPXwxtlyMiD1(!LipylQpznEw21Enei$>OUUCsklujqC3s z!;A*!3TP}^M^sk}vl-@wjxiDpUl0hBl-(WDAMX^Pv9T4U0&)-SJeGzb-4>G9$jrq* zIxM^0mVEyz2)+&VMN@&iJ2bU548i#R9)jyK!g)&F9!tK{(WM;9?OESO!m3M)9Z-{Y znEhKfeAx;8koHHXh%N0ufOjZ{K{mDz7!*hqMp+}DF(y(alPp%RFe-&JqKNV9kMB^k z$MgyVEq*UC*R(F|0M|}=sYE?Uf&^-^5YF2QQ9YFgg!dP`-_UJuZF0*IsdcBMWZ`+T z2K*jr>Cr<`u>#b$pScwzEZ%?`fzs;|VH#OcU#!n3wEBKMDQ%Y@VsFp#FAaK3)@6cA zRj53wS=(gtK7c>lr7(*|aJ5~3j*N`_OGxTmwp7cS51Xt*#&aCb&V(;dhtlg(6}BnR zTm2qvDIagkNhG*$7PqHL^=jTWFkC-S`7xQDjvfBEb6Nfpr}l25?H^u(ASKU#&Bm)C zb#{l`Y+=wi=2XNkuXO)wLvOsVgV@oK9UQOI6|7GLIZ4E;dHK|O_BS8~_M>9pGPM4e_^|E87JLKfX=daRr1+goO;Fv@HgM{Q^q`|Kr z;Sa*z=dX->FJ3UKricF3*#0pB@!VhSety{9q3%Ne_WeBw#(ci>HcP@c=l=s7Sic0O zIKcmp(&H3aXmX2ifBH*RQ9?%HF9iJ$QusfH1OJf$|6fqd|6kvrSA*K{!2llNhG4S) z)X93z*+DTO0?XFKAO08p^ZzCM{hunfIFW@6T#b1^1JG_E7mL#aPh%c^fu2mKC%ya{ z=ho9Zb~QR|)oc6n&}<-4488K*v|IM}vktZ9*Mi-|4ybth^t$q-laL0DFNB*G;o_pCBQg%j2aj zWM3reBXiClFN^Me^xY@rEEt{qrfFX{^V_aI%tT9qi)A#IAEIk%n?}apKMa{td!8sg zLwa7e7SB9i;_XUUj{fHEoGfm1_paPB5B1ZwA~v!+koDzWR5{tSzOvN^Kba{)(VOeR zz3cUz*LnQ6P1_HWyub%Qg+V79mi>mk5kW4o?*K`sHwEMOvzuIi*u{1M(0WD43JC9p zMLzU@I=vBw(x3swA+AOuYV-3ULT$Oh_B}daiNz27kr#=SC9onJJ?i4ivy*63%Th^w zgcROxwuKbTCy*gycKdbnGkGx^pwHvpSB_S!tQm2E^mnA3?(A>lHTo8ZCK3Q zbIllC+3D>W!4M~Rr;@4p4HqwZKbaA0sd^Qds=W(^si*b5Q}PANy}1Ub-vKbH!N1|R z(@C?w6Cv232MX2Su!6@JxoTT^76q=P<>D)^tbhSAQHda!pQkWjN`brSxfyt9Q$_T8 z=k_PyV`=eUvMw)2XMLxnQE`vnx*20JU-R|RK%W45GKd6DH#+&6FvvNc$5p;7X&3{sOQh&TU{ zCH&*Fq0Ek7S94mxvt{E!`T(kq;etk9X;(dRrs--v4hrf#cEK6W)hu>W6zw0Db)M`3~xWGU?TRtUV@hBx57rE}0IGWoL7Sqrr17e}6p zH#VOozZh5LjoC9*<3w3IvlUaj7$bQo|A4&5puLdZ$twyUUgKaG9o^<-h>ya%=&rAg z&1!RwMR{cDcxmx1glJF4j*KdW_wQJ@9+wY2e2npdK}n;v@mX^oHf z?q#j22I_AeW<8Hu5JKNAM;km2aZfnCavy}za1Rac7dN`g>C)gN<8?^v2{MUrqmYO-amRvqqyJ{67CPsNCS@IzXn zHC!A|EV?TAN_Ln<0n|S3X?-x6(5f=zDpnCg*4bau9u0n8yx{iY?|6&Mn8o>#ztrJZ z4rwkHz;YWUdD+SRevLtrJn>C_YZS(HNV2b`8q<>dlPH=zk@hByY7j|vn=AJg++1UH z+GOnQz0KRQmxso}#e->JkJaM9^_%oa3Q7sD6=&D%z8aDFq-mTJ$4N=tDxk*%^x4=p z*iF)&uVwv^I}N-eDSar>4@^Lc0Nk^KeI?r{LMp5)2n2#JffTA&G)&xmD~y6i{az}@ zu7&VP3$goAC!Qnfu7yzn;@@ALT(e0l)1Z>R(%x z{WFZXDthl9=9oAx#D2&mMe*`01UfpA3Ni$$C@}BA4c}`hPwj<_*_*l2GZrx~?7T@Y zplNUGRvFJ%$##n7?Z2A4Nb;L zYcrkAKW%D$IcNAib2yzpxX21_4_R+(OH8Imu=x%B7IHmtV*Jt{Cb>5eQqO4JXJr?oaJHQWihd`h)tTs`6y-mX23&wK5>`mM7`GJ%NKEIuUas^COY?P#5AWmHA4 zzLn>z;HKB^uEtvNdXa_Eu@k=OeDd0|4>MD!&I+;zglUnJeasn~l>s z{;4jTHK9-Mvn0nN%X(50hrJ(OX5EVfGP`e_eNdPX-8B_UCfpKT{D#v6@;=L*v9GP1 zQm;57ZHpcz`{M?sb5=lvH1*Kh z2RlAVPMJYVl335~;|_JvU2jbt6Ng1-E#z0FhiqzB0p;o5J2a+hXToV^yt15+ZXL*( z72B+sN zkG#>sp&rO-#^c#{J8E>;y9?G{lKhZdQH%*5YIBT{*b>(WAv5(5$kdyaA}xqs`A4+ zAri6CzDP%RGj4^@8Iu5d87!Mb^1?zmfX}e)0}a(>rQ=k;_gc}z+| z5fgB<8rkWsV7epoX}g*cG)Bw-QBo@lbfvWx@ucXN>#T!%Eq6tIcN`MQfpGMnd4>iJ ztOCKQyAC%X7g3D%m7?h((lcxH2zQ{VT!Z6WRp||f*OL(_dH9*L2IcS8pc5aVf?I9y zK;+e3~q~@sGUE@@SrjedGR*A zxOAc06dLVN=q$YLC(#8=8YnI&dFSfJ8WHTe(c|UYRq${^a&Q~`1CBYL@$J% zef}cb9*n8agfFxe)BrkZVHCUHbmD! zmNLAX#jIXk8>-Ewx9I1$$f>3K;I(D*2-uic{zT}bK0~wS*2l+X0C7tj%~C8;Ul1t6NVM-= z^Q{k>kAZrB2^ZD>IsH@ZUA2{;%_y z1sSdy&JQ~y7ZN;pCmp$ZUe&YG;l05g;WyViuEYz@p_QWEi25^8jy_O~x}oBi)jswe zDK00f$Pa>MH9;@eW{nDBH-!cOXAA&Y?H_HdYuS%;H0~#ROpy}oANdd}UkO(4$1!X@ zzmi^qOTQ1z_~*j&rf%R>LEW=Ks3T%?Aa?7(zRJMw${6f006S;VEiLsW6zl4`z#zL) z7f}NamivAvYPHkl3J&QzEjgirmXw{lrkg|W(i+_!2$qSA?k#vFYk%pwE?Fyro#Ou4 zHta85FiJjjBez*|tPmHj7fs{wY>p$BW+idM`p#*3@+|^)eNQ=xiG4e|?^O9C&!WHX zx8g-Z@Yl8#M@X^&Km~;fCB>ZJ*!HKZ>@bIIkL=(YVe_tc&r=`=AQ%!%=O`)N`n}GW>l?533Zj2in z*-Fj-f@B-*IwS}9*V^2y+fRJv>d^fd;}Nv1YSLRJGuVY5&de9N@aW#x2(F_)!qp9h#^v`G1(JBD^+ z(`Snl4+oWa@PqG{i5gn)aN#?s{c=)Hl=p8<%W zs%GQiPfrQ(C6FdE~ z`nT|GiGNc>os%i~VK}ymz=zJ^|AHFQd1EX$zt!v;R)YWin$29s-v)wsIVbyHk>7b^ z|5il4|0Z?Y;r)MS*#F@xzJjexy~R=%fNLj+7hZLJmCEqYbMc9_&WYT#oFfVEi<1KJ zGc&H%ikKl57b&equi8v3XN#gEw*?**F-r$ZuLaw@Bi&n3q#fladfM%MhC|})_tOJk z1SRcP=g-9&Es2M$m`!RtgLLDD*p8Co8;&t~az2WUS0oF9A!?1jVpv%zvO@((g|j2& z)4=bntntj7-%TA@K^~egoIoTaImeq0 z^ac}-)SL~+cl9m^?ccH0`Ux0UVrE@#^9oNP#5_f~2PEeAv=^YV=nkAA4)xsLy%!zZNs=z?X zX)e%h>$HfE=g^03PzKzSzx+iX?@?H!Ru;1ImZ(^kZEUL#6K-yvTl;7h+@jeo6-~(V z8UpF5Yb54C?)(zv#O<{!J~BOBTR<|0>$gk&E&ofYm)AZ=X|D$?iV5H=qFeM%<;vY} z6wqYv;oayio!~>>-(pCv29`cN?3tp%oKP}rR!vv1$&UZ%K;Qdh!@On?-wQy|>PZnX z>njwv4=8oGesOo{Tr#dyT&~n&xIZAd3f*~E`dptFbktH?Y&dia5KrfX4Na9gROI_G zVO3MjWbX*|#@3oMMJIVUC?RQmvpiw~S5Gq>tZdBl_C?p{__uhRd4j4FnJq;se)+qc zQ2;q<)|95Cb**1lxfWTcUD|?cP{n(E1xO z;5X|D3ZMkp+s&2@hM42t)OIgk2jJ)4>i7!8A!ry!vl^#o5upy=`=-7E-Zobzuch^Q4t0?;K7TVLqijO3es<|VG8<9Md@R`||&$z&pp zc#{Jn8yRpUG1p>+H#%QG`!TVy?btd;@@kV+a`jT>~&R(P7M}I^JF`ixM76n# zFpdtkt)dL_=8sodX}#0wS5RVyRHCN?VXBA>I%#Q+!4}*OUgQ9GycaSzz`1+bvztjb zeP_+>&3V9_ol97`q`(|to#Vh02|zyYrr^taR; zL^Z_<9w9t-&z*XiGy)3R_C?D z3pTnpO)iP2O?O`6*i_D7R>CUluM4oR8AmP%pO2;_*bJv-UhbaCn{W@b zP`Oz=lGdUW)-nO^*c}LhGWA>NmB(%L(#`^qLqI+g5SZ|a&Dc0b!QwVghdd)Fq4rhZ zC!CW5QHFFtk*;#MP2Rb&7fE-yUeAl?s>+JQ5ZqU6Lh?~2b_5Pc#2wFn3cv+eB`jgD z1J@zE3Q?72q*`wf;vJVw&&WX7%;M=6O7h6>d|5sP1rK0p>9W`)JAL^NV)0hWOigtu zT~*QbJB3vHyNfV&_Y_toF&mqb{9Ukju1_9x3^J$Km|K2UntCryYJ;cPwJ|nZUQ42L%R%KMg>Z#Ty>oOD zY$=RBHLa1$Aj`N6qM!xtieM=3%?gmx5}za zUV`?UT3O4dJ=D%De80t|Wd-vQxz?5Fimi#aDzA zMDuPtjNZi((Wa2t-YMvs3w^7pSl5s&OKUJ+-rJqGX;(?xD`fU9A6i#?tnx}Do_39B zem7A|`UX*2HITFZ$RDP*;H_Mt@+V9A2gg=vsa>T+-jvE&!Vmzh7IrtH`~~oq2xV_B zV;obddVh@zPpLG!JlRz&<5HBsalHJZ%+obEo2vbDLuKy0hCHPA^+!wAN*vWoYWpTO>rqE9#-(_n`df9B3e^< zvtrfkX`UVL4OQ)=uulM`pt#E(GIpPHYEzK|WAEcse0+TWM_+YRB{?#AN&bYy_Z0XM zxhm?i%FmtN+OkN_(}BpvGI!QWh3ePe=V+-&qd)8Rz-zfDNGUyPMK)Q%wqwuzIlBrc zi5qOOB90$lIlnN@`OJ~d&YGWdvuKSk=}R^?th(FF<;Nb`$Q<7=7c*0J=X#aqQG9n< zqNiz*kcC4pAklKMHl_0%Dy6CGwMFx}g<8xI3wbKD-b7qd7H$KyMvIsfyrpM-0S*0_ z{v?FUw}PE(+1QRWjD_ljsfM9ewBXa$;OE&>WfzI-i>oE zSXI231o2=7r4~&^nB>KrGe!JqTs(fflzv(;?flBilFIaMDsbn@#4TmB#zAyw%v?D# zSNh59m3oaOn_l|Ku)FJTO8(_}?O;Z@Tu-p{IDQ&*s*7hBa8eoq>dWV8Tjxjg=-4=7 z6i>%P&QAICu-wYXE&))fctcQqG3TN?yAIXPu(DmfX#!t0ArM2&Y>3a~iRhB=oyNk) z9QmQqmy6HdkJ-XIgorqsp1`tPw&Toyc1X#(QirUw*SwGRL9)q3Jh?7{?8t zk+Uz`<^3q}|6CnruDa`e!Qimg8^qH3JL$l@xcck1f|LHJB0(lPmyT%4FaIaSFIsS- zIH-9^zkVIL{)Lc#rw+&MeB4lwE)(N26w42Lax9S918I*pd%LFK;N*AEM74jrXxn4O zb5>UGb+w>iAEiEcUSu zMm#GF9Wgi5ALD+VmkG8o}5xR~B#oMY=Y%Y1%-a=h)5n z@Y>Z~{Ho;0P56GtNI;|R!ESNUw`hOnd{+68&dYY@0L_z4XfME)zxl*&F8Z+E=G*vQ zuj$dZ(-JLqPix@UnY~-ey;gU*9vVb2#*@fnzWjn0XLCtDil$aIOW!&bOuUeyL8jc? zjttPaWNmo}nVL^A{5>g45x}RWK4rot9@F8Kr4b~fC#X`#y>7`)>X?)=dRfU&&SueJ zOgGF~h|`UjqZ7@jWjB)a*Cq%3iX)&msF%p$0Y|jHFIMRc! z?F&u(czOBcQdyR?EThrQ2ooHt*YkNH5DDRyVv-AL`rybCb6hx6OesqY@6BKWD?+Mw zIWetsVlzRLg!I|$IpoCJvKUQf`oP%Z%8<=0|AB4jz0xao_CDPds4a~hJ?);{urlV+ zF@-(7SkUayDhn6uw{J58mmVHfZaoHK_uG7Z=gaX@R|oY0;*1fIUHrfiwvH1=?j^=I>F7I1n-Bfg~6JlS~qO+s27!PLO)nalezM}8y2Da`XS177a6Y> z$CzsTRFz6u-h7)(>5erDd%QU_$VX|A)Ny3~Hn4+6IlW!8SRV3^Li= z2V=US;PqRf`V8^@PQ+`w`;8iJE{VQOm`GxTEQRBd(TjOdiD zgCsm6qO14VKZmu*Yh}P|1^>neF4zlG8H->Q>?_=WK~EG-xR4gvWRT;8+>FAZekKhY z|B&S6xm9bOz}xSSBktBrk+ib*dSZ_q8f(Fs3pgs#<4bkUKw>={Jy0~D$>yeWUHDzx zafHRMzVt=ziFM9$KmPQiINN4{1hgMklg8!>ZNXPdWp#}sPkC+EUQaqFsdW2!DqTEL{QdWQ<~FYtuHJ6%$TN4 zJNUV#6Ou*ofpRt5fRK#^Wlo=-R$mwSdZbIc)c1#{eT*)~|8FOYSM!n8}D6tv|0gBnUM23Dg%fH!xMaIXot|rD< zhNdSyv_%RMm*c;oWdXPO)B#O7Yd#F^*Z(H2v)~>t{n{_4cva&i3MfZ0XGguGp=S*3 zPodz@)qU&RO+_MSU@1DDT+#MCZ$qQp&ysp8w}Qnv9zulQ(4cF~A+p*gYw_)gGY4eB z+im0-t9651WaQp1M;&p#Xl`@Z%I5iSD5Xg%S7vQp|FyV@9@(b z3=0N0Ab!n>)fwYKXG>HhTI$_~rS`zqkE7N!j9qA%Z1S+C#!rjekVBCq*q1`I^A)qN zB?PwXc@otVkwIiItj6YWhr^q)-8JCgNBjF|lWKCZWePkSc+qj#NZuux%lGXsHAwm%cB5aV!k0e#FXIbfRLBn!%$w9q-0R)5XU(P@6WI_vTU*4Q+I z19Xj1Z(5$MiuKv~g=FRC2yH0)_ zGTo%Ac&uU89~0WJ+oPkS*^|~Z#E!$_w0PxkGXfuR5x-qxkr1TC);tqBa#B6X{)3E6 zT_ne3qy!g~g<6bwl#?&f5(U{UuIPe=-!Ro|8b@W*p(Uk?{^Cf?bW-$w2P27Wa4h<{ z@aoOd*J|D$F@T!*0NxKyv!V7CGyW709wl6zxZ8O9DYXT@F9qOW^e{$f0W-kKu%+b^ zqpFHW-4Q=H3?(QbF@B@-S&+s#y#%EPz8#O`S@~e=z^tN|^D2J6bgN_8gLmL(?HFt4 zScJI}ApmP{LEPCFOdJ&O9%9Wb{7x{z#qU?jI^`{!9krjJ7zSa)xf$U0@)Ry{ii<)H zzKaU_sy^r~8PT3}Zes}Ts=?ASPzSKBeRgd&LY3xHOZbY>1jqpc&geu}r1>N$@ z-d42VfuvR+;H)a*FcR1K3V5i;7fJno>HO!@dP)QGFeOm68`?*YddJC7{28M=8fV9m za(YzFBOsJXiNn9Q0pxp;QEi69Y`x|ChE{`iX?$rl?E^6bNZw2F-FnW563=2g+bZ4?05? zPJhbW0Wa%!fqMb8x#GE(5bWinw|}Vt#<6+G?6aU=4^L16{7> z`^CP|k%;|CZpJ1!dJ%Mc9JM{cELAgVz#A|BWv0On4eA+SXX5ZHN47RftAWZI3;HA* z7Y6pt2HGjnf})7UC<$oF@JAlvUptApv*-5i6%#e94nFd?ZgPr>1HuJe{GTi#AWI?X ztEr1X{Jopk`ucNF`oWLp8z1cY9crc~3G}aoPJjMf16wpT8NT&;(3)lOoT%lYx)x^a z>Vw0;36~G8@mTNCVjvHMHV_e3oQ z%#XnhZR3^Z;J}6$tnVeudtA=!cm%m`+V47p7Cp z>J2R>6lRX9?c4F^YRzS>iWliSv@zwoV5Q_cS+IR3p0^AFeD zlB7PhIsHRtdUnEtCQX%r0@H_ z$b?kCtH7n?E9{bMwKr~d4ZgYY-{0Slh>o^AS?jr@O+q&S_jQ&xI#`!sE>@>lQ;;8c9a@JLeV5t37rL%NuY#hYux#hnA6Yu_UqpG9tor^cNIE*(GXoyj(qI5ETa#bG4B^m~$?ij@`t9R-l*_i*%jNNK3UaL(*WJu!r66x{5N_};-FSjr+!zCL@pk|?D3un%Pd;cLe}%X|5vLc`8ch(h;!!RA7Ur*z!;Wjx?ZvHSrFyt zOtary9&jjiDz2HsX}sO4ZA8z?H9xau~oJDQEX4Lfbk0*L!nrX=sEF8H|PAc&z% z1A#`;iZ2;{0sjEX`w4bqCD#?Xem8Wu3dq z%b*)Wx-`*=6EjCB1b{&TJYNwzV3u+q+=Qn{gh;&{-s%{PKaWzMCv5{@2 z^AWo&_I-5>V4MPP$62*;tJ*C)S*IvA zQRR}~c6_CCQtz10d(+V>8zJJ%_z*HJI+{tP@nO7flhp4dsZU3`Eg6yrO!!G#bz(o1 zE74;1Q07&@B(OadNohU9(wK-}G@jK*+O^SAl0;io4$eFaGVr`~<>@kmO(4u>`E0<{ zuG=KDo$u5)?hQi=t}GV5IwG%)4ofd&SL-Bn#iCO^~7_4gmUYcse=X-YyF({biyk(B?B8L)h;~hnk%voO3!QB4B~6%?c6ku zhKt3lpQ>3jo!&OS-saBM6!>V^e`DK3Bo;(DRqz!Red+6vTxAmb1Y2ik^pw`NyL@?K zAm{xBz|AsrfX{8)QoW0_N3211dgWs zjB}*xxeBH{>Z9kso&)izB!kN#HtQpi`Q}062b;&zaZ5s3Zf>ul+Z1+?0-_fx_y>q6 z5ah29OviCm>WL21qEg1^I#M)9QtsQeZRbYGiI!HeeNBCBU0a@gpc`%*1MdZ$%NQgh z6^^;)%{8!nB`Ay^3qK9)6sb%0u;KKIO;wc|`uKM1TKwKbBE72fxLMZ&Xk52`T7r;Ea&t{kj6xlR z$jbjmRpmhXJCaP7b<)c>z|1;NJ-5$>7bhZe(5hVAqs*0j`LRoXVU$1-_@dxo zD^Am~Dt%6dAA#8L&D5)+{tT{CH5}J4O>0z=Bh4f}&8#Il>u&vS)Y4EM19@UxvxD^M zqu0i<(Jy4>jHGG&F5i=>$AZ&ijMVZRXb}1xJ^Nb=p=q1d-qBT^%X#*3BDn^9>DBh< zIHOd?Dysv=S8<`(`VGl<5Mn>aE&IR*t7ikK*v;qHEUdW;2(c>o37_?lePG8v@5Wyq zLkrpqZ?_LNfh^T_GgXESo6ZtTCbls01OW$8kEC(~nmPm2sOJWTOMv7#S3LYm&)M?m z#zM9(z3wJfmm_P9KFA3ldC(SL3~sZvDpXN9C0hPy_~wcL1wk~w|c$(3f#3fTh~l2KBwdPi&eN*X`5$yozYL#^biB3 z%<8;8r?UeL%NZHYKl!4o`1T_;uoMaHS`7-hT19*gej0Q>n7j(=efi(*2OG0 zh^8n^Y=cd!Y_9M0q<{{L3eJnBE>6U9M^#9^#`3ZC`Q6O&+w&^F(;AVutx9Vc`4MZq zS0JjM`;TFl&9gVvT%21xtV^rAnd45P-Lry;cb<2QANoeq*b-iHBcGS0+ww2!#5T38 zw$AU=fM$2-=>2E;Z|!H2mjS!q7MHlJ63a$f-k@qvxkVuP%DfwpINOwzxU$2BB6 zu8Jk@Hlo{`x8qX+M|kwh4P?B%y~~2w^~$f(fJe*vN_vQ~Yx|gp51rUtzIGDzwG+c6 zds=|BiX(y3-{?;Zm;jcyURE_xb2Qk>PLmm-41Im};Y(fUMut0epOa7+G;PciTjVlb zC}_T+U&H#Q+1Dc6C*`7sFgHooF+t|_VeT>|j^gsYjcPsK(h1YC=X{AoATG(1tm=pv z?s04M%AhZTg^lZpkC!##38tDE@x6dxLa2YwdZ8Dv=IAAaS#$sbguiVKe2Wiwp<;89yv(wXi1E?Og|&wErQ^TQjx z*Q$1Jh-ZI29R6rIZaKe^6yb!R>YF9a28UIM3gAq#`49uF%pR=_$SV;Z4-a2B+zQO3 zFC7ikEAU&R+|#U)nGcQ*TiwMMbT?VN-_t01G0()XaMk7!?|>i0ex8psRF8;fu3b?s zzi9uscWOo~@3>8@$JLT=(9N;XgSxRxdh^LuS8pRShILUwR$E7^>HK_F=8CflnPd#w z_U7@rae73FC3Bi-#hgick!UTBk9|9f;<0mVF9}9=cf60 zE}ddE!7><=6o~{6c&@fH1K#8*2O+=RSU%_^@fE-5N$;F&XWy48iofepj~HeVc##z{ z({{ryPn2l#o$FQ?{6c__u&y4(SCmUZ5%##Rx&icvhZh5;PDBkI`d&g@u7_iyFSyDz z`-m#fowddYl`LtU*$?LiyGqPDhYkpi@OHlLL)4V9ATFsbt`wD?Is z$7Q4sz=*aLJ^B7=eEqgh%pLW15Pen+Kz;XiHSvgPhLED`t<3tw-LNbddhcGA7pIW z|0O#NF}6V5d4qIV{+;`Ip2net7oB1HLi_c#l9Xs?%?iwRjLL*<&>4k1-n|b`i5NAB zHr{m4xnB&q?Db=Ctl_vh4q6j41v@Cj69OcH0RxhKpIzG9fhWapg>O1jZ*HiQsa={) z#1pEQ?vhj6~Ewq2)m zV*8;dosk!hnXmM24 zsqDYca_+1wH>-O@B)&sw(=d`s>u@OseZ^M?v$SuecW3ZO(*V2bpMe``R{fc=m!ff$ zYad%Qhz$nXaH%+zf|ubw^PLoq!4%Hv@FSt4k)yL$Un<5*esGQF)1}QB9$G)fO4~&m zHd@JM*Rr$KecEgBOuKHINP37_nw_+nPTMfJB{tF01vj^yd6U*DM2_!dGgCTK4rt2) zL#bs~#%IcJ5N6xuSDeYcrB|LS(d8zz10(FHMAsb_Jtrv$feDK960dA+qD;S9%$B2y zQ`2_uSu%Oh4ck+#DzkwS`fhEZs=)99OTdmkx5(Nqz@ToQaOW3>`e^H4`}-iDtb%NP z#CRlAUVz8*g7JXi)>H?B%XBpD5+6|4IzbX9rp5|it5}-b7Y!oO zdN5;}``e){Yaoga%=(KQ3u*_ZAnj)Jp+vIZ4_*;X87AeZeFVp$jVllVX6P(m!24+Z z(+sT&yP5UngAZoz9QZVezmuTp9J!#q$Z z;}|Etq#=HgsZ101`mf8wFPj!EwyH0G2+$A8s5(lH(<*fGt(aDyl5^J#?IEizD6o8& zX<1ah!95AzHikGsKohe;H6fq&?FN`HJj)~Tlq>{N6*M_iU=dgraL+Z)#fh4ErVTOC z4|q7ul9GTC{VoU2iOv+YOzN2Rn*LbaM?R7!FZjfW-3x4B+@TZ?d^Es7RvtIXQ3 z*|m^-cy7&1bHF6Q$oJ{RC2vk@r{LFd(&ui68=pNm@=YZg(r8mz<{2V=8j9JlmTB184qR`Hd}5m2L~vQ}{oG7#FHZ6`NgyIh*;X3lJ6Z%I2OJl>DNLV+ zy=%($-uIRl79Qdfs=jd~gj^ijNwF|1K(gVy#>Z1c;o6NM6I`=3Q!3Wc**bSM+%LsF zuGx$eohh}KQ}g7n66ix&$E*~SR3_b|@bhp729g^r_KbV)-W5B!GY+eT2wg_(2!pzpDnbQ$|5QaOJWn!ApPmB=BrCad6E$O?STqYh9OWg+my_}XHeZ9%MR~)EPD}RO-lx0HQfi{~<{h`Sf^z3f4To*9n#W!bjAc%wtpA*HR8C-w zR4%2Ito+bx>i7)bk^n;EmaCo}#l*&zWb8Ll`+NSqNzT$~eR&*iK419W_bSBjFYvtM z8cPyeyaqGz+BgTxv0rXO^rhweLpP>|oHWJsxtW!^9rs%=Q%NjJ7H7t|a)ncQA5VID zEoNX&^0~C>FjwCT&-cWWvqkhFHJPJ%XJRgQAYO4tp=IZgt35;};iH89atO=WtT?)Q zEg6a;YDWLR;=uzK5;cy9ql5D%^hYn>aV=~xGM+amC$gK^{9j0 z@W07%3mKo2Zgq~wC64$W_Z+ikMuC7KEu!`m+h4_&r?s`Q`+DESh)|IY@a&hAhV-a- z`1)V4xX*HKC$^f$(58>a7+!iwNLE%xi802wN@H=yX4RxTw!s&b4hU_fFHly^?->RB z1{b!e-B|VOER1B>dh@(AV_IF@_8Wf`gHl2GYe}B2ocsU@gX|Gd^tL!2y;0UvBP1B8 z+Ko+6@Sl;6HR%q@U+9^G`d;@zO^Q%uQ#3!gH%iJ%{IaQCs(HMoKLy6TkuSVlPDo5V z9ovP+tj*WBKalzxhE~a%fvgFFgOr5yh;se|a)o zw?xr#C|YES;>at@HiW*lm7$%@G&GH0swSG=nr<3lzl>RWQ4(TlJv(Q)_{mPc&`_&m zL4gBZNYla|a8IqMjc#i9g^jeSk&E|Rau#Q3&xa0E=_>ZR&OCod+)G#%|H^suUZJyw z@cB85;%g=NM87c{2#2C*PCHDe$mE;oecVvL%iA?A;2V-{+2a#&mWQBx+U+2)~!GzW~At2`t%sz^tYAxsi zaRHe#bz#*WU|AH;#bdn*D)4oy*m9#eE;T5xwE#G1*pOF10-6wXScERes$HAFb&b>Q z9UN3NH7^{NT-wibkOv%Lpi3WtG%kpd>67v86HQOfNbBh=NmiW z+R6OZ3XVY5c*{wh4BrlPn(~+zpIVh?DP%el-|jXqujkT~#U;~p&UsQsn;)EY{AMmF zureetS#mR`m=`OHCaXekhWI{xx>c1BgcsL+!ljn!6OZoeCKG5+A18t$&)GN8%Z$9# zIh8}>_MOWA8i4%LUccRXc$e2bIQ(80Qd0ORj8OBJu{O;LN-1ZjyQ%xi8~<}GtWw+6 zf9EeUsgB5B9y}r-Qk6J!*UAs*)dnV}u-8sj6h-Q*oOP?6agrSD@qDgtSMo|zDibw3 z4WA`CP+cXKJbjAH34~ZX%R6H8-e$<{PvKR=(0;0W2Ha=6GyTbfGZybRFa6w?0hy$d z*+g<1JXkCJ5%g~Aae00BwD-ZpS`6+Qll5)EqY*E-hGZO(`AP@2&T)r}iiumAblCyI z9gv0z>nI~X)y3+(JKQ#`Hn6I&7fwYLr6usM#@Eq^DHP9HYZLa#Dv zEEb~uV9z5n!1XzM&@G!#c4W5pa+^qQHbxjTFTFXxOGQp!WJ{T!|1aawJfr^TgTP+K z>`&Er)ZnoYan(kA^IthFq!sTVy`l%`i5xe({}W{#yB>ANcCUVhEh@RnIEZ;o908O( z^|YP#Ts9$>;|QoDY*TijbdKFdTBh}8pyK4^3tj$k=YKEE1zcRIR-4*g6xCPC{lMcm zpMak)zViG1fqvf3``pOMvs1)QiARW&0WE;*jtyy~x#s`_8PhsD;!@AbNZfs4ojOJ{ z0h{|jv!{*){>~a#C@vQ^xkr)ZoOd7+Ado@NNvyOJh3dVSl8bmI>?$t3Iq_V3Ieq$C zb|WMl&(f?g7yZ0^Yw_O#AIeHG*(zBH@^7&*v;1?Nc)YNE-u#ZF4vV!IsN=taWLLdv z`NPTRfBZ+tm6md24I8Iw)L&I@gN2vX`PXwcwLfp8I#u<*GVuK?D58=&%-nUo_cIC~ zmUVVMXKG17dncCvhbM^tyD;~aC;0f=WmTQ~aGpxGor!$g`D)Gd_vC0U^-g}`5C8YSHJ$mN+o1p}Hxf}6E|Kg|;rHViJ{ORmJ$A1VQ z`5)E%-`TF3-#!oil&POIcd#{@$v-_aW1eLb6z(y@ha2+c%ZuLL-fDyC@1$s6ZTv?u zTDp~C4zFr%1TEr1DPTt^lpQM*BJkwa{SUWQXFIXs-ttHO`41uXM|1=0<#{vuve~Mw zs|)^z3{#Z|P~$wPDJ?DS>O;3J5ktL$3w>>Io}QcYm_mO#d?@Bp(CWo7_m-P~QCELQ z%OoCzeaNCZogQ&+KUJ*AWk$b<@f-Lb`}+I%j{`Gk?csL&6XO{Uf$K?gba2O_f*_#E zFUqS8=qF<^XEH0<5Eid>e(a14%kFTI0<$m0n-*;S6UKw9jvl60H~D@bZa^TV@;m>B zU+-u~XTG&2Kr4aqasw|F6<+my#dC}pH0${^6eHkYLvu;w?INuze`7VU*f|#0?khEv6s_q~$eDe`c z9O(BXVdo{%`xs^?2S4`iPGv(|OFxIHnx&f%nxkqz3;^j})4}hLc0hV9E#!JlsZ4Z9Mq7Oi0QjUC_Or!hbk6CwKfU^`oTL0UP>%} zByz`r0YIVH#1DB19M`VnE7YSzhA$?LBu;wcRklUigdNxVFj-G1)|azTgaqH?2e2|A zK6U1O;WQ$X4=%Ksw15%VI*ccW0m=G}G`$DGbNP}oH_Ob0@tz)bNUXrFJ2lO0taVHH zMsRqimh6i)bR2XQ%?pd9p5Tgb3du_aI;tgTgXd@6pz%%^k$T{+_d{+en;4t3&86!C z2wu(xOwh$$fF94|QqTU_puZL#-|ux25Qtorikr=N6h}2tFM6-;{_jBT={dr^+-R45#Pwqmyz0F(W9a&jz z#lzndx*r^c`oEJ}>f;j^4FZLk-(t@ zk`s7e<)mi2bft&WsX7Rk9u6Y8PxCTID~hGIb&jpCVIK^%o}aCT5s0|fV@*j#Z2|$C zJ@tw8fP`&RN+;Oiu0KUC(O@vah1;~{*7hcL2J*Fgwsxq}6--SuM;S50`q2TN-CoO| z5}qc}J-NF!owcD>*r zr=p^;YT~!secg_pT+c`O<=b!mO3S`$xloYD5<(lz;HX@C7fLb?diI3fG$56kQtz3E zQ1#s73@&?=-eCIIy;0-e51Oc)@f{5y*n&f&0qeRZ1}Un27c^2LRu7e~%3sQtVh%|v>_3&3y z2k&X-FPXOomngrZ*JbBB@7aPDuE=wCZWrdw4mNlKVNscwYfPhPD8(=$LBgXehW@Gq;z7H!X2n8$ZWR=XNrH z=64Kpz`KWEg{Z$iO;(9(CiT!7KD@c@@sTQIB{AoJLIdQ!T*WsfL*dnpxUX$IJ93W4 zo)StLLR;PtW|LV#7G^#QZ)9Ga0?ej4V?ZhXSEvp)zPE_rw7Z(vR1$njh*P*;aPwPP;riZ?U%qTOAxyxrn7+d>jx`5s@W zTxq|Y*u7|i3YLZt88Wz!iTgxoNiC0m6$=ugQ*VZGbqd=nHyP#4=k;mAu*(e^Qow4a zYYUm{q)w~jlOwo@aOvBxrt#-kbs$b?4$@^bxeFGSt!NQB_^_LABOh5Pk%*dYb^$yf zAI}3z1k%9Lj3VB5(V;IVdT(8Oj*tvhHDA6+_n~)Cf?3|eA2YegZOk6gkL7F1wMYik z!dn`tg{RlfJ~>QHG9~qFE9I?VGq^=?H|uVanLJZWfr z4Ee*1;^`iafy78K_j6DD9l&fNVut~BEEJ)tY&+g-c!kB5TkxCUz~C~R;jdA$iCiQ< zyPo5A$=-H<)MuKka}G7u&mO2p>nxPwX z%oX)A$ncUX`|_T)iD+QH&8E)m`0@#M7}MzHX{ZR->UcPAP4EYI6dx4j)U=`~EBg?e z;AYlDRatqhamg8(mb#c{*qMe?Y2i#kGc3GAU`~hhiP^nR&ql55Vl8wX%t=XA4cq#F z;Y*T%v*p%Kr{&vSQ{QadmF5FF$l3@sZP!;~7N0 z-WX!_WFi6$eT5|~f2$<8Lm37h_kFareMdqtp8CB{c|MJ@4B3cut?z zk)i$qJq_n0hEL<7g&czoDuN}d4~d-Nr34lRU(f+PMWcAA^r1uoe^(TmtNb+c>3g=j zdQ*D0INj!HeK=1UK@GA4TI(#yVVd*`&34Ey#v7)s)|vX98uM4`!I!4iFxnc%1T7JD zF{|x#49%9e%2Kx=)+J7*r!6oHRM5q-v{=*`3VE+HAH32X#+32ulYEx)xALTur^=_fA1 zQ@POBvdXyE%nP3lPmoHo^BO`!Shb;R2X6Q~2`dYkm!n=|qk`~`N{6NVQU>=L(V^22 zz#9X^lX#9Uaw(&U4X~PlsQ1&Z&cG+nG8wzw2ts)sRqdEj3S{00yHGnYi4NXLc>EBuf6I`N}-+ zZ5rGc)_Oo*8;{Z#9C7dO;5`=Wo*w*|va%E?Bvc2{buVn%vhv=BLRBJikXfehn9E{7 zm+d{ci;crMlX>9wmNqZw=6TdztmLzfuoT|dLrw`~nOAIn&_GZYMmjdRP}magm&A!@ zP43;rb5V~aDk7gL@<>@fQfeYUG30;6pHJQn31S#Xc31f6-4OKjj=LyM_Jae`z4pOi6dMiff?rB} z(YUKP4dxNM1trZ9l|tv+nBQ5?N^w|BRr|8EyMi$H9VQ*1Ss9+iWK`DZ_~zA?Uk67l zIs?5Tl>xjK8A*xi(+TwQ%o7%hwL^IXxjBVdxsQ<%wahkrjq9zU0J_6vkz48Z}0qO0a`oy`~x|2$N77Aq%kI1+dZ}-EA`nU<=Q#+ zBI5Of6nFRLl-J3073{zKnG3?+uUxovTVrBSxGucPpJS}3>1ZWj*clf4K>PqR9Ivl= z#KEIB}PIr4Qd~eU8AEUtvi~8Q)c_On~ zJx`G=mC4(PDbVOmz&lXHvsxRLiikh$Kmr&T(GPD*b62~gBshdl<|{WKT~~C&lDR~^ zS4%g@D*MSedb&*2&1qPBHn9oMLGYXa)isfHAl1Xq;@LqcdUdOr> zS{1&&n)`lxiI3Ar#AX#k3tpXYaKrfe)iP6Jo72QpEWSY6q`T_Z0Ai}G=wwbm&k7~} z)j)zPMw@c^yv*flyz9AGW* zY_q+<_dQFRgR?VIp{cTwpW)8i8Rf{VwlmRZgGtV)eD9|?Lf8wx`;J;0y=K?xf){>zL{u+FbAY_??t z?TF1v+&cuC00!n&C`YdrE+fyH7H_EbW0k)>%+LXi#wcB-wuA{H0yXavc$sWR&1U04 zbMLwkeq{H3X0yF*%DbNdZ8)m&#UT@}r5BQ1bpx)Y&cLOEkjnP_VC^YjduQQDm)fr_ zs1p*H>cIt(JP$RHy*|~AKUW=P==d=2)U>|XNvoy%TnXOQbu@QuABZ&Kq83ZhQHuWr z?yX*OJ~()d=DEPunVFfU0kB~^eA?0nYZXm6HzI{q;mNE(KFkmm%K{uDJG)oOB-ydC z0v49OZ42Gm)YPZ*vf`Oc?U@6LA+O=W+0-9?8&rP|vwO2So)Ih1`lQ*BC;NlrZ?mU( z%>8zkt#NTIGQd;9t@jha#U963g> z&c1-wfkdnj{=m|841qJZ|Wq@?_O4ILCp_!)UFJz(0xQGWJSnYHuBP- zCBthQH_!OW_*?0H&n-4irl(EEcGx3nnnE^JT_%w(}!cvsx&VKtYUS{5IK+y*#B={9JyNmBCsM zDsk)oHwZx&AEMucf#yB-i;!dehkb zW*<*PGh9j*HFw{75*+y(GX(3rw~75%#lS!s_sN9yc=3|$UGtIP+*7=^16EvFObzFs ze4{@CHlDsD6bFMqMk#h(*t$UTe^dYnOgKsgf_o+#G%rWd}PiPzg;&G5JxEkxy{wb38;~u>o zRKC{^LDcQ1-Os`|b92vQROldt-<0!+`+`3SyU~TGJsrTg;&3O{8F=NjZm~UXsl|@#UTff~nNi;PlB(wzrWk|GkHrdIy*L88Df*xYM0}7rp{W8UEIaO5TFdFy? zxO-wbO2I`f@aKZgYBN5#HInK}oTwHRhDM;@KY*a_=ssi14qaVqQ9u`}2%`oS2EX_% zd5jvHj-B4v#JKtJNf&%Co+<&{WdRAeZBCbglolB@fTYom_)#<5R|6f(EiSzA_ZHOj z^ekgB>%gvYyKCmwYAa`oraW>$S9IScmwDg~>2bRcK@bQf6WHlYL2qtJAvkDS3VgEX zvoewgp5VW%Obz`RLzy{VWh3(7B@!v-Ehx~9xa37#^XEsdhexOVT;$#Vb~^KeB&U>f z&2x$l%lI_fL`%Y0`bf1fBp z8;-bmOl1h{d^#J;+&s{qnzpLA^;{CFmAC2J5xIVMGX$F!mQyy6giG+n8?VaUEQ%VM zYVp&Dem17*!}sU=Q_v>p!~DwU$?U%4X~Z8e7tJK}?$#4%2*a&_5r&f?~9ppb6@upEIP`tO(DM&lV7Y z{GqYt1901u6>V~}wm%3o`M+oc6t3GzK}#G?-H%YuV^!s@7fXL47g~P)cK9~{PAu>@ zggrPHW=@eK70_DM*Ibw*=Jl-aRmx9r^H|0=h5s#pu=-FE6LA+M--;f0nOXK*K)3jM<&oHdx& z7*L3NbIi=lpeYB7EF07CX&1^c89Td|+S=L%9QJR}l3mI>({Ir6B8gScqrtN1M25V) zya9hJlMl{)`eTCs9Lhicn@W?+e-nUu6#Ea8@}~*^YUAHe{D%j=S7BbYiUr>Knuh-Y z=h4S6I4+eyNtIjz-J7!?@uA`2mtOx4oslUv|37!)|6K+DkKgG3_Yb6X;;$Xp59Z`_ z{+EbXAyl-}9&Io$Bn$KYSI)zKjQIZx{r}6x)$YmDz6#vBM>mlexYW+9=FL58W>{nb z9nZUDrj^Cf|J}hv46uI7=3r9LxJrsby&iwfGsLw}T8zXyf~R+5;NmnS^VCKoE|M1h z5hUNM@;^G&_FsD7jE>z)M>&`sK#N6g6eNwFUGi~vh1qwF)Z0sq<`OtthG2i|Odj8< zD_C507Y5j(=aB0ahx{`c%MphVr8t|tm7!%S=_jMX2C+Icx6?zp(bCrB-Ma@@b|_a9 zP<4WCW!i+ihVu4z@r`TmC(V=*&&kDceB7p>r9)@rQr_~^S9dTaQ?5BSIBRqST2LC9v+AV*kmPI`Vy9T{rI+W{}82q^MU0vNomUm zRArjw`ggfpeq)*nk%6$=m0M?@I(Y9&McI^l>4to<8(;^la4qrLe{rId0>sXr`dm83 z=23As#gnmEM*}0}3oc>f^ggA86|a@%qx%KFBij`k2seY_=`?AWlW%nXu~>k2w!Xf{ zeJ_E5?!h_Hh_W7sYEQ?v6_ml@@=f2Vgn%^@EIhdFyj}90c8}$V_o=b@0ZvSF@w4f=Jvjcn~!s3~?g*Qk)C#?!He8DDA5yc7QjAN||x-wK+; z6#c1_jF~_szM!_Hu~2605|+>{@c=+MMn=H*qKn+fZ32#?Y zNAXfobXQMmtMl!P_Iwa3A7m!@7#!1et|%0{IuUAn=}{lxXd{T&c3B5wRe1s1NwA7v z%=Y|>`KxGq4jI4o6H;g^ZiI5_Kf9^JkK;nJUIzIOPu%$PqX+}Np-l$7?U08cI}SJnED^!p)=wrslxSm9xbiFV)18(IG~ zyu=`?@JWELrpLFMNCGL2?V6S1X4K-#FuyLByxt?xv8Po*N4c)K+o2;GhSwO|9Xgn& zj4i7V4Z?U(?(;2(2Y{vvd$CF1m?r7dfbL5-KMH*+jrNWs0|)ml0le_i`S-_C)KxnI ztP(8FB;CSNF2^>|=71)@9K6T6@2@*Zx8n|PPwE3^B|cNTW*3VYn!>@-BBxZ_rbjl{ zQAg!}c{Mrj2t3aR4QuHEy2ZA{w(H|t8gi)Kj~y-^ZN`hQ#B>SOLaY~p7i!m zSIhZ_Hmk+-w-<3WOuuLCf%K?T5(Y|bw+M0qFA%&R_3Rmy>`-gt0aI^v0!DN87~(Ir zE_;G%$4j=#uE4?x6rN_x&aAm^do7Zu3dEOC{yTApCp;SYHeVhi*Ih5j$QsA1VB0fY&k;w5q(Yojec0lc2#X$!)yjR5l+xJZ zB)_st#LWuS<}&o%D%bKvZLge@Wbs4jcOJT|^d3oDlu)+c)|L~;rOIye!pRwAxs@k# zr${%krR4|A-+#Xy2I!7h*IRCgb?U1C%|YcC$P%RLnAvmQ&dlXRBm8jqo zV>5ClS7E&awqRX%7ipeqv;CHOP>E+Le_)EIRJx!G6kGde5Qv@%p@r)0FzbtY0--xnC&sIeHsGD*w*)AK>|p5B?I za#qqN;M%8_%{$_!6zt-M+eL4HfIoJEnkI6M>GUgm}$a6o>?;f?jYGO@Us`_5b zzkb^%%dtN{;^o-6G8}q^vbuXPIl%E4MUo;Tb5=33Zaw4jrZUcIG81%orfkbIeP$Z) zLI%0a;bkISANN%(0%g#c)TeBBl zFrWvMTPGmxF4bXo=69XKrLN$GHr|L<;Bduxq7F}KXsajM39iEPt9vhzRW8&wo;Rkdo3F1DXdgxC{xBR%fQ{HHYwFZv!5Q1 zVjympugh14=#dn~Rvw6P4j-ef=n89Xl(}$@Sk?dmp~(%WJT*5DqV@r+Lrt*e_|B*4 z?$EfX2e`oLxcM&Z6Y^zS>2-Blape=63Iulm&FMwUqhpraeX*eKK|cxM@Jl9PWo-W< zz_8=7nO|hEa1;0XHi;Ek zTKX09VhZVCIAeZ<|5IHheY4|Il`Jfs<2gTy_eN|To-Qy9UbJF2I55Aax3%-Sj3F`fr~md8aI(a5duqL6 z7*N8xuqeFk9Q#{r`w<{r18p=9`0UelB|7dhO!AJJFS@(Ff46iiXn;BB_2XSrx_N`1 z0pP#yb;dc=)GC9g8Nq_SRKy#3@OsoCC^+J1^nphPqn!6y9zu0OuH+S(eLUy~2?a38or9T-U9EYJC-A_P_a;``P6 zm?zQYz~lbiAMZvVQ*#j<1K}Syo;f!SqQXr@VKso>*66!2aqy$wmbud*5$v=M=Ml4& zEr*4v*{x$vC?HVPTq!RC+XS+AmlFzbj7pM=JKiUX-8%2P04WO<@E#hG?2>Q zhpHR~x9&%2>48y5Gt!bcLqp)b!#;V08{(?BPC2*p!}o_@cKUSm=-nwkeA&=8Z1LrR zIr!?#fc_NKep_y{?mz3Ri0?@Jc=eOTAIo_Dgrmrx{`=KV%o-?`SW1^qu>n%cmC8vL?r1@lXOmnNO0Hy2SA8|mXGKXe`PFDQWS(x90(}(~m$L#(z=2dNNV!F|_gkoaP?Y8gzm^fAU#l+rpK@$LA#3axpxt5IkR>X=pi za$evytRnl@wqlXfTZrn^_7UF8&?6w%j;^_hZy^kD+eJOr2LB@awk8Qoyu*;Lh582j ztvb(~WJ>7?_~>y3rUAGW1S6KnWVHuB?01%I9u=E!LNo z_bA~cVj&z5vU1)_V!hDByrK@hX!gCZ z(kBOX@tpc*{!MWu4+nKS6>6x##}X@w#C@joNe1>fIS&WIJ6Fb|f4QVw=wi*mzKvzeT#s)nt0Ylk63utje_#TK}>HjS$DsNKTn0a zPSmMabmn9R)#E+WR$wpR)Nk}wrEsn5oc;bN;m{q>%j1&XuBs+fTwsR?w|o#%mwQau zwr^52gZZmR*!nK_0bz2kqfnM+ZsOhpJWCtw@$!myHn3jvey{e4`s}xX;QSu3bRf=Z z^pzeG+0t?ScZg|c9rqQAA8?Q)cU%g^2xKAy8JXb3zf$M5cG3Ja=>%dBldq_SC z)EG#uQpHwOn%Le%H?~ug{^LB%gU3Q_G%EsLSF^KSTiTq}GON9*=4g7Gt{wD1nrqp> zod9*)<>1^n6v~X$Jl*vZuA|J9^BaHlX=`Ksx3L{e8c|5QhFUGn#b!pwJ*5i;r9t@DGX*kTfvuQ||A#T0? zRi^||Z+#W17^k?>Nt~RXeu>Sx7_krtue->Zn=AF=np_(ceSdnm^i@HL6P+I7itc#*ZS@bchUEPD&U<;UDmsDSX8z)*hL0ftCi-9dXMPF8}Eu zzG*2koMo=pI@fAVzh=Iih$w+8HDvvzM0dBI838JAz4LK>l|^Fs8 z_p@Z(mRk^kJc^VD#LJ~c3ErI0Fy?>Qb-6jx!7{=|NqoBB}Ad+sz;nvMUCS7{`!14p` z7-pfXLp~Z{PvCO2UkxHrHg5|qQqQ#4qnC^NqI-tF7fn4-iw7!B+n(b?1yaq*64n!pqr zg9rLH;q{Q$_aKXw5@vQ9dca#dR-7N%2}2MWZrxOmg!5v3k*UC5maqu|@ubbu=F|Ea z5FG-++kH!2V{XCzj<$t%w(asjrv^K~--^q#Ytv=@rZ&2*O_Zjw!*j>0MbzDTI`V>& zElaR$uu9Gp?zEFYwEk-`eZ0k^Oxw!AK?DGR6*d|klqoPU0X7@)H|^yu^T@+RQ_s($ zVK7`a@m#1LMcmSr&+9eO8iywoA;X;5C7YWMgaa5S&8P10?aJ}XS>@rwFc&YUuJWz+ z89PR7z#mUx*nZB^mTwHdfyWno!fKn#H^UHoM)Lp41t>;{eSJ}fCe6zoX-dgSTiey} z7lfT&dnqaYFa*A!y;x(X&c^_KJ>5+wjUc$N^c%S#dkyRb#sbc=V);bJqr00U-3v7> z+h^fRpT-)=_*DG9j@GTd3xf7veE70RMv*;#yZJ8lM*U(a5%%mdyb-w~jynNF?~@jt^Grm@-^gOS{8vUl+D0KSfDY%6oR1&qn?1_XvG zaiHard2wHB&c$G(MQ}$t%mQrdE&Z~Zbq~G<2jKFGHZNOI%K05zA?uSavb*sKm0QiO zYKAH4d&n>>WsJA%!V}Z8m2{(zxz%z7SMAK-4GcHr#;})zhy`O7I^}3*Ve7JTVuCs9c@`#3sGRaR7FyvS4TAFu*_+c^$!@=x_B3e^<47^)A%5n*`=d zcEpFmQR=L@7NKOO+eVI34N^3}RYL!~ZqL}eU$K=+R4|&>-A6=mHbuYUS=5w@3M`gT6<~tqn8t_;OW4 ztt+-jplCcR9FW-7>I+M^^OO9-GL-Jt?3klA=yX6<gFVj?JVr5{T)ElE!P6E|A*fe+K^W=+ z=f?d9soqNst}OnPj0oNu8{1Kafp*^JVvVZt5hme@IS_CsnpKmcHkL>hfpa2yS>6Zw zHUNei3^J+mOy$7CT_%Ddb&zd9dL_8<>&RcCx@7C6cLnV5H1-{x3zLq`gr5#<>*ecA zq1eF|fBc)QpUL>i)30-q19lG7{Wqq3*h^ySX4_F*4dT}K|Lb933N}7sTH{RJ-0f!j7ZZVkV>1LoC z{8lmummSk7E+2xYe%Oboqa~1&>bA@(>ipXeKP-t+A@+h7hnuy7(dF;Nn@UD7^2+Md zT%&^P%fD=7WZAE-iftRe4={bxIC&({u4$ff+y#|?$Jk90ebVU}J^v+v>VUz`bGBl| z$|jGh_g7xp#(v$lg+fQ-07bSO)!OfppI{ON({iV%fgGM-pj#2{lFg^V?*q@vX5`l# zXi=g%SmyeX+56?*hO}lt9MP}_Tzk@weOo%`CQ4ulvC~P=$9A5_v z4S{mYiH)-d=hGFvIbs`FpyY~>R(KG*0(*2^@z|N;e_Gzg7jB0!!|k{pl(C#E zcP&+P!gEukB$h4t94lY}r;Z-0%6pnhf^Uta-=q?4-?gyD7I>8~R_)!d|Jm%>t}$-7 zY;*W?&h;v~bqI?lO*MF^&!PLlrMh@@6p|2pX?U_n+eqoaqX>dqp z?GRXg`p0W=zgTa6pJ{@_x4V-o4&%zhTLLA{wVkVtawvk>DOAgBPU%AJ$?@4ib@9@AyQpcsOr~pFu2Dh2cFouByFMbzXP96}# za7I6tICpPJgPSdRsBmP#&^0>32KOt*?yOpLfsO!V8^1}5D`&yjD9r>aXFB(g(AgW{xX94#bF|n3d^&oNo8>%k z#9^aGyQZ--ah@vt)JDs@#M803Wat?MKkv2_zJI!;rq3K+vlqmQ?%Ty@EkoIksj5Es*cs7 zJ+4?%RO*SIHNoxh@f&YgtkHw;02P}DFyVhTyuXI~r?vCAuOei`>d1V+LH$5$mzmGb;{)i2~UbzLs{hwJwO z_*SFP0f#2#1i#i%&e1Nc*;bx`>GyMyX69^r) zO5IsU<|xY37so=+jB6H!M39JaImJF9@}v&bW-mFNCKkN^)HSXFUlz0<6tx+@Eo;nK z_SEmsRy?AD+RPtmizfR)zeUmNxr)sV!cuK|j!unh?iO@1u`t*mh7rw`2a?AIM%$Cc zTk7J*l7%h`y67YPJE|o7`J2y_lp~qT1&TnX#%r<>)cj3puH5mGxxRihcgZS5iZ z^`(-$$<_G~-#qOqfp`uENd*KD+`<8@Pd@*v_H%7M+ROmI@%2Aq&c(ij>d7FK49@_jy&f3sLl2 zx|yE=TGa-&CKkvG0wWt+w@%i`*;pnK z|AV8YQuJA7B*r1+(C}*8+&EVtdvs%WaGbS=Q#k8<((Wka1CJ_{#RjUeSDJtJGDHR2 ziJz=GiVv$}2WRhc`zQp(moj(Rz0&h)+Hj@?80?}9}oT_WN0&V511SLkczGd z@W^Ybta61SOhSz~!qYu560=#`C5sw-ue9ZGw<(<8R$6GhUn}k1sQlTCc;8K~%Mk;3 zddR(!eU&V=$CD>ho_ad2*_zXjG*+RAFPHB7?+HDWjHBQklc9+}669Tr9VGwaAQfYB z2fv&XNgbrriP7gahdI$fOtZ4*XLqgAGD<2rYX&6i`9jNSEWsu_>0;MPzFsy%c3b#D zUIKM>blT6FLlQV}FAd)@Ng4x(j2dAQQ%OOiK$O;EV%3NO zOS*&%8?~DiTg|PVYHC;qn-98YfFr68J_>X3OSNLF&lmb)q}e*{KM*FT*?$~jYILe6 zxFYJ<>k4;+*OwJ&LoXJrs+1}pNWEd-$zGAgc|W|d1UO`P#sjpZ5v~&QN=NlFs@KhW zX;Yf1u{Hi6?yc>&n}f;@avO`}1%2D`TKb_F^o1~Ou0$b~X;MB6ic}1oqfCvN=`>GM zut@qpONAvf<=x$nDofu2!ZvhU_D6^Z8@eTusFSC^_7^sCEGsp$^4*W#tXhGeNET+e z*0ot#cO}r+2r2Bf+hV2OdF1i`?HW^ADi2b2?6{F`3x0ifY6ePj6S$#tG>Al`_`W{L zH{^^0d7?=Kg2J(zO@gu>X-W2o3~O?azQRI3R;TiAQ%%PK9quFn8FwTFt&`aV@jAyi zEk_9jF+e1_V^SLbE!3DPh+_(Au*-t>T3~z887ywJ^8Il)mb*?66_tX34@WQCMu6i zypCq{6nTu9tLw<62xoi*9^OlFHN~1$;^&+;m!DIFc|X6dh<)h3&EXGgg97m}ue z1;&vj$s55qp8byOKNW*srsW=o>`wf1u{uF+otcC4;J{bQ1evnsmkPG~KDy`QzoMVL z`6wYllhY!`mEDJ%Ekd&r~&Lqj+G%Q^k!azL}R-5-zWR=@K6x7O=9r0|b$C z9}&U~z^bl$&{CQEfUue$M`b7sOqr7k=PGr?Z@%=PLIS6J&%&7{by ztRFuT_AVDF`aKxW-A08)aayb~W+jzrDjHa=0g807TxV`qe-_&cZ34xBOLpN|Y4Y!k z)*DY&SB_u-vua@m&9NGv>s{Ugx7Q1nMQ-wLB_1JF@jnZfI?O6(D;LgjitS@*Gwg*+6L%zO)zo|yBR9-`JN8Ij@{@-zM)y;DdA@BPa@HDS#nEv(@O9>7%)=xOggY^i zCiqYPR26Te#g9eb>+`W1$+BslcvZ(LoAw{tR2V>y6tl8ptR4`oyZYC0Z$&7W^VKb# z0h(IN+E`Rny{CuF+u_i)9zRZs2@*SO;5f?`FVe_oX?i7k-`hSmr#<)<)Ewp&3vt_D zc<&m|VG7(PtgG4HuG=3H5z)k;X!^ca0398Q9Qc{(`Kv5xgDX69;&GiZltzZ;JFV0g zh@V>=c(>~q*HM0!&Azp%o=9odsfDCxdQNBsv7r1gQ05WtJm5=11A)`H`Ne8s(SoVH zATrud#hzElp$TVE18Kyo3%9z*z?wC6vA^*^7n-q*5uSk;8TsrlTUJ)#i_24LR@`AC zD~s1;7_&On8~tW+W@m>wkM!5rYY6-ib)uc;Vs62` z+-hZ7?BY+}LUMZs7!?kbK3OG*j*YUkUhs51BOe`#23e|D&StR>EvJp-5|j_vBJ+E` znY2vIMu3QoY+>^&mjLl7v;H3Y1f*Ocdt!O_tl)BBx?(i2ImZcnKmrBC9HrCu#Y+rKeLH3*O%two@(i6x}J?#Y3&#;Jgyxb9j)qcvET{AZn%i^`?u=ztD~a> zk)0Y98BsPAGhjNfilb7`vXNIh6by3QrOMjq)e4N%pkdG*M=k>+#~X7QRJa+v5K%5h zr~$}-)c_gw#8#``44;qVBYQm@21-2MEb~WvqVLdxp?O0`-L_3;NCWV4vxko7mZN76 z5^CFju6(L6*$yvxSy))ENpTUB5ajuGUqDxGI)}+Kq^9v(mnFMOy!{&*M0cz9m}B6! zpJ!jBczJYDtS$AeC)B`yOzIoBdcpU)dfI4w3~3P^xjdg8LC0Xj@_;>;GE5r}nMT*D3@-<@R^gr+@s?5%Lqd5MmyY z_44|kY~g=$$N%Ez|2h6YCL{l6>hE7!=C91ehE-(ada$p1(^`}|Ha!6daM6~UXcW6+GP0MLW?npIFK^O`u{RegbD%133u{-c>Pec z67@G-co5V(Ifp#>-HPJ&hlmx!Y6n!84UI@FBC{w{-^Be+PEm_{Ow@nTgLm@Kc1$ge zEraw^GrlWyXcQQ6wD+D_@g^M3(p2*qPnc+C=XVzBm30{?O3YBjTh#SSD|`t@3`zmwrXDt z$YG-V<}*n1SzRS}qudGZJzx-pzc*@TYKC~yYRN#lnxx05AS6?B>zB+q`7$_QNFZ^f}F|x7{3`YXI@n=9xiZekAjY>`{&lwoT>)yaffi$7WYuJiHl;(ud3kv zK$71iCg#svx)HPkBSTSqE}?W%=_^-7Le=cj#fI;I2x>b7#F^bBKey6#Q#8>sS+}{J_ZvBso}Q!pG8sej_^t*ehPJKxDz#Z8#-WN_(6B##)8bH| zHvX`HFucKNDGV1*N&ooZ6w9LB1!eEF_dVL$&2>9FAGf&;e2UvE&M|d1HKmkhnCxs8 z4V0}*D-hxpJvptkCKVWhMSKeq!R8jJq1%lpYMi?5lV1tmQ z!LCc?3;tvYUUt`89x3eJ@mg+Y4D%kZE8U$nnuCB=SaD9}reDu_Q>yd_GQTVD=T}>I#oovNgHT-UN@fp?iTQFt&$nGy2#dc3^t&iuoXSDa*g)#WB z(ms#8$l;((pv@UW38cG7G$yNZv8RmL2u1`DKOHLT-26^HAfR*S)c9ByRMz3fy1w&% z#i%`LyKL^%vRlSamsGyzS~Ub|Cx=%AZp-NR$QP*7o_az`&uRN}?p3?kopiU1fZZpc z&ppG_HZLX#ybXL-$afbkta!tu=!p)g{S^*C%;r?`JDA3B@UNK zYgCv{uUS=!St--n_qSyW%{GK+4qEXdmP9DX843&6sZYI;4WtY8oA3G~M9Xh>(I4$5 zt;<)?enMKoUru4%QC>}_Ji@bV7Gt4G_QKXDF%u1Kb@u4Yh5?BVxj0*-a|Tk)$lFy< zL8&7mWL~?VMjvIq~x>h zKkiaDl1FYVF;bth<$;G8LYP+kEr}_CzZv~pUfA(FnhtynW=`CEN%_)Pt>M5Th1IBc zM9|ybhM7HZ33JQy)H)nd?jo!*RRr ze9ElK$mBHD?XY>;-;zT`#ID#nv(y_J{B9oaFqIU{jIFIHWtfpn=TrM4hu7 zqA1O!l(_f}$z{oZ?|yY-2ajWKHcE5(4R*oACov$7{G6P-t4n?AcWm_7VmQSaoSj~T z3iIZunEN+#y2pMU(YkZ*_f^O&HU=rP0Q#<{e-JO*qOi-xb0iH74dg&NAt92lF-}|c z?K^^ng1KytW3677pg4|z>aVoSkAn|G4O%!ai z_X^kzu|~CC3tSYr#F6JSy!Pl`wyBn$?YC5v>3H7O6K^Ow`HAahzYo}<$CX9yINe9T zP{`Sy$i(!l5X1vKxg3qk0PWNmQu@+aye*}wAR{)z?%Eeq=*xAXsVhz=Mur-h+AXyq>)eFZ z<{(p8XSdYKB=4aulJkVNo`kCRGw$QgT&T;MjrGr86EkWZmeg3=zSn*iiht<=qGG@I zS2iEfxP;B=Ea+alz9+;q{zj9A%9!_+1V?G4aP4~(>mL+~La}dXrzg++CGCFpl%8nE zzoL-B(5)T@`FyP3a*iI93YU#WeU49stoU_p)YwwO^G%L8UC47+6t0l2s|TO$!iBmk z!_EeHhUkL$tnPVV+pgDHCHA3C&9^a)hn=^xtpVT%w;gUPQ=?aWR_c3|7N%M;VrCnz zZxb>xP>R83;OU@wH?5PyZ>*BB=|gP`c(M#$x!aUCj@lGXxIjJE2(n9t^IYHPRRMUl z->J_lq*4F2c4NkyY#HwJ?}TY?fzNK3399ey>V9V(miwvoD_Qn@1s6nne>-K=WRWxT z+8AnuYO1V?8^9kS`YfM{xtt#D``y{ra!2?-f8dAZ_=x*|Vs$6JWl)J$5w!a9**8cu>!h6)$6|)9S>ts06zXLAVm;+L{!WH(RV{hXKbE$dJ)_y^KX2&|!x9Pd= zdZRnraKoDtV6$6EzjNNxHdN3&d%h?Rm=m9=1mA_K+W7(@YzQlg)Yi@C}v^dUBHPo zOxC9Fle-IFRI#E7d{Sj_@t$p5oGZdRGgjX@4GM)ulkooTSMl+=eUV%Bp8jgQIPXFr z1>2?r>#d-R+PM#8!8^rCpPwJPZQYVa%bW$ek|G#8aGwhm|cmV$3$BW zU6FEH5)&KuL`D^*8?}1eS_-cxCW@D%k;=e-3q(fA94om!22MG)IFDz27x4rT+J^>F zGk({OWN^-{Gdmj9uXTM7<^KfT;HZ{@TYgAr@4r`$68(2ZSmehA)kEC_Ryar4d!8cgT3}WBkg%R5+{kF+m44Y5g>fh}?&J zJ%XNiZb{le-yPU4U~;3HvJQ2xjm1o9#}B=EIv z743iJ0+@X$$I=VKcT}!bMHtvH*_cB8nElPji5j&XNjWiQPY68kYor1e}L8_SQ6NeWCWn*T~#}JRb4fu4^x$b~s7U>1kRR zw(5GRB9qh7Y?jIJc99o*ZuyvTRUM}=+MAr|IMU5;q>%H^{c2n>!g9I%F^p4|=hJ)6 zwOW=x06`|PM4Wl5OBoVFkGl*{P-b}?_JKEktSNttp1md#U1Z;u8z~&;z&)j}xN#S( zyR?J^OO;>mMu|K&Mzrg_LV>kJEv-S~ainQDe^s{*VhK3=Kk!vm%u58Mzbz}obW$ar z4e7_P+8mgAm#klP?!vSGXj3gn1YwB4s1)@utuiLMaBT-5 zn?0YX3D!f;5ARTU(uPlM?s=cdm^1LD9xnpk=Z=k}*3mV+L|6b@IJWWZkG&kI)p=Ze zRg2MO+wggZEwB6+_{+Her~9&ch=Pt3BW@#`BJ2hLDU{Jk^JiaPytI()N&J$ zddy-vA-q7wSJtk1j|_z}4{?i<$>;X1aH?>!@@7r8d9Y+Wq3X!IXE{}#?5P`!%@~Y+ z|K2`BJUIH=rT`}*bW&uNRd;Z>{P>i}r0T*q!yh=XuP%IM|IrHflaW zGshG8Xb}++hY%p%S48X_vBkXgaF>UR%K(`(XB~u`_luX4{GO$3EXx zviPDX_T=ZusM8?E?0cGFJYQ~XS+|jlmDZ$W0nXAH&A2qejCL^3aak4j-abO;N8=_d zh>v|-@t48$Qa0KgCmXgGTFx3#t+aIuHR~V^)+e(TQ7?PG!5R2VDcRTe6UkuwzPsilzK%y{;7%q89~hX_?uu`Tts?4=TpA!a!$5ub4AKuobN zCPfqBVPn6)-YV$g0kzlukoVctK{t<|2a|SZ(zY)Zv8Wq`o8_44>>fVJ+>(KPfCJ~k zY^#^)3Yq0t1}K@(MKHs~A$8fBfyAODSHs*ZX&MnZ zMBl3NyL@d#VSx6v(BtQ$b(Kun&v&{FHk)^WWyyoZxu{X!U&V!KhJM_dQkh~JYREnwa`fkjeLdjyWC)L|g-YLs&x6UimsUwTqP3&Ck>wBnzc8xh7bud47;Gm~>r_0L80fdi6 zw})IRIUI`9PXE4@r4Nku5z6+meLJ7SwLAO!sKRjc;7Cx`Nd;be1t>gK(8OMRH zt@*iwg)Afg8Cs0A91@&s4G}sD`w(5ME)eXJbY4qyB*4SPotkkHlzPzF|6*-HX0{_k zrQHIi-{^PrJJR^&tv9&n-9C#n2 zq4^`KN-u3Ius+DY5ljR67XK%chB`ISK}-{9IBEHqY?m-CTncuwnd`qw)^Cwn>+(O8O9qsp5ZD#mI4{=>25z^%mKDm-(WC>_$XOpxHelW4Vn!(&h&^udN*Kf z%z7ZhyUTu1p)A~LuM1BGl`4`E15{_kMQ?{F^K<0kV}+217Zt%&3;+ua2Pd~NnrHa~ zIdj0C3YWfmrSe;J>uo)2(VrS0Vvg2l?K0}IU7`P4Y)!$~tPDcA9J`|0&Wah!n-)&I zFk6-%-ubKS5@9AnwpD6627?E%YB~dc{4zjFxW26T)pk8ht*C1Yis$)}3#d5(&b^Q{ zrMaL3d3`4=e#_~;Z-!ZJXxW5}8SS6KZ>ebd9VDHK%s&Nc!O_YO>y_j(9{ucULn2ZPB z7jL{O;oM@=9o|&OwD;opbjS4=usRo(>(<^9z0uujv<0#%t^py%5z8N+;&A;$aaL8Z z^7wh2rs{XIp;n}#6Fc*gmT!8eI8zeY?olL0tBnF;Nz)WdDTRZ=p+@BPRDo!mx}u&L z3XbH8k2nsz5m~AshY?Q57}9Y~GDWdEz9k5lS-LtPl#`dIA$`2TpE{%Gu#&ix8aQZ` z?wC-Kp76=>_Y*XXBaJ&EjJ~Pr5I`FEYBY6p&hO9c(@EC|1C;~bICst3Ow0gL4#K1yZzyB4WJPGHBYn;SzX@ph>IgV2q!eeQRxUPEn?1o;G8JX?zRo~QvW7}o zEQG>dm*SSAc`}jfwV#VNzG(a21R_!dZ&<%W<%nb7Y5A=o17XdEx~_BX+g^pniiTpK zk%MiLbSqP#-P@c9e-4k>S1lZL6sc(T*snAdHy83+)A)f-n?>!sJ#z}hCwW#P=k#>z zF5VpWvAP6HaL{KX%5q>UMyhww*un7~R@U%l507mW)LqreDBo}8aWi*8+!$N^j^sK6 z>^aL@`tw9gK@)aklh;uKVca1+4%YhkFaS)wB*258D<6HT^dCy8MIP3-X(~~U)$QsyY9Oi9TVP?Im<1DOt{_M}c^~Hv{psrdHiL^PkzpLViu;ASp zt#gWMGpkpm0?8GR&lQ*F!hI^`9*)C&M%w-y=&$~5-krt4mn4;TtEmAKSFcH?|NK66)m^IRDVLLLo-Oab0}&$jF(#5SW=As^15N?K zg=Pag*s5&9s;*ZQ>)<_SXN1)wAH55J?v0`V<3pYo^`?QVI{goP{m~o%*F5QL=BbPG zRL8CUxmv;btdE zJgfW@-wXcmyU=0Aq~<{V>b>{UtU~^Z_<+%DmF=#Q5gCx&sM%86R-VC0ed`bIsXegjVV@02NXRtTV#)ka{P3JDg^qm9R9{I) zdp%Jxn*Dp_S>;S+%}YjqD@G5YQ)6nH0(1fo+h@Dne;ox182{z^5Ux-B?Nf;xrV>ik zOpe$qnr7XN5&>UwVwrCdulP^4Dz}4sRWapCA7sCF{dmn5SD%OwfRaD`3#HG$uEZ@Q-MO(ln_Wm*< z=`TdT!EPCX{8v=^dkiKk{QJ7rp(dYIRVN@O`l`btBb=E2nZLus>;3-a955(lH&;nc zuaaXAmTEYaQ*thL0QC!jEsyEFH54nsH%RHlSAL$-bQeh z_yB*oN`y)nv46#^K`Cy`VAyi&c`uImnk$fjmDK_EL$|!E>jN1!8bYv}t`|nonyCKW z2BjVjOd*f`X~m4g|Gex*(u;Z$WGV) zL3Y~U{~rT}|K)^2;6K*m@ywrebchFH$$O#h2#{p^@CVREW&MckoP2`XLBg#rm8KM4_guvr%?x3Kc z=4IHOc82Cs(9-u!BnV2p;+1&N^n~<)|W1gzbnu#?z?w6qQ#U)fr&FqzgF`D*AaE?Q5u@sgDQ5j&aMO4!vWXG2>S*HANZElr%>aF zX3XQ=RL+LN@WH~uyUOtEU}-UfomWjxRNTd{{l|y42MqQ?!@V}{)4ZPjJi*`+2LspQ z6&lmb9p$5c+Ds8SdRkc4@~ViQ+yDLDR?2jGNOKI)n`AHB8u`wfP-^v}lPTe2C`D;2k}dD9YBsf-kFV`N&R*t|Ti-50rL3f+3;k8l-xM(u@= z_@QlyD+Aoq)UVgq*B74e9b8%sknSBXI{DpXwZhKM&QReTLppp>b9E}8b(^EcbR312 zQ)C$^;Fc^V6AB~R4W1~1;`Q$S1e#M+t&E4Sj;N zo@zo_y^;H9Jqojt@3fxM0}puxPpS%83?C^-T?5_;bf9sHW0de01(O=>-VhpEY4j7o zg;X{k)}R0KccPzwSzd**-hbYqwHiBUJ5@&2L1S^3hbgw+DlbSJ`JJoWvmVIE#~oX` zHDsBBb-juc1msRL;M^axdJxe_g zg`b*|4Wv9r%ZfXB;D=aN^r}2{-O0XRu*eGxy_pg>9E`UA`@{pC0Z2p(gIG z2V(Ydoso~O+BX~WXPc<6v(H7w%|BEba@K=&`pfyR%fQ)dDVNBgO11+E+{M!Gp}ucF`mlzwP( z`>O1IyojC8RAkUr=Y?K2XK!*fL?ncmeD^V;bRH2d2yX!eGNwch*3C|{I)LBpXoi9D zm;k`7kyWAdowygY(BgjcsKLIx@kwy5pfxjjykjh4m=6_4aO81xMDS=YTc6w8iokJ~ zlD)kzA+T+%lW*tbQ>K8Adu@W=h%fv)GthKm&IB?`U0!>md0s3f7_vMYoNslR&v2kg z@IakvdW^GWzZ^q*N+~lnnN<<^w$5gSy3L*EJMtXwbeKG&CxOew2a3B6 zw>S9Y@_%R}aOLIY!>zwf(7hE8h)#ccljK_>@W@$l)Nb?E>(T7@oc|M*6g|!c8*72p z{90730vlz^vAn{Y*Um4*%nd`8b41WTeEj$^KMp!yLyhEAWvJRvQLay&%Vvf;bR{x8 zZ_bUS>kUVX6w@2sh#P74KhuBYx-;Cs(5hppd(Gj}8dq&cUmz-ynVvXRee9F@ zBC7F?%Q)Vsa%bvwPeWU>>dUWlzF7u(09;N#rvHb#w~VT5>AD6X1WRytg1fsXIKkcB z-Q9u(cXxMphv4qc!QJ8Do<6zvdG0HtyGMWD+x@5a4;cH5I<>26)mgjNoK1VAtCtL@o(#@%7rc-p^fJf{K{* zukG5h*-G}eR>>;(v8F%;_ZgxQ@~tPCldPQ#yN`8>5W@Rrn=p3?Go>uxtE~Ix={_!x*l2u-~_Ye-3<01w_6CF1g}Maa?rZT^szoIXW|S zvr?nntkRiK;rKB!*_;$}EMxLI-dgZ`oBWf&*!FN}{h`7jixo(b%-oj9W?G2=KRv>} z?S06TN&j9qh3|=6#)x*mv(v;_s{QfajgXw5DZR9hgz|M$gg0AX|7w0^JZt&0<1X{! z#a(9SMwhzny7#4D#(A(f!ONG24FU*}R9f|>>?6;tEe7Azk>wP@4b7yUp4BP|DdNz@ z0IM|(_X*ziN7}=W$m`XG2U|_V?DU$08~1&wPfOj5j#hBcsto$p`qr1~sB@nFyBl>Z zmk@Ikp{P2eidx$lJhMU)aAS2X2xRTByuck#Va~*M zJYYP0a&29%CH&9Cm`GNm&4FLHg1kq@k7;nQ4C)7i3(r+s5ED`G=T#9OC!5UDcc7Ut z##n;`B_}*tWLw+6`4uk+ji4EYa4D-dBQsI4j#l{LM8ve+uhWt6x!(^td-R-%-70tF z_SmNIYPzQ^SWi;R_ql(W$%^Tga3lCW2ryDg@#19Xi7;fSfW1ibAzh#1{hnWVNsVn> zQ0q4=5F-JK$5s#|tf}pDJ6v7PBri0G zwGGnbbabhcOt~2>iq#cxj6e_Rh-sa*mu16<&Yn+LYL!rWJr*pCKLT(C@)^k^^+$5C zmP1;-vAiolH7as3O`%M}2fV~Db3u7pvou!#Z;#RaKqQ%cAAG-k#vhU_nZd>VIEy_* zxZRJAIQ4zx+)^8BvPRvT#1D>!Jz(_NGqinMBVsoQk0Q#FWbJHSeI=LnytT=f*CjnN zh5s$I{;SmQo_>J!^6txAXJ@hy^pq5BMY%1znk#Mp9pRufFf+6UBG;Vvel7l$eI`m@ zEwr0x5HnjHz!tjolb@-@(aU@B)rDtsHZwiWGF#Prr{|QZhGL_yPgZL0{(k;C%lg;K z8w^DG#Li3SwTI-GLY*@W7`nh)p!?OA!Nw(&RyQWlKLwXZMs$KrlxwnV-;E6YCKwCJ z)-MB|Pe#^Io|%y=TrpP#K5JN*YgbhgyQ$qyRFcJoJ1xste#j)EtQMMzjEJ;bNQpf^+z96 zf|$L${h5C`DH^SYn)`x>t7URVwZpC9ZW^pC&f${4R94nwVNl=DDqBIN7&}MAmY<}P z&m3yH-h9dKyu%>8p#4yeF1Z5Rp`~fcCg^!bbkPadbU`TmdT(3nMCdz7$p%yS`_a?B zT4(HrWy3$@;`V!cfPUu*Z>1KQOk#rF=DQaEE6{RHMi&<^)!wYKI;VM*UiU5(t7U}84h|BaY7oGZg106NCv<(`y8fT=@-W8&o8E&3J z=FOrdC+UZ}5542Y9gQ6Ro>?h<;}qF@Bc5@mzZV5w;BPN4j1L##_yNX5wZ2|-`O#7x z{jvAKLn$|FR^&x1E3Iel8>{L7p1v!xm2n#T>(Y!$WjUnE)khUEOXEhA=XpiFTDfiD z^e7{Y_X(8-P_kc zIGluTZx~rUHg-qq)6J6o$1e}?2bHws*7qnX9T_q>*yFlwkvm7MMTMIZ8TRnzEC<(i z7Mw9dqfgbbm;$kfIy%FWEjbrvf_2%Z zGi{>qxouw-#JHaf=jT%U6+K6_)faTk|L{J)fbo%UI1P&a%dLzIMc{{Prg?F zqkgxD4yh)Lv>y^PlbL8)jR$kN=Kx@*R{vsT`E6tFv=7+$CLOY@0AZ@GPrW>Q+_Ak}u?@p5zQn=C#w zM^06jdzUlEx=u~g9T^xO^h%38#fHWM?}X^J^g359TfFw7(euF4b#P*9^1>AF#B8L< zkI0lqH{u9p-b8ri8@|uTGst#k3|$ORPW3n89c7kOs#!T3o!~HboQwtrjNNZZ{;;Tw zH`g`7BFI};(#x=I$3+Kl^&)bbI*<{R&xM6Z1b9+zqh_K-DMq%Fg$X29W6KiDF*60C z(1LobZ=#6g9=1^Fh~G-pKc#Sn^CdtHF&T||9Yo0zgA`I|2txuQ^*(@Kv&TBKX7w1_ z+$P4e1_E|Y+|Agfc?SwV{(LapIXg^Qp7PZ-_w=}p7ZBx3-4u`<2*q#s^r?8w*(59h z`Q^9XG3Rr~Bszu@{7lc1@>_$=zUE%DbNLS(K%YN1n>qg8?A)J;AOYfN9A`L4=X`(; zCcKG5{i)q#K39Y-hmT{lSxorh=%b^nab8oxI}B#}JA#Y+FPuR-3>2fpM;tDkp?9x* zCXxh`%X^3F*SAjm4I1X2*2GNb<*vE+TDY-8am7WBYo`MZo4@$tZ7ptDJCi0Rr5~cm zY-gCW(zj6p>OEIH2KECy|hZ>5FV~?A0eKwq3 z$M(Sn-oDi*V0ufH=TMINzE)UxZn4F~!gQZ@Og-EnbnhJE`zYVNl()uH(mNW|FJt?h z$4jHsYZ3hQuD4(_o6b?ptUea^c?SpE(2SoMC(_3ME-8uIU4J}itx*ezzyXAHV4S%C zAnCq$IIcKhcn&f@Ag%-3I@Ky%HhX7G5F|}#55nsSCjk$;-6rozlRu8_clRsYEc#D- z&LK}B2N!2kZ8&r!`OXzDYehgy0*^@$J!#YI*ZJ%+7XZ-wxnN_v}=_WYr4N|r=o!7%^@I4~3`h9Pk%wK~c zZoN|Hb+@3}074(_d3cs$`KbaDT5nodR-4VIb|l7mnd6d8N#nTAk=cv+ht7z$7w&Z> zU(cQ*U>$e*MaAvD6pG!))VdMarjWSPzo`ZiKK;OsHky68b$7&M(T|D;N*2v9ApII? zyOpSj%pQAX>M<&k-N}4h0nr^|rE{S}7=q$@RFfSOw3*E*9bH{ZWlCALz1QYz%TCg& z)r7vUv8UDDUGgtk)Q)Jwzs`NBYCr>lX%qOp z%01exRpU|kJUHG`Q(1iASUetX3+$H?kzN+qa4)T=P}nYl>3c-?6@fov$2%Q=k9OYS z>sxO5ZJModi>XrY632b}$=48d*KU2(T07@SeQMU?NG0i8bw&m|#@bzZSY;Ua%u3F3N zTu70o*eN`V07VDe_ljh`P_=+|?ikf@RB-I>ZVf}LlOYNs^y8Y(x(T~a3tgb&^>*=qbi~RL5HM)m2^T@`%>i#fkX|Vo)!Y#I@ zqs&%g1I5L~2wYpKxq?LGf|vK4h*V% zt(9Becol-&<>MZ4yrBrzP4VI*o?^!BY2E|rw68V#3V0yy!konWl(I-#Z*9_j{I!qV ziX9cv9xf|Rl=Z9lCvQ~M>(Ox_UK(RIcIg`y2T?j#IKlP?2i~&aP6$H9IVyR|JP>s_ zt2sns7t^pW06S^4KUN~Sf-P;d^g2EP(^$@)7yzOsa7ixv{}VOQMMM0aW9~Y!*YP(F zt#v>TN;x{5YVLGM>9WHLFDU45>3uG~Z^;%-Texg)ptPx@5itkRghXLkEKO1VoX}PO zhrz2sMsaYLI_Av&qcg1fS-QQQ^;dW3^; z-5i##d$uWm7R-0Xxao<*cocwOSaXek@dSm&!tt{lK_wbctZZDGUnVnCjeK|fVmL+l z+2V9Xbp0JbDf$Y+{ysJ2U=B%Pe^?+{pQT_YNWF`yz#yRx!k!h{7boz4{Ef7SglM(? zjq=Ban_xkqqoYsY526=#bl~+(yQ2ODg#U|vU=8l;8HuMz2ifksxjB_fduMAO=nPb= z*}n;l&!0c%S5!pA#i2z0XcGt3l#q}hr=lXNrl#f?O!5cvZrE!1^HeQXkdlL!7w?~4 z>gO(oe*pOTKLGr?4FxYR-hTt&%~3GIgGx#$^YZf2ctDcL$gL4(tC|09{Qm|A@E-@N z{wryqmd5j=T-n~<9z;p}LFhrM4?7>Bpx9ku9I`-69r^icqvgdv=ayb&{y*VK{+EfB z|6hNBb^`y`0`+4TXiob7ZE^8%#n+P(5Qj7wFUtRK?T`N(!sq|W*#GZQO8@l<+V&cA zgR>utYa5EBJF9V>!mK1IUdYY_lfH0^tim+wRIN9$A$V${5L_q{mz3N(I|0Vdyyl1&RvI~IZL@1C(3*17dBw3-#d*g@a3XCRGlvX z_Je-}SjG?naOjX7Xz`8j?&ZLddkQcV>$qBncgVz%Z8K*4eA0)P!`A&8P1IrXJ1pzw zhEpN`j4>HD=17R`VHBo}7NTFT<4kFV-t? zUR2Q#@Pn>Z?6I}9kI`tNPVgUDetgN#xa=M=UhA+O{?KbHY5co8INMo?se1e^W+k?H z@p{!<;QCDWv-r!@1?f`l(9fmcBRR68Q*B-veD!fv9DcrW34X*}7*5sSOx``M3B!e;K@Z zPw~6t24?2wnGkOeR*|-U&(T#yd4Hg}SMscsZZ^_sBMEuXb&D48(e9OiLum^_wy*G@ zE$sL$K*(q#=H6)C#jMhKE4u;x<;NZ}#3SWBj|cw)eo&%kCNB^0qn59j?STu`jvw}^ znz9l&25x6H;-IpecXiuXg~gb3ddt%tiQdOpJ8e~yx1ouS1NEVE!mO=1YseOY5aSZQ zXw~(F6F?)Xc@%Nh`gZQUpFdRht2edny4m&U=_z)~em^89F-hyd_QBWu-SWx7<;_j; zG)w4G#PeMmhKAZ^ib_RDu7!JQuA1=q*4DGqtYF&mt-TBQhRwYfwxegAv&ZPKMY5Z{ zFNrUiwB>sD5Uq*Zt~-^7ag<*A`0?FT)!bs40mKsyRmV%=>VQSy(M8zm19%QwGo0-H zgpyjl1u7p0{{C0lNKoJ$$#^_Dh}7!c?ZA9HS<)QvkCrRG#u>GkstR)1{1dv!sL zc=JYS&Pe2Tx;^`zc8D~`!dI}4A$?cNKru#L=Ho?er)*vBlZ13}xs+smc~H}XJmIZ9 zNlj8mhddNjcXk>#+8;WSwI2M`aCDY4W|^R_EV^(>PF7^w9p`9JUd6Uf9AJMwdH9w$ zx-`aXbk3RE7`~h<~W|q&nU-S&>RiB&A2l+dQe7|3BZl4$Euwz@P6I#k+dEJLv&m@ThD!A zkXwOmdXt-^D}}vnVqZ5;4+ICOSequ!Btp72RM#VF`5KbK3gB(6;FCF@$sapZ{mUJ) z!b-PWhH6nP@51h+os1L?a}K&V)ScNCowSo)$inMyTO7J)w6K|%%3nESU~ha{ zRsutGGA?QSpMsY012EbO(wq{jGl?tU*hbtx@3E9eVLR&lwku zZ>&GWXmbn}1UW#uww$?z4zB+EM6S8ry)`~KUXFx)6;FA z>~Sky1ahf%t8f3g6pmNO2!q$)bs1aA=t})AE{2cm!9(xs?B{Bm)Ob})pIm7qF!uIM-P+!_?_Qk(ZQIxpTRG^W1F*0I1*HL#MdF6A%H1=>^d%V7R^J;Gki znI%u|UH$5YCx46X5Yr{a$D9SaD{a)dt%8PU(Gz(ObMEDR-f<7qw7-XAXr{bhlGjRtYYA8(ug^;aN_X1y2zk1qz+zld7$!eEA$`9D|4IO!R$> z>vA%BFA-{~%kB5s7h}DjUX6A6KQFm#S03}F0vNKtDNEG;9Hl*cvCe+#YNcb1a-FR{ zOd1BmI8Y_j-!)oc=ZLq7*nO6wAgQ9E*e&$;SR^RRsVND}u_4kD@`1?DrZ13|>RPQ$ z4t71;U)kUQ&UjNvtta&m-q7gYqs7NA$hE#3)EyA=xCy+OU=-}LR1JPD{M5xjO;nPe zY00UDtqVJ*JF3T3dHe>@1m-3@k1HfD->?%P8)j^ zd~o9NN57ctZ?9npo-K}MkzKQScK|anTVRIrX?f-@1j^w-+#FTZi{UBfxMv0%PZ+Gj zN4C0*aM!vo*$=)yLZX^}x>iZc6U;4nh~wJxb#qx}=JElqhmWyFmgi;GcBu6BIqoRJ z9bE7VYYbT*oPn2`$G@T=tXKjy+HX>FDy@T$x{ z6sl9P-GN4i9j_!6uH2r#+&&b;PheMEg?+BOmWitI-~@!y?SniEm?2?HqNzvl7N<6* zBp8C`uT=c0{C}I0*?e`~nC^$Ag6*Lj^;a$#RVJ=>MBuM8EfsVn{*>TB<|a!fOBF%^ zQ;+45_08IoWw60}%dv98tdio4#aCvwv>00lwO#&~Midoe0^`NYwz&J~e6P)O?u`zT z45aV&X-Gy1^`-!X!OAinpD#j}xP#Lt#ZP~&3RMrA2Nx#3JT;}O*#)4FD|{aZ^QR== zU7vDu6T92%cTZAx0i^>*WW4o5Gcl?z{Q0Bv ziOj+0^>Tc*aT-T~z3ug6W^={fvTjq5jO{_1bABKjgZFsLHec%9C4}2imxHcvEu?>O zN``de92u_q-y&%>hiPK}1T zT|lCI;OUr77z_3tE?7hBb7UZ$0g{28IJ*7rq_u!Rb=Oy>ElTn}oOjWlNl#H`h(Tj6 zAyj%b(vC{b)4N6>qICg>ttGw~Fo2JYplD@N**fc?^D?+0d1^Hcxi;T4QG6eIB^v9Wg)fyl-RwaUTvNGd^tEmf zbg>=DG4Hu~70Ng*=yuxE-gr4k`mA#b1?z2rKcxoG*VXL}*xxh4+q0VyNdxD7h0%@q zZjnI~wXwQ|p3C)G-pWqw%NpCZV`(+R#loG1X>g5FA_LT{tbBH%esL8r*EXm6nyu2LiWXk+_tMz=7h+ zmT|rW|08L_H+EaqR1T4Rn)z+zc>ZU^eCBP#yNxc8;&m<4t&?`tP z>pLvgPdxaJt}o@Y&3IngV@ltc+)+{Zllub~yP|OSFGPKhXJcKTa(<1MVWM!aiyeV1 zmsAzk=7WsUSm3BTp2@E-0H7M^!|gW5I2aHJ8xS+zv%l0j*G+Ymu!caTaiOziaWk*@jogyU* zH?Kqvh6+48eleHBW^hlM6rX-r)YfVf6W#K_hFA-`H{gQPoM758OmwxP1$ zsA3ldGTuJ>V8UxY4;d|p*4!2zGQM!O8jTrjc15oo&Nz@PO5$26jN1%NmtC^AOM0$# z3Gr?vxj9w(U1tP%0Y_}+8-IVzAoKq(Feku@s+jG%8IGC z2t*b(VvNdtu5d)wOfu6=)3cY4Dty_(;=1;se#e!Yi+d-~pu9zs9P(4~)9;ZeM-xYt z&~h|HZ~0S{KuM$HXRM;s3@z&My?O@FfJI}fEnM$M)sHc`ws%u|kM%5-N;#*Mg$;lI zO+v`td_UG!H8-hzVRN6HmdVP~j(wNMV{T*99dxvY>6kCOTeol5>_ZEIv5I!*hI+i$BNi*4=(^iNH9;vEdJN;(EFe)fXSSj8TEC6Kan*Z^ zy)|kc;a+V!PeyPvG$x|&Hydw48VmMo$uG3LDMdS8qqgeb^k3a5nmQC3z6MtRqY)4l zepkPT!It9`|5FJKm7g572*EdQyUVc=pCLO`D*U^_LY+9}@ZQx*R3;?IXH5>x8_9uZ zI%slD#&8m3ng~L|`N@+G1%GRuP|l{`k5Mi_d74)ghQt9wR}`nEauhOeLYG_A=YR@` zwgm8_redUzuf>>r4I$wtNp&0 zC({2jMNS!le{M&uffB0m5h<7_>&tGZ{CAd-2J>bcF&HM|mZf(#eHzLkG=j;3RpK!E zMp_jB0F^M_-`{@(Q2f?bd=K;iE2~N5V4-rMHz7o4WSof;7cB6j2CN_~trM*3H5;1k zi>9yVRr1-qusF4Bn@10s8o(^}L#icwvNFa=Ar65Js`Jm2A(3U%1TUPFGoV{vcgom5 z+GdurFD+2>k^^60jdUMz1eVhkUFEfdrq5C5W)b5?duYZjUYUoRuXm4rSc4r3<^a>g zyh3#42&r05$FId*7$dvxzvqfh5&y@u!&oei&~jsTdY5*YWQR*xU-KVp$Ea;fGEa<5 z{eo+G%A`<>afvH8C67GXx`*GoM=R2uPd?XCPSY09rVX(YLX=q$lTs%ZMnB8@JmOk; zxYjzU`mS|0!EO^s@UL%6ZHJORCdMbmZdyW=C!)y}sf9>JkTr~mUaV9#KYg0ZBW6t4 zGdQgw>ZCHpO>CNyN9oX2$7!b#-+#6d4pYt1UV&Ep5Ed>K15ljFNz3KxX9y zv2V(A8`Wtj9?GQ0lOhqSB@%>%-e&##Bz&0Rf!yHR2l;plD$1i{j1K6|uFdqXwn5=R zB1(;oB-959&Q@wB`2OjqU4S!idkZreNW_2-Sd2qIa}zL~k2qLCGeHP8ya2E^SLiTW z?SX1>eEs8hzZwQ#a{_0#9~8L{caV8BIm+S{JhUUd^G)02z_mt(pGlE(Sq*hk@nWry zIcaC4yJ*;@@`g2s9B0L(D_>_$^-zXr|Kh91*7tn7;5xu?+Bu9yVrVP3?wx2dJNiA%blt69jU`D&C zhP{I=pkfWE@Mpd?KbY*}5;6>>dit@J=aIML1qQDv18dPwOgmwW=CooV1pLNdWo@m3 z2A2d$8`fz&NNGRBylwrVvel5ICM-YOd(EdJ@#~HPDkJU}Mxw<2@b;1AXB;kl3|i#a zeqpKnyI7A(^`TO?t(m(~(<`EyIW2e@`W1%}TZLaMQr7DHPpTy18Gx5FiOGXS3HnUd z1bniwMX9SbMlTO<)rSUh3M}5XR!V9txGgJ-iyA3o1wm@2Lc+LM^pRDx)P0GN*vrNH zO9gp;%uc$3k23vz+@Td~US%k4snE61o!y(kDf32?K5;#sn%6LFVo1o?5`pdp$u1>_ z>@D5T7WLX`R6MUtgoh31G1HJ^DNBnc9P%15(Td=vj&%D$Pf9`Lb=2dH$UIiG`-$IUQoq?e zQX8~u={a{|qeW}RW0d8l&$w#0&gmw0?aNJDgbz1}T^Xj53sFZ~(toea8te3Z zEA+D~mRO6u6W!-vpCeG7WI{>Kg7L-gFi2PKvMf~rVuA!9lvgZ}ZT5&tZ~q!kcgG=!#0ru#lIr ziLuefr5t?a6o+q>Mk)niA5elU zm7fa>7tgJO-2odOF|fMp9S}CvKzSe?ZjhxT``(54NX!>LjTv{PqGZRuJD+ao~pBRLO4+HV{vT)Pf>=I zg0e`nJ65@vEXL{yA{+k|%5i?HpC}NEUVF8>%qz?y^T?Ft>CtfMRMk9(vpb^ziK9-| zrh8L3#F>C-(Y=6mMf~>`%e`@$`qJW;`vs^6)hX+y)00ZZ85BvY=&5Z;W><4hKzSzL zuqR=m<8mGN(igczb9v+x{_)}?1x|~15ve57qC2#1gI)-VXSx6~)|2uyxi|7A-nIo* zErTy>r_E1D=^Z~_AL=;GB+pnqzA2-d*%V>R0f`P$?}T>>cuyht?JX~|H(tswhA5x= zczAj z&)o#+;@8yLGZ;4CAYMN4%wsJChm;^CtZJ08G6n{2Eonua8ywPysWe1C@be10%hrdc zkE@Xj3JF;=phh9mXUBZ)NFS?pKBrY(p|r%36&H7H-1x!86jW1$$J~~QC{XTq52rFU+Yt|8JRd)k`Qh>zde}DpHZsuWgkA#r-g`%6VuaI?b{NB zI`-u>^M`g1LW*)Zfw45oY9HxWr{`vv_s~($Tf&IocLPd|z6Ahg zInoZrY2(}6RGg(HF(-X&VAH7)-p!67tPOo`j^$+sjqAoGzCU3h-sEs@MJ{E+C)pru#@{;l=XX6Xrz3sFUsJZj?Lvm4wr@9N~f42T9UpaMxjIr3U$n%%#T0J zIV=?Iddb#PoB_q&w!;NZ) zsn;Vq*tM3#K;m>_ty_0_B|<(S{cBdUuEy}~cnIW4U55=_Ny03RM1@C8(Hdz>M;Y=! z%@@(;4uEp}m^+GzwwzYuf-q%c6a}0=*ezVCdZ^fLKjuQk>^{oUc{l6>;Zfx! zRkvRa2#;-TdXK?nilKXAWr3&t8$Z{}9v|IzY3aA~HuS6FiT3AEEV;2YIe=nd#=Jeq zBNgAHP8#bC>d50`9tg1CUWsUl`mc^F-{!_XQJQD@va-*|MVNk~Y{A83-L=4T=f+p=XQqRyM*q!{GutG@FF#iN#clmydf{b)FSdAbxW z=&JMUjB)xQx68Jt7ro^_V`A`xfMV|FV{j%4zV!(V-NSo*((0gZtW|8`V~phOy{{c@ zuIiq%h#qSq;KXMm5~z+*b$^`GkXZ&TR9@a^3#KI^7$(U0#(G&`iLj(pF!&r3BLc8H zTHinOqL~c}tF0-hkbS&AU5c;f1WJ8sjCs`hAn!-gJb8PnH0Xk zr3kh0Xtg>Rw+JESL33*;vd9bwy9y+1Yl!5Rj)WR8w*o(N_F0h*PzPc`YOLZ#0~QWi zW=s%2XBHJ5yz|A8T;O@2-bw4lSRSqmEty%k?C3M+ez*&IdCcRCD8_JppOslkGjpI$ zk%E%oj{pmB?XY=hth5z)S(*CD6WH#*ZUem~@V+as<}(F>$P4x>PDHkWZq6qmR#q@X zvjpq8O;HX$MRZi{Fcz&G$pDQ}udQ;5_^$?_&*5Vy+l5>7idAL0VlGTm$)6c&_fuQC zmZyrI?b^8J_weX6zpRprlBC{9`~zPK!eLAB!~Pr06bvCE8KL;+4u;8y^ojIeKqm;~ z{24)-{U3MzF|m8`e}kZZ0MP#jxadC>|M!RHgK$sR@^lVc1Xq~<08@oPez>n60PqCa z;-62k9Ilr$|I_V%M`H=vQU586wC=Ix2kmpsZuGz3&{R9EKZ8Y*RCPK(KR-P^{j1S( z=_gMH@@bamMGpXg>+9>Qq>O)I_h(?3M*qOSe~QOdoxa{x`x=9Y0W3JEi}uT|Z3OfF zHv3(u$jC_0{0ge6VZ3NP%-Tlyt9*V4uL}jJ1`xSlR8&LKu z=);ft(`Qla}N5|WqBFJ^Efgn;T0nOpsh-g!lb(V=&%o)~LTR_zil8kJD)kWhdr>!Dl zOj{t`Cp9cCOf%^;53jgVa5D3l{8C`Vdh?7S;BNb@Qo0E#7}+c|y}G zKU8)RR>9UXr?cHFE)#ICQ1+9V4eYGY{0u?dXd>x$I06?6hYWsZOWa&9_GyeX7 zo;q)_W^zrR2R(TC{UDpEsB5zA33WADmC*2z$9ItHkc^!Hq3|xB)ghnD%QNYK;XB-L2p4HPLa; zyIPB*sd|rG-B+m-zS$ixw$Z<~5oBSAz$Ex1nNbbC@`drF_Rl<`swf_Wk;v+eUDvW)ahEfo`3=|&P%C3)GaDjm#aS|rY z0y|r-t1ngBA$ub2r*wKNi%b_-WSl(xHb^OeJI(Gu{~G&QRT2hAARw560dEgRETH`k zhZ<}W5dL^HZ^2sJX}vN)_`UT}h{_!kv5tC&L{N$)m`RW*&@OgwF0%s23206<$U5I* zNM*OUBDR`jRw+mUei*&)9@Q~rO**lP0&=D=Jzpgk)Yt!OqYnz(;_b0cr! zzN#awBOZuHH9y7^IJA5774aIAwjc?5agFuKi{REC*yIhKH>MiD{m{u68My*=0G+Wr zEg`G0l*ZMxOPaKB8iELACzsL`6i`rO)0&ud;m&}0VeI-WF?!+@p=cW4X28_E;mYCQ zaHzt3=eEeplMWk&f>3zAA5#e>eMd&iooF0hDP{KbdOXP`%wZC5UJlOi8AzjoB;at- z7952W!8RU#Hfn)~#WkaW8D*@1*_Z^s7;m*=OyP6mP!Y9j+=)7p{Ayu zjYFPX*}LfA(-1VlM>$oDhSD-ZlI$~H zPuzBRe9+53A{#G^Y@|!y{&e$yp%m?WCiR?S=5((@;0>tZ@5hM$~zp0f}d9RQzXjP=ZgQ$$YrR zc7>Sk>$;=5HeWnlG8=Y{7#O-W22bEW!!FP~N^0QfqO0u4dBaA^n5^uWkxx&wzTMXg zyS8N?b!xlaxq;n8Qaqg)ks<6VnK0<|7@P+31FBre&U~FQhfXqLYGacnG3(t6(KSXp zue9*n4wdoTF(p1lsR2b1G!S?wXOh zz1%flrh#nLrS%dQO?)VVuV@@T6$d|eyTR<(UwKBS46yP&?toRA-o8(i0!Q##=2c01 zimn&amL*sojjO)EY7v?zpvS`;9cPA16DEJ(YRTIpcG23#Rv=Q|=hU0ys3QGp-Vxvl zSB3kowT`ZSE|VwpaZuIg2%++PkV|~dK(A28h)ljHsI5Yn0MD6QRtuPb0$Oe!L^cB5 zR%ch*&Od^ObKKmY(!iEa8eQgEfZ;@6l2;DL@7-NqzXL35*iGtr^84>7Gi-{hSP9c@ zdCQlI3}Bn|n3U|!+=6JNf+Kf>Tt3k1^O0wja1KXhl06pL9IQWZ)N|$pHfXOnWyTh9 zdqR6kQXpk>r^6n3b-O|bXWXbi+`qhDHr{L-;T%%{1b%a+!;{(5*>=BhhZn2F*FIn^ z1b8Bel6R)f&Wd(?_`ujSAJiLzyGQ?&31xwP8ZE>v3zQkpyLz5mcD7CZ2`96 zyD_*ZP|o{43+QMh|Ma$O(`AvGT+-Y&+X%{DLHnqJ>5$joNYP1~{c0gQuCHywe{xc@ z8)TczUt=~>osW{# zlCrW&bZ_#YtVr)jOBboIskHHU<-MVXAtmxG!(0yD8F6cEo&d?P8~Lzl{I&}vbG9m( z8vufmsu^if%SHq?xXabCDZ)-*YJTRqaGTpR?jstBZ$Fa-1@IUM6yCSRYGYnM=Qinj!5 zIwlIKB18^ySH8t+ulp^UiOqozN!(v>`#nE;Y`k5;FRGmmuf=|Y{dz~#bWYjJ;?8&3 z^&6@boc;ddT{78SHV{jj-Fj8n!os4sTRNQ`e&xN2V`XbFI)bgCfYOG$)rJAD?rnYH zV%V~z1;2|PuxPk*+%2m!94GvoASngedvA6xqOs zruaf@4$bdsyi_wLf)q~4_wq1NVZ`&212nfp1fL+;&U_gKmP73Q6N70*%V=n(!K+s+ z-5w$ng=OXBHAN>lPs+?YGpMpu-sv~Kn_Yd3_=v>DlQNjMs|4n8Eb+aT5wLm_k{`DF zqy#-hv2qerCi5#T<9pZwrs?UYwt$m(&lR8PZ?|FS<5^%i%#Qo*9qk9mA0ntNen3k8 zkkQtDsWBikW3La;21NLnQU5&oRCYN!lAn<=w${^R4OninrqN`#*+!~Yzjd=LRwQzg zVXmLZ{pj>iSid8=Ok&azA?j~BrC;`;#}S3e6C21Z;6JeRO{DO2v16)jwpPYNgk`A0 zaE#%bSTSuklGu@u@H%s^@KB~-iiSBx2hrjXu#xQbVWXQNk$#Ax?Ut$5$V$)K3!z68-S7%e0dtCCyyT&^o4pJf;zyo= zlCs@0rwYvC&>^bDM`!=Uc{dhkil|2Et4*%O$!8l(+VN0?UnL&;GE(HLYN9^+lAnsB zo67l&@=u3pL?fu>93oH=J_D&(fC|bT9eSDU{27*e=e>>$=5xXId5-UVWhfW|GmiN!I|V-_tXhn5zrVMA&3?HPzUMD56_*sUR^Us(nT)`< zpt&F4?DH15m;&!!v9Z67L?C?X(sxWTgc6aL>-+!%pJg{@_}xYxT?w7LYo)}$s(CH- zp}Y#KcCxm{^%C|wo@kgA_rXv*!VUBiLgso$H;e4)rwzA`H2mtq+VdQSQ|?LSTmYy1 z(J#HyJvA42R%$HEFixGOn5sD)Y}2ARixu(JuHBDn9;<+l2Nh}(h92aax@(`oG4gg4 z#Y3UiZNwZ^;=4KEH7&uwyB;5uJ|%If-Fj!xO8TL)JB`&P4I%MV7&Xs@Q0VJa;wr&L zx$rG+ac*?KHQLX&HXQ?e7$du~u<#@2k8j_Gg?*zMalXbdBshs9xVqIU0u}&590;EH zZA>BeG0GiY>z=k0gJwyURU~|d&ZFkvHJ)1z|`S0kDzc+=SC=fKxNEY$k;n9W8SDoPxBP}0sh173;b?bMl zj6ny<8vY;Dy#-WUTe~F+fj|frAOwdbSQ6a5I6#09JcT>K-6@lO6%l z$*3B@Xil)y>XptDqHZk?0hf6zHZ>;IL>{?6?>%Iz2iyBORcJqmARjT-Hx>qrA@R(^2$xgvz+%)|=;=pjpoG&2aSUyZQZ*>y*GD{{kJoxJf-4pJx z^_>-Ux^ch2S5b!gqueb=>@9NS%$F_`#p&}WSsl`m(yD+frqZV4_p`0I`o1e$Mvg{> zd#5Rl;jEt;7`Pg$jr`p-TUThR|ExHawil6n8PR7MJ?cGu+ma-FuS2S1w!B`kqzRQA z^o$|mUccK=Z!50gnHjXSu%Si$WZq+RbA4K{{3!Eh z3ZO-X?_;_{dw{r&{Ev_S;su~d9qy-fJD{jqdWCOf0Tb3L#u6Qec^=K2%cO5ueP}LX zH{zk;@MYa%7TqkVBY;5iHVsgK)Kzl-z)IH85sd8!TT7K6?@iAYXcMO0Z`^aV`A>>P zSx1RKOvL&P7gk=KD5U34lv-QY z+7Q-PTbyFc1mg;h&4XVT7S`ipQ(s>>R&`ho>pibp2w7GHf|IgQf*#;J?xqPSlEEfI5Sk%Ox@wO z{;bjn0Eaq=CRh(}oc%?32pdrUT>Q(h-azM_KACzXE*yHGm zhMBB{so}!cukE}8y402XF35TBNAui6Mx4N|r`7|DtxzQHCk8nBz|d@BpuSIcwGZ(? zso^X8#h&4;DbC@YW5pVx8*#nf5W8lM1FOipG0|(AS8@`DPS>iA28BT`IF3ekf%JB} zm&V-iDbTmk;kTVnzbGv`1g`c(Vwub(xuhqk^56QYNp^nqU^SBIb}&8i$Nq#+3_uva zk?fS=$y~Iylpl_$LGw!t;@z9oNk5hTD#(itZ_3RRJ_eN?v566dVWH!EQ$&L9;*`j~ zMu1s-kz%)6>`NoU>g!L zx`gad6o{VNL~dwk4SL5HH;#VQX-#TPT^)!vyzh0h!D(VYdK>&^%`WP%zleQFWeP7E zM&f=?t6!p^WWfM;y9e#vUp=mezu{esS4k1$WzV=Z?@=_KoXrWg)Sr}bgX7M#j&9$<3KTb|i^a&P} zNPQsYFjL;djwGMnol%$HZnSH-J%&kn^36RT1 z2|L=H&KpWQUunp58d2k;uJU_S@HCsKWh6Fy-@#kbjP}zDs-v+}=-g#_3eRff-0jha z*<%+Ld^6nd&wWfjiW4hEI^Ep2J}X|qZN>~*>6SjpX@BJXqaLd?bEW38F&}XZO{}aB z811{isjaoXJt9R?5D`~%`BZ#{?Q~dT;*A-4W5h@l=|#1!au_{~XjpkQ*Vp6D$R@~@1D0~l5JImq zJ4DF=^10>ZBx87UdJZ16QBj)(lNl4L&E+KXo885!w3n#U9y!`=40slObaj9$O{?;S z#n9|GJO}$O(^Fg3V8V>O5xNq@I6Wjf3$2Ud(%9To3lo$E{hrFp^6>ydnwweVg1>xu z(A3-^wV^s^WO7_GN`U+~^{Z5(M@BO{)46x?lHaMH|L! zH7}S9^3y*PVLoH6E4rf~BalQ=04lw`j-~x1u5Z0bUIf>1FkQK`0`KrD^&ag7pcYKm zmgdT3p1vV2|2WOq+nl-?W7K}MOJ=>XUX;cKejm+JQ2MZN{I~xIGTNar_=(KQhKD1+ zwhOS7A+~E&=JRVb0&;^r_N6FnHHjCh$CARDi@k z;Ig_MH$!8b@xLSF|5f-h8g%^!LjUh!`v33^M7}F1a9!<*S|&2ns1LXkEhr%R@1f_u z+1XSy4TMWwC#B{eB5p*$dF3*Z%pYhx>pz?bJTD(&V>{nnd!(eMT40ZahOK4do+E&14=k911R@?DFy$H1P!?=pEyP#Kl}tVj@Kxi*hV%|8EF> z^I!0{qNb*_v~;K#!QbT>#`oS;`Sbq&+oJt*!~d4sgK8gRUyG2Cu#@h~f4u7v&x)6? zFEQxRPbkwBztTU>0IC0$myf-DW}ts^_2+N@gpmKA$Y{Sa?(@H^NKGX-ZXl7%ZM~n| z-H>75S&m&(^XT}v1A!oxjoHuqlWm;DQ_jEdeJPTz0*6QpphX&asG}n3*svOKx6v*I zgJvJ{#(5QM>sNo8|J|SbQ_5y1qCtZ{PK=tGgmN?3M{Y`{qf;^ucG%3!%rcSupW`~jtu+UfB);>y(qjZHs7OYgj6=*e}eA2GUDh{|5dI3 z6Fw(2H6y~}X5hdjdxO;xrrsvc<+Sd+p7C~EPV($Emy<|aefxaTrVy?N60WwoS)o(9 zI+y(ZTEfBph+eSeTEr^g{aXPmVNJEaq>e&noVlKSAeO2xU8Yr&oJM9-Q z!MCON)iGE>e*5T2pHexS0bR>0@V{gEg{V_3@3(}i;8HUx+6|$WLQAx}dl3N*e@(#m zgagi=wI=_n;VKb%KXd;6nSB66SoNYdM~rSNHIl%-K^NDG$llBf0$;niQ{O>U!uC~1 zDiOR~@93&Mdiee66;LQj9EWe7k6Uh2`uh~U{N0%Dg#9|@N(a|>Mj>4sk6@s>AQI`X zjS;6<`=0O=>XLvna!C{{aH+|Qgqi!hFN4>oqrm%%S142`9ps5L#-DQy1UV^ABHyuy zOK01-?l9fq{w*O6H*QJQo(o^!{UiwHv3^z_)F2y0Ep`b{0l4-TZHPtFADJfn@e9(; zT`uzr1s0j6A+a*N2(4*Y3od{j)j!&fT9-0E<>H7)^W^Q11hhFfoKLp8dY@Q28Z^(; zC`ks=-`#rCF8Xx_qfbAxvfEQKK9zn%fp&ploYg}s1-QT`DWCgdRLfiym|6tny_{}9 z!KDTRU!G&&=APTHtKP;|D_W{SE5lS<#k-ls>jNjut7?kJo!6Sd!VL6bkT}`9AUmCl zf#=;N zH!?fdTa@z=d7DdUr!N3`xTR=aKzt#2iLDqQfN#7s_nBjNk}+A`E!fFB@(cl!3cK@C z34dX8Rr>WulizFq)XA+XGf756b`235`z5xTai zYdOG-&*S_0H@OvXZjW^u_T>Gg!DR`*fJ^>F_xSKphK+FMR;D>J=dsMpm03aTcNW`b ztshN!&~!Co-$h4dnmr2P?$MK0*PZP4QMIFXI4lUCNM^;AM<1N#7fDwG0L5toGr5e0 zzswNpS;gK;f#=?qD4^ZAHrwAg&QQ3@%i^@gw%-3wsgsXlzF-Ae9( zA_M}{AU_5APQ~sZ#LX~`YsbJz#vDlPN>Pdmi3`RWdhsA&5FHIyS;jBJZM0DOr8lwD zyu$iiqL=8%ncn(x@orJtfTF7WN7~;dQnVJoX!1x+(;vM!5iDadtXOe>i%qnxjLVC8fE23fn-Xt0CD&?iv0O9 z%zMy+RI<2tT_?Hp+`2hDS1i2};)7&QC=L8JIXNi9F@^XH6uk^1bIfC6Ew$jKgxR>N z3M@;m21LdipKN_T!RA0~&z6?1^8m@t99C03cFz^l-qf`fF&BFK7(E%IwY%3a)Wyo1nJ=0 zVqWmE;#0kZTHZt2(`Brx+(h|liR5pf69AJ1K5<%T3UDpmg<}*!p{BNqCPamd(J7dz zma~G3{o`sUesRkd?r_r@W#ywJul8@?Om$B^bJcFr<% z_EmZvI(4;0?(l(EmG;0oqHG=*M85y$5jw>hM(!WizTA?!ua?g zmUsH4C-ww>7hCkM4Nohqc1MepQ@(tRFuzi?OQLyuu=hUka@SMLv$&JAk*?A39uKDa zXm~tFIrW{g^4@8!&1lYx*~O^B+~ppD9v^Hh=aZZ`8f96T8p#)TpBl>#rY@cCdz6*U z(x42e*_!2xezwT7)2@Sf?WD{7#>kVi^_R&@ZknNqktT&P$wYYp1BmsVW=b&Ek2}d3F{us7o32;dY+N-5pMmCY?$ulU$3%kh$#ORy?o2e3G6X_hbPBRwPB_~AHoigjbzI2i^K zz>s3sIU!{;i%Prq!A(U$6Q?U!!}_D+GKlKn?#1Zv;^c&TF@C~%%C4q(uJZHn8=eoa ze5dRabQlGnn}(V_E!RZiDBZCIJq)8aJh; z#AbqFdD{Pe?B;;g1gV1l2GiB-CJZZFN3Q;eXMJ}q1@i1=1<>H^@Z0G4xFD7OOI~} ztLUG;TfX*q5Gz@^jJHAWYc@Wb25fjV~0<*050FTE}D7{ z_{o5l-A6hj;C0Os}oAuX%Q0OLUSi_8s}G;7sD;X)7xqF*-UslSSM%G7gYE#JTm}#Sos= z{q>DAIY*RQ$JEVW2lKoUbU+=gt(- zp{dE}(KU1#TY5qS1nIr-TX)y^OW>7SDLPDCyW502V{*#n_jPby)f5-EEF8kMImaa$dzy!t2_Xs18ynr>Zd& zJ+YiUk;2bSb&U#X4v1&lOu@=6__$ef^!vBFT9iN9YwP)IpaBC;A8=!B@@LLYXNCI3 zY8!d~+FJ}N7C7pf@~v~(bIqNcQx?Y@yVu#9@FA2m+ST?0H8xf=aO*`K9hwX_U$~yY z7^uv+ml|CdUp*G0B|yz3cdvkC)98N7-9m#38A> zbJUO`zHfO{9l^j{+9SZAWaz>p(0rTYpf*S#j$l}K{^Ja$kd*c3<;(VhfX zJn{dKd*YnkpP%*6n(t$>1@@uo#8cHbC(*7dxMgk|Vk>JlFHw49#2jIMHvcpHF$afPAelM?yyQ|jaBTf&?{YlgFI z!zDk(rf5F--uxr;>$~}(je<|3@o8TGGw~{ZNxQ@X-AEVBd^bN(&jO0$5(5lvu1Iq8 z;Kh0G{=bbr&)3vnACw}94bb!9AU2ZJtYhs_((5Y)7P5 zJhPAB2glrbI630N$>Gga1SmJCrRlo-?k#e$zJ)_z4nfxHJs4l~$hfx>pSUa#A%`Nt z!pRLC<^RSC2_Oh;2-K)lF7?=$h!NbMpjxa~d?#8N=Wu^3Mu7AC@_x4Qelu@d?0!{s z*(eE5>3Mq>6K9t4#M@-)jPTe5ozBONJq~4Rf32%ocRRp!#YYS!#(;rEjpGuW#`6Wn z)0I_(H}QH^m8y7Nv{jEk9sZXzwfe8E7NxC_PDh&uh36U53y?LasWaejoy2O!WOH8h)V|Ui2~!u zaI0i}tTfhbYnl;?_lGzDCUY_6XoZgo?clzM*-#c4 z-1sLBwu`s?#G)I3* z1SZx^x{5l-3Nu2S?ylmpNKeMMZFg}E`f}z1C0h#KJb*>J+%AXt2JDrbg-NcZ^u>vQ zZiOjvfcve1MKa_h%PAcl9r(r#FXFT0B`})qRMsiO+&zSS&v!TN^RglmA^B@P@{l!1 zfY6|p6)s@63nnWvII*4GNzq2nAnD8>GOi#mkHH9lSPKcQP|B4tgsULi@0XH=_WUlw zmsB~zS|*02+UQmNs@4~#rR3YtsVF>8qsE6nBPe^y4+4xMwl?U%|wq59wVbYBzTe2@izNc6klzQQ@@#rw`;e?iqJBsNF9&-NcJi0?qDH!*BN_ z7hzF!beExXPONlvEJvAavbNgS{x#7=iy1rq%P>_={4gTAmoHwJ7IoIGRMpn4Imo@3~-ZvBeB#QjD3aBuEFQg^uIE#$t~ zD^<0$H8tK;&Nj(KW`38@t8zK^7f`^#Ogn&{>K} zi#Y>vnXli#!VXm;<`t&jFS_&KNiIhO4JlNJs^R^zo+ZBradpd<={}w!Z|mhE60N~j zv{o$!}PSaqKtn0Rb;boAHr#zpwCCS6e&kYYZb(e~8$k)!#X`aSifW=Dk_T zoa#dmY;T0!mM}i&utZkzv%r%(*U%o~v9WR@^|#n+$6LG`tGx^FiKyMK;QhDlPW^8& ziX_Vq?))zu_4+{g{34ZTYV%)Im0ue zW8x$vR9HT{``>s{!1G&^`+sD={#;C!|0$Uklk{(rX&?WMK_#5(Z^^W^3iA{eBm&B? zgO&h8!qYqX!_H(b{?Mbyd^lD>Ka5imn4`2jn9OH}res~;ov!SH!N4Ar=o?%^{&_T~ zqJn|~_5;k@fL6J`46{#=h}DZ5JnRQd%*;_9e~jt-VOd6ttQXEDYgBN6HuC=89uj9w?@tW#qil{Vbu|ghyIlS zt=D44^k-nmptJP%6)uAIKcKVu_w_&bg8$_l{TB&jDd(K77;&^92;b^E$i{QG6o*4I zt0Z9{t?(+>1>zsZI8t4-;0=|2pPS{$b}iqf5A7utn2>>C_xgL|f^ZMj^Q-6wsIoUA zb>|`~5YeG_@V58%nH=EsY<{9k#QV0z>rJV|4ZSKI;j}!|;dBy?57s^&vr@44@uUaV zv<~?*K)|+*7)sE-1A1r=0_8}-e7AyUcdZm~amq2cjhYo5I6|l#r#3U3Xsq*yfBx)L zpNH@$x;b!}^D%zWxKZUixyn${xEK%$qHREjq|m(a5?tTu{K`1vA8ocZ)7^9(f8@V7 zcJ;dO-jQn-3(M>n1ld@UClo)shVAZudTrd8-l@nDF{yo|0!4^LJ3YM@NZk!fI$9xN zSx|2~z*jjO4-|jv-zy3i;WR>N;ne0vrs(P|C zxskz3-fi?jh(pJW$$vZ_vtEGkZ;nQn98l~vEf ztpXQjDx(sN??bzq4ex7*22Jm=$aGp!pdb2%190rGYKl0E`(JfA#G6n;O%r%Ebqa&m zt~lU1KCq!NpjSc?6)fXVNs|lm))SI+0#X$LFZvi zs=Jew#{;X=&FerF%;gbFxPEJYi9k4Q(e0;F!0g**VwN6N3)3E9&kH81uxPHaL0?A; znQOcVhI^+Ps4%e{Z7n>lD<2SEU-{$yCa}WizW%!fQiSYN7{emG6JyHD+5ZLG&H}Qe zRt4w6AHw%T9jDF9lA}59B1ecD2_vLsc*!Xowy3lLZSiM}=b~@to3h0ibzF;rxz!cd zL(UNUzi#uOe354F-}5fMJG)>yV6hzfI4U97Z|t3#3pjSei5o0pD!EY#XZ?GMspsAD z1}lzN1MAyQkKqe~Dwxg?pgg#|(q)NoJGsppetAoKy-7CF>=79*WQ=gg#LKb~s^IM$ z=n;xuhQ_sb0Q#~|3=fFYV2kO(5M$@&oLI~%Ai3i6f_&%gl6}{ zfhDiqcy*?(UhjZBDh{J}ZdeRL-Dv%qXC=vm8I+zzW%y;y=XwwYW*h)Id)7{Xi(bKJ z2Q|`i@3rR-=~k=3Cd-p0R`0EK{igf&CU}hSmD@siKX~1uo1-)V>bH+}z2kjJ@Rcfe0!$nr@Zi#+NJ%_<`2oLYJ-7f2=uL z*58nK$#02+q5AK(ed!XFpEsDRYa;Rr9+YNTzae)Ly8Rhti z1pRuQXcT9Dv-@ag;n;O&M1(zx!JL^fRqb9?TG`ZVtID-N*OAz7gUnoPq<<81{?~g*jhS|RA|Q@ z6%i*a9=lmmT9t}e>^B3tytt8oR`_d6l=}Ua8+f<<51n$a#b157iaG=GxJj)opC6v^ zdX+s7GXyOT+NFXj9Rm-BYKD^g4p2@kNq-@2JOkA_g=!gg>TRHx>jVkPB7JtB^XDKB z$yC$VyyS2cfz(%%++>FGY+AE)8m&^wS=IN+4~2MlDzyZ2SX^$Zoo}aXc%258CTeXj zxswRKZU{RU#K(dp_mX9L8HAPRWtqr+P9t9(t0{l3(n2-gU*TXqQl`d zSIhpBZgea{*$`y46`Rha7k$~3VR@>j}}`QP6N#dNN|lWC|hkvk;kxHHRi<5j~W8M+%X)>rq=hYAaKLxgi@;IKSc`{Mnf3Vo9|Y0d97 zGUS!X0Z()(=mY;AWsUdf>u*aIC5raw z2IcHO2~nkeuqqdD7Gc-60Z+4Dr*Nt{{stnuxSsg?2Yf@g6m&ig?4*`Vj~BYRng%bf;zl)pi)-~1yE*7vI^T0(^kql$S!E!Bd<#=(KTaL5_#C~BqEa+Ge8Z#~O7{F79)jFRClpxNGU z^%2`7Vk4{(mCC?>&f)~;M}0m<+P?)<0b~ zrqugWk!6AcM0M@~5BJq92$^f=`$BJ2Pd<~ACLd7FQk!(r39ZaY4$4dVJ2X|^^MeuP zJP-e4HyFbP|0UE|>Ykj?_2sl~!T2Q~Ej!1%Wl>c-#Hxu*U)lw;qXR%%!EAAz4L1d3$K7iVxn*DTU5Qzi{s1}_`pKXn3i_|384y>?_2SzvZ4{@8;Nnfkp}_( zw0*ill_TB>K_09(#iE%9f>B&`c=%~+)6hvbTNM`BOi8k@&^@OBCI24U0L?V`-l)%N zitsNd!*?pG%jZ89L`7ZnsO^y5C$Jte39!RIvyg!T|M3>7z|qXk)v!$RV1yW~5AHUF z7ItwGmOk3Je#Nkt(%_5`LS0QZdsG zx=$@d&))ZqjY)k?xBae0IJXnJ(L&IS zjTNZE6%sT_oh3#jh~(JySx@-RoO?AL`gmfT@Sdc6Gy2ZUG3mks#m%)Al@6`AJ~*$C zuy-9gJOmAIIoj}hlCmsQTl4-lx z`g2~lVFg#)-{!LdLdl?gMPkQZS8SeT@RiygvsM0hC(UxlKp-E&|5a zl2M&SofJrP(}l4Cy$<&A{1X}OlAKVN>@@q0A;u29A@It}Av`4kgv;E?>shfzz<`IY z&`FO(pVB%zD@R!}GVk6*valAs!S(1rYFo7AH9LFL;V+sGB0g0+2}gUNH!$Js!p>}g zU+P5y(XU5YT+;DSz}%zD9_y0S!UY}Luz}?%u-W^jsGXG_uu5boA+nl9a9{<@dYcG( znR8R=9K8zlAW8!daVxY;^o+F|QT(w$Vx-bJbpaDE49G=ALz6azzQnV&jGqK9QbqYS zbfqK5@Jcs6QMsA9&~k>E1ww#y>1ov7zGK>LVC2*md`Tsd&@n512 zy_Dr;et@DE6GvC*FZCJ*D&-Ld;SF%9tCxPm7XDlGvzeu4ygr=sKF{aJC>n}%o&oIm zqZ8oD;n6%3n7t+`d0sAznmrFD0Q}H3$ojMWVuYG4$42;gC6CO4>3IBc$1~z-JBDA} z9BU7q$WtL)u+^l28F>nlYf=mcU$6CFkM%|)ysCtW<((IO^=?X&+U8c|b=PReiQgnf z1)OzoJ2MIQ4eGx$g^>9=m#+uJTh&aEZC*Gs`tzXa0lT9ULnA))7i`;{VfC1QsKnQ8 z5l*byf9z20oKf`ScqYq_9wQPv2RYY${BxQq^>ktjl1@2AI?~(Em7OP+kn*7t27qU41jKF*Ug5Ajsk&~EL#j#O7UfGxct}=WS;b*uYJ5m7C;a%%vFr1y$r^od= ztLJUPpFC0v&<+|sMty6la%+=q&J`a?l;}yBKJVq}eAF*m7k1h$p41W+2<8p7z7RUp zdt&ZZzTHQjeF27*#eDxErD95Q!bj^>D0t)-64ebZ&N(RYud4;9=7Rt`5pIV~*R&QJ zt`ME&$LEcH{F?FVddoS}3>fxI<{N5NUL+-5nXCQO*-uN@WlovN8v1)vfrAaQ@b9Ri zFgis1Z@seUm8PYKl|w_b7VBeUwYWP{Z}^^ZZWZHR3jan@%Fc#q1%j4bc)4lugsq;c zX-v(7!+)mz+{CYLx_?PySMe&KstnQm^-t5jS5A;De~_pdg}?=Y|Zlt~^f^?H9g;85C$TjISwFI=}J)8k@i;E2-H(6C=&Mm(P2 z1TB9uxpS|$j3KX+E)@?Y6#K1J9Y3V`?5YrAA{83+7f`@LzyH;=C32XYbJxqoX|_vH zRZhEtWe6=oY6_c}oYt^Jmapq3VVB+FBwv)8DTLxBfD^9M?b*08$If3BT8TY3Avwft z;0#?}sqm{3+W%Qq6wJ{h8sYX>_hzbNsloC6{jbQj6^617i}NnxDC`FT4`T;_@aW%MNY3=t~Px31~%R{qMKmR8$Hb{PW z2z7>b_D1lAn(m^bTQfjmQzn0jCzR45AawS#w{JEWiDM4>S&Ey|sB6K~^)GHN#?=jx ztohujZ!>t74n|dKH62WW_26A{aM?usqXnFE`}vNEA+;t)5_slHgWlV1MW4p&oMKEy=fjs+)Z+p(DkMO|t12-H54Gykim>L7C`NyhqLBKkG)W!>kmy4!=DG8TCv8iP&=pFZ*pJVNTV=Ne_vGVbFlrs~1>^pLhd*J1qXbX<#tvUXF_QRrUDS8$8Pa89j-wz0ui#T}o5v6Kagtm<21yP=^M+4a1V}m9!}Tm21Sr5*O3>b!A*c4X9!vV;$Df zmhiiFUwD_rWZA{{k?@J`_vJS1U7LFL7B({R{Su$qim(X1xHOA_Ugb(G79uNp#m6H@ zjds=q(^2z~sp`EhbKc`;z@<}Q;1ro@9q64wc%QHv1#s<4&vs{3^7(f!2g*;TI}Cx6 z$_FP4rg3THY`t!x+{$9%RA`zM9*UDgUyV@6*Elln?Sa>z8yrXZw{m%SwJ=p%BTU_J z78BNK&9|n}=`0|b-ZRdQ`o-p7Db#vEEAGzWcj%yn2^*$8>Dm{i1D#}svpd~}tL^9* z{ruwUu^McO)Mg%5Q33)2TD_{iI`${-D6sRCjG3^dee`~FfFPT zS^(~R^No9_YFtBY)vesT+mDxYFDVDhYTs3h<6Q@^o~)LfGr{@n<+Y$*iTE9v@-P$R z&*mIrCPxyYK>wz{fKf2IrGc87km5?#JKJ|&L*TUaPO!IksK-bm=lZyxEGR8EHu*9-ARD_Lly&4rs9qillqL%h)!gJH*os9OC z7x8F#`7JUQdxVrAg?Maa%*`m-oWnbkwUHqmzwo|(@vVo0Sbn|iAauD6NOzSZNG)TW z$fYt>Q(K&1tNLIXAHPr8;a84jnf`T5m-t8peLm$L*HR}1ciA*yn~IhZOK2{7?5%Yh zj(&|vmYJFZe#1o|@v3fYYxm|kOb~heSTj(yljh@ zVdSUOx&(Ml=84~!TEjv&4)s5y&Q`v)u=M`Q5nF!hu!4uiqAZ>#*AGxQFa3J# zSy!pE_ZUQVxUw+$HRcRsE8NCwbRaL!rmMeqTg;qK0$lGwlV9M#C?rX8xV!)iOKyIU z*cy`(pzk2r6cjtLpVQg4uh$#Vgvlq@r-_jIg5`($U4lmOYJQwfyk?BLT@Nvzeo^c! zB0E(2;k<)yx6ggKA8zXCi8BwLzqx&e*%?}VuQ>*Jrw6^Fcu-YWn0HS71IQ!3zkF9Y-^>N%P?*P39vLoQHDQ*`b{Lot?s`7*x_dF8^Z`GSuQq_p zf?PzL_w+5-OWy3a8=S^Uog2JHK8_4qy3^*!weRWi;!91DUi-cU!n?}9o{N#>f^ee! z$H!{cVss^Rw3J#3n>Kc)lUm9OowwP2O>N%OkHp5mbBMNw$Z& z<=0A3tH`wq%jPl|x`ph7BwnZ3KET1gE%qYP82w@N*2Qu%x58>8#$s05!-2;8DtyTU zxXRP3xBNKpY zCZt_eVN@KG#{B-)XCO?!;%W_TAxz9$WykX|%0a^z243TdrFH(Mmf!E*jxN%mt}I1q zryeN#?FT8o29nkTipNRwC@p8I0e??uRnX;Du9-sqXlG6~e zw|av6uW~v4r`yPz9Bst~4e-;FlImPXUb7Wu4)?z-M1+Wh&xx~&724YLK~*bi6a1ry zDkgKxcB$)h^`&t*wWSXhZG1ene_F5(t^Y)(I5BSj9mjzXT3kM<_7#DOso^{7%+-8fMmNG2dq%v7i4&YWsF$Wr~0cpM@-tei$GJ2)Kg z5qut)^m7*4zQ#|{j1@x=%W`pL!e1W*9)CDvV;dS=kJlWI&zD}$Ns~%Uo5LZ=FlrbD z+gSNL_)S7hrrX1VvVqHHrPol&bVyO-hvuqYeB08@)JZ@74+cV&M?ZWhzqkg25Lq<&vTi=qMLx-Iz>(H=}u8`;YQ{}*eev!qOiEjKB=ppXnB{TkQbH+Nx z=sm#jnbOEJcGHqZyRNP;0i`wLTBDR0izOUCb{{<0qYNMxID4kcS&8tZop$Rh@&dn6 z1$Yx`dVM(2Nw6f{|NPq5R?u5}T1b+Y6fo0lqDt7@&-6Vx4z??Gv(FMvRX>R6wP+ca zJDZ5IXDfbwH!RCU$%1{0&Axf#7Hy=b$3bfo-tMGfZ%$rf>V1L0@g_dp{#FWX80gLl z3G$HTtG`bcAX%ZN?=<~{V6cqN$7sQzjQ+e>kwO*1?kRytpcCusr%M-p+x6M)+%K&X zS=qIdx|yl zxRc1}TH$qk32RU*zjbU(YLppJpqhh^yD-(I?pX|v&TS|b4Q3H$kceNEfHzNEY!LVh zA^GI^i-s2Sm)_qfKHPnHC^jeSFQ=T8gBc(h(8d%~ZwGlEh5?C)vX8dq%?-zNRgel_$@b{L-C5Mv|Tf8^2B=8WSxJmMQv(Pm-{RC8~zm3!XZLv;Go-Sit11w z*NolOKQf2?WpUi6A?`<)9pZMICnRmrBp>L9$G&CGqT4*>WXqL=mPbmNN-R|-b?=#N zVQO+(va+MrGhJO>^Lkh3UpRS%p2TUpL;Eig7V3>w#J=a%aS$lqPgvC323=*%I(^o- z{r6EH9f+M;FU9qVgNvW`e8a%N!B%)Hr7@<&7qe@?Drd*03@8&zXS$N8OTFkfz%(T}ss5fIg+p@z<#KY!QGwr~BUMN#}rwEliPh zPh#?_cTGp#nEhFwde-$0qzxjKmDPt%K8{Z=o#(Ekh&Ap|r^L%TIxk&Ne3;B*@ zMNetA>PAo343W>fO^^B-g84^SYDcGjm)<$%e;iOu^_J}Ldrd0k3Q#Z-37}f}5^{P`sA77aN>@RE#C7uaOI3(>GRC@V);jJ&fo%}R~E0Zv{-=5b^wfx?Y zQJIMI+thr@coc*Jq{*0V850oB3=L&Yso1QHR?zby<)_;}!2)17oH^<_iBWj7oCpbLlbivE~Y<8+}M$Kl~^`cPtC9MUUR_aCp1@!60m;koh~`Ae+O72yEd=K zd$=lMMe%jMZ&?`IyvEgQ0Dw!kFn;M@EDf=n|K7@_%i+z1a)x2n&ef=mysm>T_O>cfPemyEMr8YU+`#`9t zLUJu#tWdv(dm~LWX)uzX`*RngeV=Y2^l^WIopB0-Hb^n0z|Kaw-%_?UI#z-$)!mFnz>%x+hFHpDgGb1N z;U?{h1PQTTY>dgh2<0q~Y2L=uikxA@auwMwrogEQ)~GnvKDxWoz2oHdM`~mGY*;#T zuvzNLQXM$>rSRH6d1R6b8LH64{}Q(%+H0t(WHPJu{(jx&f#4%-dnJT!50-39=wS*I z3-c3U1NlmFv>#iE8OMy{*KSCzH(guO;3;}W_tRDAz#zC#+OM2%q zt-6sx{}%mJPTg>|U7)93G1Q`_a8F{^xn zc_PeanGHm8DtEU&R&+WFf6m4ZYLFvtXM~Ai~{tfzg;NQJ~w+heVFE0QVSMU)u7NEz2VqHN z#Mk19Xc+wZKY^o|nxm49v7?Kgy%Cg&wT+b#ql1CHk&(57sg2_)Oa}l8>OGXC=x1fu z^y3vb%^8*F+q1bnZ3r9$u9YV14PBvc=!ewQkfyv{#X~i|Jaw-$^Zb0X)HRplBC~Px z;ybxvnc-U5F!iQ*K8QzZzCII;bs@tWy7%v0nU}9F9=6`RsqTSGx|srdLET*N?zN6L zdHOzFr(Y-(ZbMp5RZpDxg%QaEF#i3+5jbjWaJJbebvO66F-|UnC)Xt?Rofrk?M z|4n>FLDjawb49?f>>J|D;id3)QxY z0JZb2<353qWF;<+TF&+qw7*F-^KiI3}l z=7(q(=+MkmUVQP!cK(Eiqz#>e*FAErrRG*z=)BE(T&~ybPasI0F5v5q5MyO#9WV2xl;xZkH26wN5mDH=`VLv9PcZ&$G3SmVv?2{^QNE z2Xn4OgmqVC07fPj4i4<6$>*P3k)ro(~dZGllC^~qEkdP2#^_V?Q43k3Kuyfd5 z9t};P=XHT-0)syMw<()+d+kdk#U68KRLS;7)3_)Sh!Yz95hUP-HSeiey}x5}O2LVJ zR$!JxRK$6wqEg;`-%6*lK&jR2Y^ENzHsP_H%M%*FwrJe)i;jN^h(tOEng?s zZ{mk~L$GIjp@bBb(wrMwUYLMCDk`!ijRG;OsD*?;DIJ*$|ICk&HV4RKc93F4^5f5b zn_|5y6Bp_5ehQ8_i70Ni<;!T)WP*AC0LcFI7CWy!O_j|b{X+7#ALdlU+d9w;+u&~KP#(*MO9cStgYVvZKlcffm zb0_8iQ+~{qZb@nWng13lSX^nslmmsx874bBqe_jTu&$L_2`4aFd*b%)?(8BGWXGc% zFMZBWS}3n@I~<)Eez+awzCk>uad@GK1A$Cr+WOPlJxtnn*}rton7U}yyyy-z3N9@K!xl{WJg6g>RNBA zqAP9@lZHCsYsTd9^3Eawo^sB6pRyaDNRR!^Ysm$&{13OMsYzrx>{R^=9#b2U4o_R3|BUDY#s)-&azF4|a};Inz4 z&EYX^f#@}wUouO9)AH5we@^H(LjSHLQo#?jw6sM|KNOulew1?;y;?UkC&aDE8`Akc zbF@^cXOQ8v;y9JecZ7UCW9NYMIfOjfvu)Q$b$Wx*Q9FZ{#!9TQ+{UZxhaE~ z!T=M)PUd(V!6c*a`0nNI?!4}!SF3H&x?*<5XK>k*|J~s#z>)PJ`AQNC&Y0h+jNFod z%UT28N7LP^QQO=CqG z_V7LE&}*Zqt4ml5!>8A{w+=)d>)_tUma(Hnd^zL-B>@ z>wdULF9q1*m}LcwD}@w_`t2gYG4E7&?9qqUq1YmFw_cXY?*Ojqo?oCEAd1}{ct znrtl#o$J|KfjwBBZlW-rrW}roV@H&ky)XC2d0=|T+#=RK*3v(CR~aykd^}RaD)CY4 zmp2=gU7nroxSw9}5$Tm(_L`GL2}kSuNgu}R1_OGufH4~_ZLuSq6q1j!vm%}^QNR1dWT@ovaj5}=J2*md@Iu%4!^Cg(6?#&yLfI_UU8#x0u&iDE@V*8 z@)T|P7e(dRJvPKEG`RYPiFZk8W6P>*#M6>afe%Ws3p4Wwc(K3=9GUOwKbwc?L9AV% zp%EBsPoQ^ws0d1a8oH2aFICM>DgS*2Z)wKhcBO**lOTkIGsMFL)}U7gQYG>;)EiFLqR& zo3lRYx*;c4L=*bj65oAohd)jR9#_199|Q_{WWiLPAQxXa=@8b8Gh4QSH-l>m4II568PRI=~k~V7s3l4Z0wE z5Om0guBvbqZdEwb3o$$y*z7GNFxBUh4CW6vt5v4##QEU{5matSW;@ai(Pg-w|nSs zl7g`WUGSDlN00`x@HJFiPfQW5-JRL`a0)tYrA?_hr%@yggQ(Z%22{pG;Q0sDx?;j@7LtdOfy^@GDj zyS(f1O&|`AA1gy*0D%j;hb^M6!J0?ZZ}A}-ZBGwe1*5BE?w5=pp$5CtB_AsHwIt74 zLp-x4Tozbt!MmtOCk;`?48)Iobj77)RVX1=5)cDWr5lhi&O{u_SW(rpr`4(D%Wz$b zL5e#yJ688l>__8RJkL(Q6SJT1noZ6{Wj#{y3T9?IFzmTH#?z3o2We|twxpB0+G>6% ztQnTjd$msCwvYR9U~iH6WO^9TkG2yE6i=$-%9U#M-UyMrFHq|eD&-dGU+JEI?lQuQ zPQhN9tvOX@&69fm_R#-HFY0b;Lo{yzv%&Jt_dGO>Au3BHdsTK`qoX#`S0Q#j#+q(KoX)$--Q*?sjB9* zy~X+AAKlShs4mGFM1wvaVE{WGLYZB5RHiY#hou=D$7w!ZwG_B+BDj!6R5`dlU}KYu zvzX*nUOkN8Is!?vw(ccXH^wJ~F6JaUHr3Pxf1JtxJ|n(8k7?j>fASKrmG;RIL75+E z`dRiA-Qn$$vs=Ys;&80%CKNm0RDdznv1>cEPwFXj3i~Uvy`$NmU*d-PgJ_T-ePG#| zPhZlKT+*nsCD^wGvNcAo5Dg#c%p&<0foe|yHu&en@1 z`KUHy{b7ZZGMRojuJiMQj%Q2B<2H%1)<^A^SOn@rH0{JsIPT zBV8WeSe%PKx==wnf+tecNkkD(;C8saGLEA-wl{!KIf$fW^v9@1Xierzs}#U7;B2cu z>teFXvdIn}x#~X`R}+oD*t{avQ!xzLHd81nXTtYwWV%fHqB#-s2bA5+Uf#LER;~ zj6zTHb>|o1aQ6|jqucwVC^zOtq6#jH;+Y-5>R{yt&Na5AAtv#ETpZLy*p z*15J>z5J<^QM_T*c4*(>kp3M?N)w9+qZ$z~=YdJMhJuvDrI!5p-h^|#_YJg;;X!cg zogCz`=d|&6Qj&W54uIZd3!$}XHlvbd&oFnA01DVep2)Z+8@VDnvcJ@H;^LF_{)89T zWZ}zVnxB=c4g8NH;!*pC8E(EeUc2sSu&YB`H&@`+M`Z2!6omfwGS|z5_c^K|jhlrl zyK-~~j)kt6Pe(6cr3QSS#z&%6UUMqL0cGo#9v#F9q2ZFW<&ena`R9Ol<(p6A*Ggab z!vhxz9ZT|jqJ1p+!vGI4!ZjoYy8`4lH@l?K| zT1@k-%oLKa3xP>GY{xx(fM_?lle7Z>;iO&$VwbrRYXkkVK(!`!Ex|nIox!z*va1{4 ze@tO-U0-0Iq8|v9t)H^IKaqRLprGtXYP%LVrq5(|nyh@*u$XuCxyKkVqpBlUl0?fa~|ye+pgZjRg=?p;{3oss0Cu_ zDvZevbiKeO+FGKJs8bQ;0Lqj6ZW~ zP?UQlsmg2r{u;`E*^|%dnr<03k0h?*9_W{G^`QJ95(oD z_;W(kIS`_RM>@!i_SeT4#vDP`>>w&m&ZpSJWA&DW-%41{y5pE66Q?Vqowv>mV**h| z7(f!~`i zEayFlJu3``Zmn^wJyW$J`8tvcXxY#uT1&<{NjR0S+yk4 z;SLT$AZLDZ1~C2=4Q_1J6IHxViswV&L@*_68)V zp3uDY2-8wIEGa+MvN;}*#5DHOzKW*3@!UFOgU2=wFsvb9`CcW2 zbOsNQT9zH#mJHY?{=f?s_4LH)B}~Qz4eZ$fBxl!S!U=^U189^^Bs1cfU0tv7-sv3r zJ+RzwcqG3o397|WWT3k5l~6|eF@KAA%a>tbjA$Vo$z-x7+^!w_MM{;)I2`}P`FH|C z04FO4_~%Y!ZcoijSUbN3GoI|vRaoRvdmWIHyd@fsR(bmwkc2zsPV}1`&!R6}g4vM| zl_#C2sPT8P+3gCk(|xm_b@&gRo50U=+54U)nk{mkG~xIXK?Y$H&6eAagpod7dY9K9 zzH0F}KF8k%I*_x3X0>L>Mlmyl+Ga#X^S*#{cxN`>va0ZR)B_R(eE>FcYP$O7Zv+stf7`X~L*mM=PdC_KlVxk}Z?e{3%kVo@78NvsV% zTtG@)XA>RLQOUf!s6DJO-if*72IhK9RetML8|d<1U@0JCg|AyY&P}~nX!GF{{;@o^ zGKDZf-x~mp?A(U-q(I`XT)fJ2HF;lM?=`g5f{Q6=NXZ>R&<|4~zjF-&Y|QvP%gD7E zXj>WF+>fD`6un$sRs1+BGzq5%uRHQpEm*2qIiK_0!tBw{s1#+?1byLK34x}d(*Gj# zS!xJr@5sbEUo#4GJB z=d$q|n;DfVyIUQE-VFwxu&Lu!-PWYT%?g-%y>S^@^k_vqOQtL-ubI00A6q0#chNxQ z(46AJ&^K>QalWeXV}6F0OK03T(K3FnBY)>(!>AF_eW!@fCntz`LioJf>I@Gr^jm;E zw-`{u4yV-4P^NbY`59RDXjK@~mAiTOcydCyE2BT1?e&vj>rMsd6aE(23&Pppl6r&v zM^Hy)Y(nny05W>-oWK^9K9;-y3ko(LO1`eJ4C(4Mp9QT=M;n$$Hv?(4mb8GTXVX#K$FG`}+6xq9}ZA7P2Ma*382Y_qBcYtO2yQUvEUbQXejs`;s*0?ILtTwmeRs zauwlbit1Ik^%(t`^SWc~dB(AHm9-ql|M2-^X)#^#9WmW^&ZvqDVgyxYD$ZK|i4~7u zE^2tq8IOTh2o9ABS9J<1L}G3QO<6Zxz=ED(V3!eN--0jf^fZu)&G-_L%X-l-DJhaQ zR|2Zi@vhi?c$Nou^!G7xh4%UK4RZS%-TM*VBx3A8;L2!jF&$T0gi=8@BI=3e-g)Ct ztp+0jL5f;gJsna>>LC%IqiKdyhLDb$i5Hx+T6}?#>ic&ttu;o(MT$W+jZh|bdHjcF zZEDd4d7auW!e#oqN){`^_dg)tA1j6M%FIQaiXgHjUu}!%xPp+GQB6s3rOKQ~xLi!$ zCBZ}_b>9LqQs-Q4VOJb+C#M)-qe6?x7qWV?5Nh)B;mEJ1z)=SA2Vz_{t3l*~a0ZW* z_sSeO)RfLRH3kx_=7V7Z?S>y)jQiM|^N-_LlY@g1WjKjH@Pu0bNcedy?LQw}O+89U zUGRB;=fhF@m9oQ&rJ0%G0!6Z*0Wrj@vuD5@+$Z@4Z@jeRoKU}Q1PojY3LvMdw*n5u z);^Df7m`2W5Q}m5Xvh%{_-gOefTVcb*ob6$_LX}|3-XnUY_6v!^M>iL1q}F{hkF-0b4F#V-6VPiRCurMK@$ zt(1Yq{EgMy(9tR4`Bn@Y+3;iK0Og($3VJ+CT@J$HItSJnTRWXhDYD@)&Qo=8VtPTN znIrM;0L`(-+8!S8&W&pfq7DXoaMc_}?zy_$tNrj!8z`n*D5!QG;Y}AvRIIXdf0qIt z;E4LjS5L3)5&6xMWtE!?hmG5zSJRC;cj#<(RY*(iTeqXx$cytwetRg#fOk;+=jU`> zb(Re|@Zm2Df)6}mKElF&UtOlTcV`aEPdTfbq9jxgd{&-HR!P5(-GwV)HcbPD~inU5HStdFD+JMsxqDYejfaL$P4PS`TN5WlKQ-3mR5 zv%{+lewXrvD@Mj?%={VF?)AbipJueP6J^Eto`Ho<O+FZ-;M6ham9H)nAri9 zQKQJ**j#jPa>wLmRP4lMs7L~XoNMUm{m4i?L}<>aS&t=E_Lh$s zHS`Uk?21dvQk?}J_nJWFQoekJD`brS^wUwE(OkJ!gdBkL2Q78X=Cv2oDxijeH^BXj zPqkJX!mrFn(s&x3mDN~UJG?a4+RwP8UT=rREaw_*qBeBj)|Bi7-z{={tlpj`Q_Y)V zu6KB)l;ZRijF}b^c`Z`9xYT)n5~Nng8j)t*23m45_I7jW;J3qM3#Nr}G|b<%|7?@< zEg5s9Nz>Gjbao|Zg+ViJRJuW61ba8IRH|;uu9MOjH9!ZTsZ+J4G-`gJW6Z}Zqkl|d zFu0uD50{o1`CWMk8`!A(y>p+ak@Z7FnOVZ@m6-fS2v9a%AX(Tr5-z-}H#|j3$X?xt zg@|F{D%|a=`N%C|NV}(F^$+kEoU%A{C-I9}lU%4FSTTb8`?L1C?Rw#X4Ky>%Gk|VZkzbB!HKq1ZeP~Ear=w(`v?JH z|6a)5K1m->dNIFipCC=>W+vQDw;&H;grDplIG5Y`r+lFgJ8%x0^T8~WWz?jr`L4BL z(of6e1A{y9#MUM>g^00W=k3j2&F0CRS`IrV=K(pEzD2sfa&DzUT(HhLT1dWRcqDV0 zqyr$M_apr+-<%%_Nq(?v{?jw*4-UJGZ@nW0uHCnA$g^G}L`}|@dJ2>Rk2p10hha5| z&!g!>2I%Q~7jA?Z_60XDN)DVp78;6CPBArU~b!x z#dj>L`L3j%J8n6N3`Q5%fu6OmnFOUTunIl*y3il2ePShGc55EN6?ctlUEHPABGxZt zRF_v4w&`_>%EVmA#nMsQIxmIo=R?bcAP}zsJiEz=sprEh9vQK#lx%-v3-F{=8~d)9 zNTc+r%j=|gT~4luF7!~C)j8{FaOO6igDu2%aFK=|0pOL;1b4%7luz*J?FZdf7F{FZ zvpwMB0)t!ceaHLs%o9c%^_;R~6_~Dhfpm1Uty)rW{cKXMF_;M?GdA7e<2tVLf7`r{ zXx->U977I&IZrzvV|;+HRym_7p;cF5vo+6MaUOa_3Q4(z8c*&_kLylE*WS5^nCah( zb+tHHOOkdwiqj=cXY;52(PF@Xhih*yLC*E=ebQB!s{P7SuIumFr(;(amp;q)88gD) zty#ENSVJSlN#+^SN2QDp_`{NYI=xg^47|-&oIt1EsB1L*&+}#QEPJudd|{{vk#qO= zDt9k&@Zokl_@sn;sJohM=vL%LvURYgd3?3cB*Di-V(FyBvx=c(Mlq?5;q2GudnH+} zZU(iIgoD7v+YyMlv~Pw^0FJO#l`w{ zi-V(sd6ktIK0ZDu?EuqZYtfxr{z^3|ns!_0w9&|(jjZyJgUrH;Y4g1fCP5&hyY(e) zHmE%wO~VE2FkMMHb|wf^+Kw~dwNW{zuEYC>pAZ$58AmP89VZ5 z5CV}IN^2kyTCl#VJ?9KEAJAmr$fM%wVrp2!)*ICz>?9X{>ywl5pc;?d8Pi{;8pdnH zo7w3; zaNIE8c1BFH@apO!0s8F5<@kc4BAK5H$SO+ZwuVo*q)6m&meayK%-(5+CMqVu3+1At zqn!3`Z8c}lm}^jXQdPyoB}QnOP+P6wm9kL(ez5zd_+eX$VrxbJH6cjvJG=A#c;3@q z8f*t}#V8hk1#Z7fi`g}ODuHQ(?lYoh@#6ekz@;t2rlh&F+!LHhifzm)2^#1~ z%-6}UdaDqE&@KUTlZ%!6BSYEXYx+!6E=w;hN@wsYVdbR!T)ZK(pqLKchFLphG0x2H zGsC=BDrkzaRAwlXYWS`nEul(@giic-@)s6pw};=fyvV4_=mjT^ zM#p3?L*ceGUsn2BxrKI3hIdG+Fn--yeEUNM39kPslg^`Xbv?`Jwy+`(^fBLv4!|fg zx{c^r;)5j@rI5uJ3Z1dwZg9f?BbPA8moHxmb{ShN*4n*>#VG?!RZd`*;y|FaKimyz zDge^Ifccl2#1%0yi3ZnC!ojoH?`Gmi_+9E>2MHGc!kbAq4Eg6?koUgp%kWe*`&D*U z92T=3hw-g8Q!w||Er=2n&=~uZSl;3Y<4?Z;(|D>i;jyKqG@wme-A4mV;o9m~jJM7S z@!v@yl9${6;ndU;qQb#NEZyw5bUZQk{6M|UWxyIBqtr9IGd=D=Vn1=TD^kI!U|0i#PjNxNpi8q1rP$ z3T0uigljXrpYA6rbUNzM{xY3<-&tOlF3|&+tGKqTUH-1d>!M9d#QsZ;_*2(yKza8^ z75%e0h~xs||L?}uLm@HEI~-*(XfeCB*{=$VeDDA57=V#uRY*qzc@0lCy>l4^BxB{j zv8qh?Dq7j6ZD;(uHyFa2!$Z`DG;huCJPebN;l8B>I@ZM*xqKs6QTY1qBrk+Iii3(k z|L=fFP4}xU=KsV6D#n(s0VAHxLybvuX22p=L!`e9ui#;wJ}!={^jy=fo!%+wvwF*g z?&2AXO0^N0G`@(5TnV?@wtA|;f)Xjz;bQ^G^MCK$t6=3gsGjI0=CxUT!`J@iZoIrr zo{xhCkIx%H(5|OXH%O=_GBPsZ5{oUwU(OHca?B0i<^6Xa5GO1{`Jrd9*t6u)1qPp= z9u&09`{uM-9QF2p9YL$Ld-4iBRt6Ln(zr|!2EH~=<$By0H+VJuc0H$kWiR1rdm`D` z*sOYPImvT>%e7i+*sA2mXES7JZ*MP~+>iKLa3cJ$cVsgGM zdHe7HQ>oh}Y&@1eaeS>TOV3#MZ-DxbIMBu7VVm|GC6P~(lC978L`eb_6Q;LrU^J-7 z$tk-g&DZSsS3A#%oIZiOZS2daFbma(B;+V6&=9t~o15D!bw>9O-fpNzK*_1 z1?%hUN!;o2@e#GNvrE`Rr=oUX;Y~p$;yzzOAlu%r$}Lr^s$LkRR+;!U&uP1hY|LeO z9}})rFfcL#|6V{KzFe!B#drkqG2A;M{6ktqMTdxDH@g7Waz~6&+LRn=$i;KdMDm*5 zoUVMm$K9|{eGvBI7x)`QOIPwE^;l(_>ikXc?6Uh}+^x9C9hFo7-^KjbVE_?L{N=WGFCH4aB>|uRt%sX-N9j`yG!FOEgObodQ zjqBw{7YsSsDQhKB1ru30;D)3%R8c<><9Z@NxT4eg`zM-OgL8dz)40x+hImIbU^bq_ zZ7(Xzm1Wq>A!p}PW5gdv7h{N;i8Smn4)wCUvXD?Bbk5Z=ZRXy1-@B7UR^3c|%)%6k zBwl!|Ek)WXH)3Qlwz}}5`lD_{iIg~gg)hi+zRu9({HG*TsajL)$jAt8SqJ$)G{@LH zX##HNQt*=b*`Jk38gVrr?p10!hmEH;`BbJ+N7%A5b~T6IM0utRnHmRJH1%RUqsf@1_R~_DPcn?ov>KM8L1z;D+peeZ?L;0z70a#bRlaU*NTC-a|^l& zK7nBId)bME^nRRTw!b(NB|a-P(YGgKo1}||%f8Q#!^7wuxTWx{Vh(%r;oz93FSDo5 zvH2NITnqt9tEuUIn$68e2R?7ti>M@*al6D^0xrLbBR|Z6g|=ieZ{DCj_|<2Ukajx` z1CMyr9~4nPuZZsM3>Rd|-rF#You^2PQC4V6=&O>8;S$M}F=yn!V;yCPDN1qO;Zm=D z79?=Gz}H`dNjG}hlOBxd2;<*Ec#d~8;2C7wqo74r-1@zw2A!{u%vUcM;9fH% zOX#l~dh;^{PqlDY8^6%LWq?!>@ww5#_#fSIx!~A&KHz%urOSTDM)~g5=+-J? zX}#7v9yG4FK8=k(8>V0olz37xGk?bP!EJ>Pi+yNp?g49=F*1f5?PnOK?a%6Cikp%A zg9!-PNghJt`2>@X_f;CJW0A$kA<2<+$N0Y{yiv6<)Nj&HdVO>2A$RVGzJ2!S3I!iy zyjZ0KSROMh6ZXfVK)sq2f%71VBSvgo+*y5>uBBJSiWf=*P%5ac4bbtv3*6`lYWVW- z+6_unRCLtsRpqz>*w<;64S8=^gNNDeEk^gt)4{#v{3jOffB7Zpq*L*K47X_s{oAcE)R7n3I6hmS0=0Hdv3TR zR(Jlvm4=8>i~3)8+zl8WW`|=3!W%JXsz^AGTa(HJ~BC3e-4C=Cmv=@d5pb77*=5O*h(-#_%#bwwt>B zx)ZH_LTfnmoZ-p>4xF0BlO{|B>i69I3(beU@Dj$U?3*;48BL`J2S;*Uz@Bq{v1J_-yEmqPhmxzaZe7idLXgsO(5~Pq9}`+;y{cJ$?Drp&zx4ge|wCPsHJfwjPm_ha3<)5QyFEgI134EqaFT)1nTfl z4)CB8`5lfJBX*nOz9ZYD33$i%l#P>76c=__O7D$_c;J6{K$8&oc)^od@k-_A(qm35 zmLI!`sLAU$C-i+)ru|X*#ZPkEHPJuWT01&Pth@#WhR;ppGm>f<^TtZ^BrjBSWj?yT zeFJ>&QhXH?f8G5}%D)cx9EzCP1nhKV20sCdEmRLS7SyfT5|{Jw@`61o$JZTHa7E$v z)Rer?O#Uzg>j!GdO?oWWjeX1HB$lVACjp1)*RVP=VqzI_ad-j(g5F-u)a|0utSn!2 zp-1l5xX)mF0Fx0{M60#6^(&EGU!yXfh81>)m|`4{6zmOZF~=~SC5FFnnWdBz%uDr_ zT@qDhr_^Dwi`!O>Eb-`APyP+q+#d)T#57`?{nVJ{e0T{pR z8p9)SKFVp&ZoXkyB?&nof+q<4>KOVn{KE(LhFSj08e-Xo2>`&)8H#5>s5Jt$BmG0e zz?)TfVmWGPxvX^tF*zT7KF(U7ReV$pVqAK(Wg&)ERQFmr`q^{pLp_vB35z=YBAyQS z);by<>Gqm{5i-E;*94gCPwP(?>(35P)LoonSWQ z74p77HEY|#Cni&R>0gFh*yfi-K`GILfuu&l1xzmJQk+^q>!_bUSl7T0)hn0`mfrlJK-6io1{t zr@tSb%?^ro&|>4OeH1!~=t`?5P?8NH_a$xP|2+G+AxqqKT1((PmmnXlu zd0UyBjPKspn>ArtxmZd`O&yn-I-7GcZL&K;Fp|!jtFmZ*#%Z&IRM^?s`5Mc3dwWZB zPnl-Q2|$MvapPvlfgvj$mF`X_M1dr{_5oxKmY*x;E{;-0(-&uh!?JAF)a3H>W0HVA zs+`Q+QE^3Pz5V@Mbi}|r>KMD;=zhpgDGyttk5&9jX~~U1Dj+P_&%`_ek>-@8=KDyRXT129vPbCVEnQ>KvTF#>G8%HGt^rHUT&{nfG zK;>l5y7r_RunFE2k3_nW)Bk5&Q94g<0Zm@M+ZemLd;=S=wt z_(ek%)j)%5wV;~r<63Anuhqmc-)p~=DRzNp~$#f3#mKNCCY zg6)C5^rwXQc;)4dF{Kb{sJOvie{=v;XOhmS!87-oEhODT#HeOrBwHlt?zraV_o0PP z&yM}s(i$LmaJrQ{TZ$raA_AY4I3njahiT#-_jB)3iF4fT6q~m9rdH&S)iEukRSb^r3kH{#sShm7%=4o9o7VMZ zi@liiumNIN%B>M@AnOkY0}7Fpd5W4q{;MM9$Z1&y&F0Y+xlL=V3(H%CyDeE*yO`+O z-y*6nwP{u*s&UB($x*+b3P5Z&E~KzxlY_RAg1{TwM;TJy6k5wwZ(`{?Hl_+Dl;ab5 z$e669VB+V+jR;E~(`+Tr(g!)GQNc0w$b6tF**oO*y@=!|VjzsoCX~BWVU&-X0C&N2*>eaD#7g?DS z8Qzpxt4_Z{P?Q`VS-;&-wkI|M=PZM{$i~){-PtTuDPxH+de$Mb8IFfP*MFm36A!;X z5oJ62Mrq%V(c=rz{)FJ}c#*f>8C5EjK2$V%PuePrTcsdFOd3K-g(7)nPeINJmQQ*BV(jFRLp(*2rxn#lgML1a7 zy$trd^OC#P3&_K=_Yy~UPfxKWM`lV&UrwJij}u*7%ihR`_gVRZDdt#%@j>~O_dDo! zx=T2K!xKTS60T?q2k`^2{rl)qh< zP2KyRBIiH@29N@IwmlM}{ig#A!XDF?)Wss^^5(6|-H%n5{$j+VO#S(%pJcjoTc4vn z$SP^&1;*;BS9cbI@ILRqCw2FDE&@a28x`pd~Z$uKrAXn?I^M+3xYkuF!K=!LrEB?O2*4N2d%4)p@a+UxV zU3=zc{I&yLXS%q0>y14ft|kLc$1HFqQ}3phMAHx0O|kJKcdTf8D3dj{=J+QZN4096 z<|i5<4a=;nk1Uo@+~~+91?5t9);eHlBG(5OJy$hKa_Cjj@XR5~3dYA;BTkPDjW<2| zDQxaE8EI#6-=tqX`)^9R6Ij2hsN?b}<4u|m%k>7O2b*6Jrw|95XyTh0_ zUiHY696P;g>H79yoMAd&pm=WfHyM;yAn>0Dj?Gld1eILH z;?f4Y&{VH)ZXRv$^jU%Xctp}_jSr~T2U7(LAV({A-sw^LT@A)^Om4u%qjm{lmJ==0~HJ3?psMjO|&yU9uP1X8=1QP z^gXx0_q2-SUL{}?@n4^4F>=#6x_0-+7O6ZSWOlt<@Sf1MIbr*&tH5e{|yp=F`8X`WfNCg49Rmo2Q>8k)9q(7dtDi;>{qi}s=a^bgGer# zR(RT0a!8kUMk(<5+Os*7ws8pS>)Ya-MrN*elKB5xUGjiN=Y(-TMHL(svFrFV0ZD)s z_7D?I-KO;4EW+mE$I~!i0z=FHVC4ZAq!j=04E}W5V2$ws!x#a%{F8u$m z@yT|L=`R=H|H~-BFXO9i;BP8x9?w1}ZP$zoO>4}%!Z}Del5m`%(4py@6QpI=#I~0> zz!JHWBDopoV)m)r0wLg@ORYnGeXbxllEc|S>v*3MfKUJY zHNaZlcMKqez<+;m6Zjjh2a2Z@-Y&8$>o8|o6gT0gK|`vYhrj2qUt%-fc&Csf!*2%M zE18`*WZA+0mVs@BTSqJ;R#j^)wa(B`&rr6_FHsF!XF>BT^P@im%Z@Ggb4r9hbqED; zV|>5}$3T%_Ns&<-p*mLeS4;aoWljEZtUu2|QH_f^j6QU#@xEJz&;5ybe}C`hl=FP^ zLG}_2w%|Vcb+!Ione^@g1!7fyi=pbe+tVL|oqd zK4R^qK&-XCBn4!%4Ca5}3ofRN|8I3h-n<1t6|8joJWUEbUsns9D;g95ws$2ej`hIM zcqCpR>z`8xjR=0FtJR=6XVUx}o(P|NdjcJe=;4oWgOAT`;?H0Zpq~g)eyIKL8jK}6 z06d3eY=7IhC|5piGCXFpygV*yXZ}CDy;VRQOVkF41PH+;xI4ia+yexHYjBq#xCM8I z;1FCUNFcZccLryG06_+KcXxJj|9k)4ec7ko<*}Qot~%DIzVn@Gog1$1Q)tHX%<_TN zQYCOEV6F$uZwLu!DGH6K1Pwd}<9F2DKgpqW{<~!9G31S4H1^x~F6tuf?GA95{f0t) zhC)8ugmf2|vm91Ki|D18O`fM7t-4R!@H85nJ8UFj{I&~sKbzCmV+1PS$!$yuGXEjl z&#RdAn0cW6*VfLh@x9zBo&uQAx0w$|zO}$K`aI5GS(wk1veU@xG+TFaQM|;aF<<-{ zf9A+$`=HY^ex+?{um^2Y=ZUhau$InG{(=5~$I3~bI%bE+tumGoIQaubI(@1VQD%H4 zOh_uYZ{+mFvC8k@l2QMf-ne6D_QLZOc`4SFqTB5MEYadsnJL4Jg9%>-%w05=$TQy4 zOfao|{1zl}Tb}Zq@ovZ9_FKm4YLxy?avI49+6j--NtL_eNc15_X!FY*R;0|@Hp{u! zW2(aKJ;!pvbOo~~NLTR<%e$nHuXc~jjw&e@HI^7Nt`5Bf%ip=ct&08WnMb_WC4hN! z#-5oyGG0Wtb3-;)fJ1i$3#KrbQgxt0xb%%g$QQvOpL#PN#?Zus=FZSe!Ew9lVgR@rT=!G;7}R1$B{|7*QbWucdIT?$VFv!!5>t({ z`;4y}&Tm`Vr-VF7w$Z}9%$5c=iv{np6kKk{16--ay)DNJPj1i}S+`of@aouZa&0;q zIu!WlrxNLNWKY^7M=BU?vHMRjCb!R3m;Q9mh#vj992+wZ|3`6FJ*J{o3QQw(Q8mP` zP=Gs6GlF@Y46$WA&{%L5whhmnAF$xP7A5ziv2l_ZuiY zuA|}zp?75j>f5)+ML`o35m4w|0Xi;|r`CmNL2z!o7>^?(lxID8t8_O?e|2qfUrY1` zWMM|U4Hfpg$iB69*1>@Jv7}9Qj^h*Pojd<%ThEd(Y0K?+C-j3&TTW|Qj!L$s6| zXCMcW*j?v_9w^_CGloL|I#iJ{Ql$^se$g5d{gB*Nrpp#HPWs(yvlFk#bDoKfWsPv9 zGu+tdtvG%A*%T4_^ys0-eqWY z?OSYVvsk;fcT2eeHhrzOYD;NSzJ8A{EIIx=J=g>VEoLHAU;*3T_rTXKJmcZH<(uP+ z+mNQm9spLwNNj0dhB_;LbaxWvz|%cj4M zR-5o;Mad{h7pN)fM5gDz0#ic>@H$Ljut6w?O&%>a*yorG^TA5bI>B|I922CT&G&qK zE&_X{zT8em_zDcw=Qf2JlYw)(6~ElPx1AW!wFdQN~bu1us>#} zr}SMB7HcG)&;gKr5#MKK&O@t zg;zaVbuM=N+NOn@PB>WEc5<8hCyoqYS;ERs*Q0|&lMT7(pIJkL3P19hqP`x!!Zjaw zc*Opj$RtiweifDos|!+P3SPpEHg&^BN3NN-*BQlBWWY1+qI_dQgzWBvzUK5zmGjj$ zQ@6?M=srmSGKLvk`tikavK{{hH_OvFVXIOsm441La4}LCGrm=SU4282VeC?JN9X4+ z)jGONxb(O=Z9SGJ{*y!3h=zY(?XcA0_9in!cVIX-1zgYw*{S5-iK1117k$SLzwbWd zvT{)$Lf!K^iZJc0Y~ibJE|B!>XY3XWL5=TaL);(NE%Df@7!hO)&~ zIp>RHvm99ZH@*4y7CkvA|eJsK&4Q-a4~BdXW|$AExi56`_>=Zh`xy zdK8aG9e9-&XAAP{%fbXT?{MZD?6-P$^v?9xb)9XIR9KbhFPi$5OJCY3R|pBtA+gQh z`r~escvE~jn?Voq~WW~}GdbB${sXazjir1TV=9!iqjJ&T9j~C|LX225vXeNVh zeEx%_0U{9mW{oP`)PyfYd>2e&PEUq`6BRrPq{V)NgMoxf$`HK9NdOK8n}JVm@SymtRz1aoE~ng z=)<^Gg97TYUjH4NOF9*2+Us_`C~+9WRl#NV!kS^hQC!{l3VDX9&e0$ zP2^kdQ&qQ9`PGb|^B&$YhwpW*i=XBFM9=JW>vf&q?#p!>n&(zfUW+`~=L~c8 zTIN)@!h&Hw4oSU6`JK9b)A;qzES&dACl(=LxV5)?h}tgN&w~f}MFXT=lsq3ryvew} zX+w5W^3f@L_YJiug`Svx_P#>FRKP7eHiV21nv2ZANa^ygU|vMmH@ABTdf(Vu5+>q$ zQnOl8(U2QqTxod(zw%qDH6t=gpS+h+%2Xc7j9MC9J4PA8B*e&>+pIoeM4>0sk8K~@6tJL`Qgjt z`DRK-V?2aowsuX`N&34feJ4}S)`hDsm-yS8@TWiVFOlNH3as?9AzYNP=1-owNw;I| zDT^uM_+y@io66cP9aQ${ux&Iq*T5zCI9K5#x^lM1?=J2{Ou&Q5039$hfaT~yMX4KSTK@GH{k83~D_6|JEO?oW2am;Jhc=Y#dKLhou##MGx)5@3#25UJXUX^n_kL zKcIi{aDDb_hA=Y~{^SJp1k=zY{%!H52$*k5ylHHgQr8o*w6v6_QInkD#m@_zJL6{qR!Wlcz=#*qDI=q9hlI z{k_l=65wFPiCbIaYb{2f{QF0L>5_=2nnw4J@y{e7C1l%Asu>X->D58igxT-GV-dd}7K38uLz~U0GOlVg=KOR2e_simJ zX$w5S-(}nRwA#CBT^Php;dNY4u*2c{9JZL@mKmpGFK~J~>ADbymCIm|P?Y>iMK(;x z(|y)SNiZbok?t^gju!Uv*Jb9)31~lYZToTsQ~NlQU~_qMam=jjYFrS zxt#rHj5^`wHi2B1V(U^L8o2bK;ke>;5n(*lgw3B9EIX&IRb!@o#q7q;Hd-MAf^vd~Re3Kp00B6>k2}#yieb1M0|bLoBw^bQzMB%su#Ad4u#_wabcZxl>?or2 zO>WX%WICt-)lN(48;9;yMCoKBPP7gxCp*}2SRTOrp=O~%8g=7%FZgqM9~xm& zN~XL3ei!i$9mBGmz%HZMm>l#2p@yEGyw$@h6<*o2eA`qM7ta$r0|Roj<&5%a<^=HQ zldfvIq{0oH=yDga9|-#8k>iR~bJPBtj@)iozx=~&|RdF0opXyh(+W}KrRR3<6CNf77qO@JH+lsKfvoNbJg`;g3Wa;`_WSClVN0*4F`&FZ|?9~78xk5C>*t~=_gzv|)MEVib@ z;vh%sbQjJ66<|WH`{yS(znoE#+dnAgGr)Juq7CN;`i6i{8zk*-Ex_!A9Y|1tz@+>y z+`Gc2mfhwEZS@s*_{QQRlBT|#w{7Y5ou6PZ3`2Bs)=r<(X@Ro0LUL2{TyNaH^*w^G z`Ue4>rG8hGIPvR0j|Tp2+*y=MxOo>)S}~H{IJ>x6 z8O$m$^FwR&#`<3Q+-KD{$`M-hY1FTLIO&?xaIqr*5Tjz&CYybXtxLqUqJ>&Ba`k`h zBSQZkocG$V<1buj_~dVp3&*yE_^a9P6*w92a;1p=s^?dW-#4Jjt<(1*)2L#RqdAV? z;JYmmBkvYR7gd)p*om>dQDT~n5&h1}_QFd&pSemGD$fAgSZ?a~ccVdsq7u3f!7W94+6Sa>IGv9kYjz&Xy!thSU9NeDR(*z`36W3EAN8Fb4>nli~O1 zYlu=X`x3`7yd{eUX{&w4&Ju-VcmqRLW`aGp1x9a0jrfl2OqKisr#f;R|Blr+`Xgj# z{^YEt;Qn>l4Q5PJ90R+)K*WIvo5=Fvs($oTGwbwMQL@5@%GiL4{U7uqHmzfxTI@rs zEo8!Hq1M*>W>+(qrLt{Ziflt0dBI{7E5Bur9}S+@T%8Li#>YX9W*56hN>UA*0+nnE z`7}`Z)G4afv$}^3On==IQnzI`0~a}DmapbRwX%CB`#bT%Ey{}~F1RbrJ;OyxORAaO zU(sOqr1$psx8fIsyjsUslWNhmzV#+qMm!r~RdvqxALI-iJ8FEby;GKpTxnbGHiCnt zAbjNRl)&V~o}&xA^fZ$|@!sVp@<*4(2rh3Iby(nO(2JC&CW*z3m8lUVD=hkvkeVuK z<--!Yv~=kI;+M*%FXN&o^l727t|~7mW>JI;6XopeEW|ChoIo)=LzO2OYS(X8W1_fS z(PG+95Et_VFinr7m489^TviIxB^9IZ*V{Y1SVNUXu_+=7~}g@y5JR9t?e1R9r69VkKdu=$qo!ifAqP+xo%X^(QO25V|{SCdKmhc z&iocgW)b%~>D@bpp8^|fiq$5b>oWM{+MXgor0=G@HoMNICJ#TjHc|WRCetd3==1kG z_Rk>j%fJ@Z15J;g@Hau6me z96c(Sn3nyd1UPVy)v0h$D{$RsD<_Jr)CO)9ay4^0ZKyr3@ti~mkAl|+?4o`)Iey98FXO#;49j6#@|*JO%Q)FUuRmJS70>BV>x zZPQY!e|}G_daMjdT{~hZI-gqDQ6+Y{*k5MPbgup>62Bv#y~uC&O2^kmWJDg_hOK!~ z7xzcYghGA5YA=fbblvbcdO=(9&i|Gs#^KW*JY%{o$vRA`PA8J+avO)|_!Hcs#qeJ6MGN~t9c zyb(skjHN%E!*{emUGEcIZa=dUKnT#n`-F%{K6KmEC#Ggg0&RpsX>F9{jqhH_S!%#0%qGUobV$A2~B1x^#`Q`vaLM2*| zd3X+op?Jgs8@b5s*Ju@B@5RYsX8oE*`RMm?LK^%i6YlPv9pLE_htc398bJAVx^kZ# zw`pT~wZ0vluswdEU~?N&ihd>QYUj$M;B#AR*vr|Do;zQ*+>9_+Pa+fSm4kTzA=cU@ za=tjeKvovc+!g4i62&tL^f8ki|6^xua&T^us*`8p&ovUT(A`qt$ETr|@c|yrw6uX& z)wLF*S8Nc>DwjrzvO1HNoH2_*!}7JUm%{uUZ+*3sD+6alK*4nrk{-(2rTX{^2n z#FYyjlYr!AWg5s`Z8mgMGr=k+m8+HOiAZSR%P-!aT^@GNkeyM$x00rhsKn)&2O@h# zFu&QP%Vu6Qu-gVJk%t5gFS|Ezux$5xVa~J(->2lKG+)#DZb5RcK-rFQmx3|tYF@jah)%$v@?!3G z*HuJ(lcVGA=Y<^(4$C9nW)aQFKEEr|uGRT1FIF_S^HH4lQ?A18A~&PH6h@QX^`vob zgSp@O8(rp`PoNA9IfbApjZmcnaiE5$G-nf7>BXL~+RO;SnbKndYtE&vsqv4=NrTGm zOx#){NcdJsPHfzW(AD?$Rba}6^;YG|7c#@eJ^t9c$K!D6j?n0bfTf<}HHBLK!K%)r z?lgJUwvLBC{QA1gIJx&v?`JTp>ZMRV&8D6fOya+5@b>h{4vGe^QJDotLSx{irA+O@ z-^;6W-w8rln^q;gg@p#?<>+UQsruG7zny0uJyn$H*yMoAPVvxf zd<&Y|Ix37>w=q!~{lKbg64J}6HQHF~@Ss|1He@pWm={R1Yi9pxWvCHQ_#}ZCFS67{ z%Oz)EtP2qxxzI@hBBFCgN8k0`OzECO2##|Z!WyZ44OV%GV&X0;BIa;{G&p^M-J8TUp6f7#zJI(>x&; z9kmvUn%qGP=@a=?S{Z9Qvhlq+aYtX~72z8E%q*7E&Zj6%A$_l*jqSdt_mwP41(Um? zLKqcJMkJsD5bUuX>+d*JOykB!Row667Iqr;#e~GudTI^us^WAbM!IXgYUZ-CZoQ_? zJm%S2^}L=+)g4$>y0ljO4i~Rmy=snceTu?!?Ctbi#8NKSYo>3os~MfmYr^JBXEtmN znp<^Nvd3xf4^&Ap1RtsZrAny;{qfJs7v~Z zeDZs2i>UM_{G@=}>>e|E{M7W&^r`PbUz8{D6pco@5;68JvdB<2AgoGcvLRdXn6`2R+*?F9x?AP+ z>li}Ihb8+891s&&r531sy;(4d){7v(^brtZyKifyR8D2PV!a)Kww(|ayOq*xVdwar zygKbIg(1?I_Cl9}6>b~GWwpZwO8J705_F63toN%~Y9Xrr>4Z+fzsZJV?gx%(4>M6FTThGw0D-m#}Tr=4+B@ub$gv$ zy;!gP`J1wLFMpDS46WQX~3JKu0g$GuF;)4qG0HOnK5x*(`W3i)k*+#|`*!bWfFs_{19SP+PCtAu zld!w{#ACCj!#m!&Hhy%0CTkp%q*oZDADtom zenOX@vmgHhN4-OnAaAPR7s@ih>64AsT?M>r^##OPdT1nyK}ijyM#5K)+V)MHV`jpe z(DK6W$kU;LxNTy+!sTs~i8kqXwTBjh80+XE-|zvKm?%uZTJ`eZ2gQ4#UwFD0%{yc; zrnZRvk_ui0J}*zqI`8`1ku^dL+s*&HkSs3}FS?M12hrPf$Dn47%KXNq+rPj=M~=d_ ze0hG_q+{4{cB?rEF)Xwq>tC9U7vzfrKio`B&PGggzD5exArFa47_#UwBMM&q4YY>MSL(}0)JwRH|FTL-wxvEMwoF}u3rR_l*mVk)i~-e(zAqK@3!k@?+k*HX6UrZ=2vq#jfP--`g~t zY`3&%`QMvttqx|-!GfYk_4Vi;=RLEen+9daaC-NrgL8E`3tyHi%WZnD$N8SwJrdU+ zU3IjnTNlX@GF)LL^QwOHtIU9jA~hA5WmyfTf`;6zS8oJ$y{32f4hT}&K5j-KiVhG` zP##!1GD*VWxhqOsLgvF!Jgx09vRu@gD`;{^({jc~6_9m0O~qeyL~~>$`_yx7!YL^0 zO}jYF8zrhnz;Qy91;(Z2jEYyM<%Ox3MvkKvpS*C-d<-uwi|SZedK<*a%K9wo(z|!^ zS|*e(ex`lPx|SSae!~Q1Tcx}Jic3=A!^^}<3Jw(^fazP|SQ)5-%PQiP>cP$ZYl z>+`~|w?Nd5(zJbBz>7@`YYVS?PoK_+M$(YWhXP0|E9fol@gI9QbAskL%-lx2V9?;i zh2Z=U1!X)Oo~yB~2~Gbom564+5DYEA3L|=rbcX!MLt(w$)#1&GvwQ7hUAiRYYx9o7 z@fHvTg?>Q-3uq*vh9OF9?%H@TfMZP4(|=76!(*vb*wa<#2X<1v`Hn9Vy8;8kV3kf( zE)5OxIUtvYTJ|s{g$@K(PHWSg?mg~z!1VCiJ9|Y0i(M)dmW}CA9y)R=)f(v(vX*ir z#vQEjkALPi_O&pw`%3&lN(qhEm#Zuaeejx^`05QE+na9jz*5!Onx2grO)ZE=wYUBo z7WO#e$ClO<-c~X5oT6ndiiK6PX>EX$Ndky`WN&suSa|;Cm%0k_R$zfZ+7=f^xu`vP ziH<`$i0AT!^OKJOp7!hKS77k8O8@)kiJn{deSH-VqY74bB_SnJSba+{H)fAN;-=n_kAShaCJ+hk$S{nB5z zA5KHjQkk&dKfe*3tCDw{)cyU~)l=JQZed~Z=252c&Vke!NC_b z?cCn%XCr@?FUn@2W6#)y_qv_UR}dTHun?XbsUK6}u@gweM}l^T`2N?kIp%HhfhWSMEm>yL*=5(rZ$<)|Ex&)5OtH zwbWOeO1_w&D|=*_77yBxljUt66z8;O0SzhI??gBfLBYjm>9AD4k&)AK zZHg0?^OT}ZHl06e8a%=8$6rqV-pl*p6UdO3mg>MEx=i(~N|bNXNJ0lYDN#O#nu9m> zu#gA_G%*cF(u(sEwDu7JdhHK#+&6+%N=x>s9GB9E&vmFeVLhY%;{5G`V?*8MZ#J$m z2cG!2HO9a9|E4R2)`BWVILxD_znl3J+_me5450HI!w_lIXT&uuG|Hedcw21 z=8%Hgj)y)vBcJA-Z!8z|M!q;V5c2!2BYP{QX)7<~82aD{Yh%LtKapCs%VPJN@mWM#G;*bof&|Igcyk zsi}e|%<|g7i1IF&gE#7+_Pe{r$(hD^(zT#tW0y zN5`pq&q+p4hH2io5ETw40s@k~RRrDzs-N(G z5HC&|H54TxFPwIwVCo!3cnXJxj+*rY)43o;&ap%yzxhInDzErX=TglG0=S4#_)=HA zc{?pgZ`z@*!<|P_y1J~&ho**0yJ}20`vw$L@SHjZY9EKP9*UfXXRv3fwPwH7Sp;Fo zI8QnQ{ZdAc%ER9m^1m-vl{ zj%3O7ci+1dk*QxPt&KhdA`U)AIGru#V-ug?4jTM;&aTQN+ciCUOw>v&w6uy&FGma+ zn=xTmkf{Qys~^^Xcx^h0bM6}!t#uHdLzd{jso0OP!Sd{lO^Cvd%l+NmSTcINyW6Ra zD+5+au||`h=iAoT*Xs!W-n=3mb@8bQvBPC=nmXZZ`0Ha{YxJ)){1S;jh!NxC^%9)J z>rcfWanq(xJh(WovQPFx?l)topo%JEQ#&LG@hlEGU#ClbJCN{uWkAJ9sOvyhs)%V} z*=Fr%5`jYRll0>@A74FEx2ctKQSyfpvD`%w8qbR_XJ+Q?;w|su1?Bu<6hMzfrXL;g zn04OWVIoda%+E>@x>K74SS*4iHLUNr&aPXolVc*k)|&sM#9GNjar{*?`j(m(5awu@ z242in?r3-Yb8wRolEVLXvMnz~$)0*0py9b1j_&f%>e=Sqq>Wgq;%A_gwvn8Zqb*7J_gEP4j_OLvXf2w*CPAuYH%WNBVQ>=#}f5;&UsX$s(A3< zLHBwY87CGNWS6sP+ye|$%F40|AMt~6LL_=)p!;3KX@Pyf?pH(v7toR@5qya&?fzO1 zeHiTI42yPCmHiGd0vzx3l0eW9Pxd8Aud)R7%f%60qY5-qa>$`9Y2OHpMkcO|m9rBHD zUAC3O(T~!~6gS=@O&IXKjX8yER~?n*OR`X2syVhLxvUv_Y!1t?9ZE` ztyP$0p=7efUzOLEA5^?zU9=yG!jGI`EvE_L*v=hBe-qx5mHR~Ld^4XMCJ72l!)P_Q z{fdIvdd|VVd82tT$440ZJ_I6-f}}-Bx_^I)X9_;8M@-rt+qrtc^RzQZT*&gh)TzL? zD6VbFPiTF|%~Ty;vf%ZKz(PZ4?Fq(m%!nmh6#1pTP{!y-OltBpCU)FO9k$?-c}ERr zI@9S)M1X(V^J%8r`jT3FVdXCLq+R%)cl~z$9s_08D0wJ{_V}x}&tmyAxEp8cX^a4n zX8Z}V1@=Qj+B>|gwbnqhb$8d`zeh@H4SDbUM9C^stH&g6lBu^kfNOS zTkWER8ShX+eVG8Sz#V^m+0_<=N3z+eTEPW+7e_)0R5$|0VzN?h8uING?Q5xIKJmu z5LO)ts2bkVeyiN|{mXm7PGeR7!-HjD$F&5Cl9d|7+q7Zp z>A_)nlKWU)R43tq6)&~|eu-&JHjDZ_Oo$u6$D)*YZ=@6@-=Q)?FOf$teR_U1 zG}Y*~Fl8pI`1%Tk3rY&$GW1(Bl0C;3 zN8vig+;a_|2rK=Mv6b>D8unbPV5?! zSS6aO&lJj5Le6Jq<%V&rKYy0qRxF^&MNeA?e6gU&%K*9cS#q(P`P~XNq~*xFW&oEF_O(vAW7|Oy?1P ztQ;t;d~ZZ4)&+n0hcCM$Rc&yj|7ro+qx$X+JBy#2R$o zG#2_Gfp{Yd?egQQF)+&J@e~R4>5?)`jwYEE0N1Iqcm(WxIDTf$ajrifOb%GD`GZeD zNS{D#i(D07G(%B}P~4zjik>ONuh-**Ke+at-}OhsTA=+*@pc z_l8u5jo?v@lv*XQ=~fe60iBD>nFxrN3y(#4^c31OM^wwLfm*z&az#B%1dE`XsG&dS zPuBO*09-)pd;B=*yYG3?4kwWDmnTF&yOpVd>+zO6c#ee|Pw@wL25D9n7C`vdV3fI2 z-~};T`a0#VxzEz8?BqQ&5<}9?vgp`Ku5j{r=8scBTA9k9$`oKWC@tN;)qLZln*j}h zeUHSZ){GB&7D201j=7%9&55|+;n(|N2PCTw6f;r@?46^#J`|R=_#S^Aj20EE&}%{T z3PB9L4$&$&`7;kB4}8>iCI``^%#Sgh30KK;`sQ)tHaT&?$TGw=y_A8!v<&y@K^RpQ zxO=(^Wv@yXj*j>`+?u7U0eC15`U;bw;u~$0_?J9F?F9j&n_M}4!RQ{lg%;2Wm&A^y z$A==#slX5qpUEbgJdhJzFw_P~BmL)4mqiq{C;;?X05X=jv;`;`X%@_~{C$fG5dUa)a={uC8iyG{7_gg6X{3ORaeyNR+b zZKXEnz6jg8lhqb4E1Mtax7Nlt`0K0Tr&!fqCj!w@elGqR{P;Rq zFg(Not5pP*2Le?2AqeS&97&CFU*MNE9q+63X#hsb4e(HO$oqW9Q~u4+pSfyLU$W#amEu(@$;{`#%)iDQ8JS%G zyc%Q-Tn?gIc^Fmpe*qDgrdti17Mlq4U>Q{csW?MsXR~?97QJ3vIzZ%wjIZOQ8IfxG zC#tUhXkEl=`!E14h5ngh1|O`wWgE)Zx19}WuUxTX-w`>G6@&jv0<~?;j2a6@BaV`} z1IK~e>QpT6i_m24&@-%2zX2IzUg!4w`E;~84>~N6sn7k#RDn6FI$;%-W~1AEy}+kI zMz{c`o^k%;jig|UH&gwY8bYpQ$0Vx(j?b^+jidT6u}u!bZE|gt@~QgadVbZn%&AUy zwMdl?{L{(9t$!DcQ%(RVvgY%a0aijqTKBSFgW#5-v)v~vwwrkeJ4$ZaV#(9`*S3GA ze&OZ;eb}(f1VHR$%_~j|5>Q#h7eYSlN6jQS8|G0v`11 zO2tiEnVE$A@PJ>db37M(Tyh-u&l+JdjaZlw%ou2YMZWmhxt!U+`yHlB$3wv$HkJ0# zxDZ2F_tL)$N>tg(#sHZ(X-IWTaK;{Kd<=exifw=eFfcPwp%VWt24otNZH z!Jc2XFmN>9Hn4FZww*8{{G;IS&`RtaFPAPD#xH$7rDt^i{y$(@uzr4N=r-8Y|8kh3 z;n05L*mCEhPr=1WC$?@r0%nku?lAE>K8Jc-yX8*%{ZB&)ESM04snE6=u5=$4Is9#C zK?%_BZ_AceEE-y};(0?!vA>4Gp?xvdp)8`8uko*JfH+|)1(y$!k6s>VSvL(ewKSlG zG<>!6W4bfZV@9xjUV-U3-$Nh+8UJY;jU8{lMDN`%4y=iqKi}% z4O3x+UAj1E#5x~FiDk{~2}g@W<&Mk2gy2U+y0@ z|EXiNhw)$gz{%kGf9(h6#7`1SFCK5ZO%hBvKQiIdkYVCQNk>Z4z|PGt*)8~A1<*1i zqt-!VV2m72blCTCVYJP%>EXtXyUuu9AIV#bI%u3jmst%Np$PN;HoW2_4}F=0v-ySB z!wBou`CHJaAtS%y;r3LA`>*@Y<(NA|QC1uiMK#=%jC_!19yMME#(@A{pwi~z|EH+P z!RobB>&@3pb$7kz>*jI3B$zocDsG^R=zkI%RHKy@hfg2fE};JNnK4ua3@+rER{l?< z%9fY-ZENGQLe#Kd=1Sxx|F@{@<=sS8+8k`LSj7^2vHu-xFXpWf1}WhGTnBL#;0>t% zA@)D*(Gx(IyVj?+d3!V6#MoaG#^@2xls-3WG5ws=e$4e2fNE%Ex0o*2y=LG4{pVea6a$~sqFk`Et|{%!JtIURAM(X!5~dQ2Ki2QRWJ2e#|!+C6z21#&BA(N$|w-`@(n|XD=yAL(*$gfkW~7=HKu))h$Q# zVz${LEzAOM#xrQevw*<@1m_rNC5|Z=4H@AoA=yW<=5BM$O>{Qb;gMGnUK@%r{;aGc zyn{m|G&u224EXfkvC#)E9AMWpvM$(RnND7n6xjb`QQVH+jo)Hw=g7-zuwHlu_m1I; zV-OrzhqL@r)M;dHpaR-Lhxn{^fE_yGqm6*8-jCaaPE+#2uwEu6L4nuMG}F^pbOkud zIRKr?|C-MU0+-o10Bd#5&+UGJcs10VwarH==eHampgV*_M5_Z;jCayMx?7Lb{M{Mk z7R}c10?Z1IZO~3~1 z3JkBq?*;M5=`t*-#aEPTXi-=a$ReHJVdrt6zLE@urwt&U-2$oj;+`=TOF7@;N$Hep zy~(*NhtwBJmBG8@U}|m0EU#hv<#CG6F}ZCg#K@jStcODAbXYgHx2kKq!l#7PBENM} z#^Bb^>jKz45vtn~3`hUt0$5nZ8wyeU5XLWFQ4F3Q{)!;%(0b(ymliqU$2a7njd_-6 zo)9~gF+P25hfIUhB84a&LB+@)Z>u$@lWHVLd{7JkIxW>wk7dWYxKp8Og-90XN>wVS^Vx{tmOJROl)zZVESAn>g=DV6C4r{ zb6#(Jbyna+@M)H)oVt5M_F>MM6%z2|7o05_vQP=hFDnC846KJn%#tgfZh%cGd|e|+ zD~Hz_Bn8eNIQ6qo#y9}VG!(QJn4>Qv^eDSxPwTyymLD)Lk;l*HADnR|%s8WX z4dpCG8%zM@b27&5Kyk(=HHclvTXz;MfXu+2k#bfbZ-iT-tGZgm;|^a+?8?1FNY0xllyp>TUt~B^D6JB{s_n+qcLiVxqla85ExJ7Le*Q&xB z#cAYzosb1Y_A+<&4}Cp61C1N7`o?KQkqwKEIR9vLwKU@BJQ0jo$xx^n9h)A4Z+S>I zsL2n^PwZlR>x+S*=j2nly1$lB5%K<9GZL_aYtY`)b;K#)$2SbMV6VJviFzi=qYg>3 zgrZ+&U0Us&)#L|fxa|S(q$5}j5`~q{zr{?vjWKsFU+wS7{xY96KHY3}=eJ7sY*{cb z@{KOBG5Dp8yM56GQ8{O8De^tEbgP_9j2#-%h8yYZ{=464V&S(*l0jN@JV9YV&7yMm z9#sIkOjEy6U`|GLb-4-gu};iQgiEvc2Qe=&W#5BWnPVhNmCG699@pUFNUhhCCS~y$ zw8ROrvH5SGa#GQ7u>$ObHf+C;jpy_Yf;Z^D$wsyTKg=(BdTd>r=zS7sPh#1{hw}GH zY~OhzboES+P!<1PO;uYOBf~@squYx{pu1DLp=xFiNeX(8u%jMuPo^cG6vBhT_|CCL zRkbM__Wp5+c7Npjh)k$hOjHR25+gK!nu|v$2o4eoM6(D}M(-LwWq~P4k zDfhM|pxHr=1d^6lW5#8J=2ZFaw07e&f8PF^Junmu7k@}iHs4y#wA74!p(5FG^rs-b z}9xK?K{!zSp^VDZ}Z z*G(Uh(`IsVr-;W`iTWaAC*wuN2XJ!70=bxp)P6 z?nJux1pT+dqh{2z2x_j_gA{j_P;*S9{YRZzR`YmkEduac0#~K9& zQdOD1fD9l)?UGRewfK*dc|)|KnVjcmG~pL1a^PPR1{ z!zway&)5oB$3@6$di%qckNJ}MW8y1kRmk~ePpMA)^lH?SXLo1h|6{ureA`*|upC&J zeMdMpf-jexIjKM7(#SuvN%VIh1 zjcglcY0Dd^zii>j36gO!lwWq?`r`OrfWmKaif??0(?0F=PpCh-^X7ybl%baIk?zS` zXTn{RRY;{7@8ovKeOCXr6xj7vkTQ?mF^Tcn#~%(4MW3?w2==MJYtuDoeQd}EYz{~p za?5`6Oj%H(x3!hX?u_`uHEO>{I6HUI``t`TYCx%OwB2_F4?_1j**UzZ^Jk$@*_W$U zV)qHz_d8GOuLnq4)PBy*LTGZguNs^lv)&dDVtYCFWrI7`#7UGzO_o~xDpW)+m#dtu z(7(NjiHW7}eqV&Lxp~Aq(Z2`9TL^3*DiUz+aBa?Y3%6^2u*dP@VFipc{R$USo16O> zC`<3{J$E-jSm|UVB%4VVv(mqkTbWZNl^yw5pR(rN)59F&){w@#vw>IimCd7Kdrr(f z8h-NrLvpJvg^3)CSUVcwnpKdzi18#V)FFV$-4a@?A=V!Z(nhe zlG<3-@+~nB6cX}Nm-4y{JzM6Gt1tMrinvJe1K0OHHALLv*|*AQ z?>?ZeE6)xK!~{KU47Br=;);Fzn(M?qm@LF2m0}=6c6K9vM4-vMw2c!ICvime=x<=0 z$jY})Xaq*j2|eTtgliL>pSQ-wVpuXJERtS${^h~_2y2bamt$mLv3TifhCLL>uzn`b zeP?3B*`qrl<|YdJk2paeY2BIz3FbEhVyUTJE!p-Uiv z7qM)>CT`m&|1SKM0XIcRB~&Fsc0RS1Qnt}ig6m5#Q}Gh91WKMbU024xe^ ziRQY&g+Z@* zb#~GUo`(M7p>_3VJ4E+)Mz?=%<8kNhF^g*MxFXap@$|mg&55v;b6i-LYvg|>)2YMt zPc06@*J=I-!%$Fh;lIpW>rek}B=%89{^8mEeI*I}w-c0)(H{Jh3(W*~c$N&0{ z{?7paQqT&jdSpCS3SDenP-XQp}*Y0C@*Tc+csY0 z?mj2)#Qv${WsQ9#%X60^WZiu_EJmUNm~W5p*Q4UK&$DIQD1^c2+#Z@n%-%3W#oSg(#H!t`s;uKh%D`R>Pe zy@}!j52e}qf4}qF@p~U6a%5y6KrWq&f$5J@g_>{#8a=wLp)b(YMG@5u2MN22%P<}~741^&Ra{`ugM8>@(ZOnvRMv^7B5DFKW(p(M?VtHZ8;dsXp~g(EM&LWn@mjOTt@;1v_>>?Dl13 z8b`x~3vL+3H{zr77?p-&hFr< zs;MQhi>o2xU0JWibMVN#M?}UxX#=x0ZDvgw1v|X9CMkBi(105+u)p)s{?axe3p(3% z$DkE`I_0gOiosqyV=&8vHSRI}qV(I;xPkho&2@PXXsoyD7s zG^m7@jQaWOKv3HkPhlUy(FfP~Cj9;`f|Eh6E6XEq+p1J3_eR*V3(L`|y=L3*ll&o} zpkRL>p62Ppm`|${)>E6K-k^^+1CP(2et(ZJ{D9sBTNAk^zhM!vsIe5Y9SiFJq`-fz zqsr=&dE32(qmw%kc`QVwh)DEifm=mh$QjwluSd6AjhZsfAi6vTMb=U0$AQ7^TCKJn zD~IzLea3k-Pv6B$k>|BQ#zO~|+JqK-;_v_7A7&uCAxiH`&IkeZFS%wuP#bM^WvbI* zd5^@gQ)|3Ogrb+)WM%|J)t?pIi3+_4qY#U=!GVI}B{W1$z$41>qx7$hcIyk`T?}n* zJyxWtPoq#UZvLsrS)8fM!GCHZ;r1wiBgehub7N)qEHm5FTyk@Mwd?DTaI-ASM+^Aq znCoOp0^pJM;#``%ttElUzMVn}(^pnox^!fafN3Z9#tXHk%~kM?r_uInXOEr= zI#%x@Gw!qu^g=&|jmx_`tg>ADtjsds!*uU~vJAa@0Opa#uIXT_dlSR(K0Ht1S)NmE zH^eHc$If?ELN=UoEAUUivo|`my^yto#chwDG>%weSf;`$Y5cVz!nWSExLpR@+ywx= zl8Nh)qR#NGwBjQVw%PtN&Q*WKB~{Yh{MMj+HE65wTiZYkc=yax^KD&XW8*U;stu~%Q7CA~S*JX^0xP{*nu^);2nwM$2RoJa7zbSK6Wz+r~ zHC=plR%m~hs`kVr`yS$;l1l3d*&w13zBWHLea+&}9ZUx$R8Ri3! zxINNa^P0|gxq9DM84s0ZmW>w^b+g!Sbf{mfbDlO*a#=ku{6QiT;1GMgUa*8~V=B&( z)0y2qrhdtVOsg)K?d&gi5A%x%T)*DWsLV!5Q9oyG_*gWVTwge;sllW(?u=aLp|k5( z_^vp^61-`g@UXaT%K=Ari$&n9(yP3q_6#wX*JU$5X-KUUQ#g4U%h65A0bl2GYyi2@TJzyka(1I`~ZHB?>yON>8gN3!@TtP6}ZE z)ekt@$}29nTZVax1!q+X$D2VS8rd1YPe{}*CCjSd18?(SPW}MgL~_1JfNEIG_6{%D z`S+XVp9Tz%{4nBiI=#NYHJ$^=#p^u#k54@Jut#V{ar`S(YE9(ei(f6MTvOp$GqNfv;*<8h_GP)Fs<)xyhc(lox$Ul z&V~6rx(5ooMvgccpkKsL5Nyh;i0eYop=-fo$%Fb!;*(1-|1g+>etJd`?IJbh=W~)W z&em3;7Y`P1ovn9Uqo;yZ(Xm;Eh_%^=x%E8omSeqK;D4*rb9+ZTnGVS+PAp^{=GW^w*O zQAU1;eRlS1$_c0y&hE_`D}}J*#&89gvAu6o?BS;QN@&T+SluoAJ@3y%^r<2`Q`b0) ziamdgJSylwM}%B3G!&l1EBTP;a&=>1iC=TK2-7%C=WSe{umr?`K=>yEF%Pp08 z%vL*pTW`Y|%CW;T`c0?$?(a4@!kcUZ6)rYY&Zirh+NjUmHg=>jBNhBCVZ(aai5R{5 z*ZYo@)_YM(oDFX&=VmCpnzitVSEQyal1;+5CWKbY_HCuq zSJOR=UyBs)HZco}ZKyW8HBQuAl%k6`>Cr|)%X#H>8D9g^OtI$-o&`cg`i3iG-`eg) zWFCEL+L!1TbwkQz8OW5B+ZHhZFF?ZCe~koLi@|2JTdE?n4r{3BZ0IiOj+~7UC+d;* zSF5hx46Dx`xGtc8J{D2T4HLoOO=Bf}aK=reb<}|xg-i@%Q(OAC9Wc{4J z(sgn~wpLN5V!WfXJE|`{Y zf4z4b2yVKJoN^C~aAH9FU}Qvnuk+t6#`rVU6`PLCU=%IJdLo^iimI-Af-q7i{3Eu; z6y=sg#Hk{WSEobrsNA-51c_fBaT!>2Yg?Dnm}KdQgQ zaai!owi5Jachi3Uw4ui44f7Sejp!2BcM&ed`IzBBf{82~+lM~vL{uV2+ZF?B?pD9V^=nry%gd^-#ki7f0dtm|?FhOyO@|6WIFUb9oO71j zx;%LCnG$%md?P_A(_!DckkdqktNjt52wg=X1q<@vT_KMy*t9v_}jc`6DBJybt(tg+0hT1Rl#k7F6`43&riGv93# zy_}dF*SvY(WyuX>|FN91y59SMg|FF_Q#JbRa;m7vR7`m#P2SU3W3+mCQMRN)n^zwwZ6=L zb4a4DeI@l+M7OwFHLTnh&2F#$Bd(x+jDz*HQ>nz&xQd~6+O+F0ShVSx#zTXz>0+bw zv!C4;%KU4`P?@rP^@5!2B=S*yB}E+9-^@!53;40dm#5S`SKRdebT4?hpvihq%(}x8 zpfAY!LBoBc3@li{*k7E||8XI5hmK->0~{ljA>1Qoon`8nXxA+1_}AoEO^^d8cW%kYUN>K*g$k??><@#&}o9 zm#uPEU_*Q)J((uQo?|!=qJ1-8qesTUe|8QMn;f8oKExKN;U1=T0d`H zvNUQgSJ2asTb+JPNU#7CaTGM#dM>`JYTROU4210-My!hO^GR>;yhAh^IZo(T`v+3B z8uBYXEd0w>SS08K6R|@aO`Nc&DZOcnNue~77)gHe;5W;g@y_teMvM@BM}NFkf3j39 zdUnf0U4lsRJ#30J;^~8D)DeEA$ATf%;Z6J9`a;e! zl}W7yVSyg=`NO*tX-2ER36Thwr<$fxTbRhd$N=HkJ^sFkQ09C34>YE~lxXsFNjww; zI3Hu^#GSRXO|1y6Q2CRc>|K2fu>?VL_x)%m7DZp|UMLAWKMt1<`*z<}SiW62=9sGB z)YBuw@3zf<43)QqJF7L65?71#o8P<8A{_@0Np>nXZy)+K&*d=rN>5KHI2C@?ErXxs$`4vjkp?Q8WPiv0TR!c4un(3#krEk5!-??=+Kl&F@m#91+XlZ?1H z5%BQlD|`&@yFxTFc1Cn_fR=iS{`Tkh_MUtF>>XcFq)v6by(C{PbKoo90ghPcO|6&s z-#K;n2j5sPPu)Ui?hHAXqte00&Gefp`6e;(&C=&9&Dy-+-;y6E%?Hv1+Avd2*5B{2 z_Ebp~n&19%5+rf82B+Dyxjp$S+vc|fKMh*Pa)$arV9(L0Z&ge^xU1x{Z%~LM5BE1O zEKN8PkY1;SuYgU|_`O06nrRYhKbH9$QU5tprY2t-e`f%yzF%7~%(>1Z?8QRFwX$Bq z=32T?u(rjZEI%%@Xg*qp*JCxe|!Xq(2+S-J||JdOMzMJH;;?c*P92zPD3h=oOJe1F(Y10 zw?Z}<8bV(TxHl3xKOR#P(}yhB9-P7qn}bQd4cU^3GPa^0uwc9*W?=Ui{XI#9hA1(t zDUx5M?hVINN+QP9#B};QM`mUlL*JxujE-w&C`1#R*GSlE=DWEod>>60e66Um?JSPN zxFsuCUvu1bF5dmr{-uG_(ZD<-^i7X+41{(31X1TIbu_8r)sx{Vy7dldoMI81o@}gQ zC^Pay>&TTw*FkV)L5MlsR@SfBpcmIFeKTHG=^}>TBLBFaVWSBa*p8>Amea+A8Pg)4 z{EaOn3E0905p#CVvB+#CX)3EoDm@#Ny%bC&$Ac*TNTg&#`dBEKM9?iw4|FsH-2H`3 z($TMS<%iRu!~-yU$)==Q<^6NlH0rKaEG3W^JQ!9#B}EX>C4#*Vcv5y|{yJhD19R<;4~ zays?nelhg5Y6lRfioWN#G*qVmYML3V3KV;xY1cB=pC0I><-d$pAnrjIID}VbP{3#Rq zFX+EGa7#+bU+=i{4K4IKU-x}{e6ge}Ewe};)jexD64k~3Og4%z*=#K>)l`xv^2SRh z9S^0vjZa;AwwXuq{BfzYzVu%U%TwzoY#XnAoX`}SbU+~fDrRKS+c-3I?J!j}RX?4( z=wSCHn0*f54*D%cKNm@zSdVve!E8v%>-4VnDu{QsrC;m!-crYL@eFs%Z(Hk!$6W1P z%j_W1>z^oZwqWCj^oTsH_*oC|CQ_3mXZ+#P-(Hwumg_n(o|b<|v;VfG5@Z=Z_JU$| zp}NO>v(>1=%UQGXcdA`DK%4eVDGN${^T`vz`fEDLpRa#BGbNy8mK}dY$yljdg zy$S$|0QiZz_c&AjI2E-NNea!=`^gGZzcTCIvGERT-q+wGt`jdVWM>uzTa>(3R=7G|VTQ>kn32#6EAMndOWX`uRt zran4rC%#6fA7pvzFawF5Q7WK=2Vf@)YyG*5Rh*0Knf#NsWk6pJTaPak|MIN&*e^ux zbc734ChYko6ozRT-x8_ymG`ME{Wf^_SC`gyQE8`PA2##5&SX{mZLmLn z?YRe&?;7Wo$89i$Xbn!i^}YQkcGmm$+qIr_qO-N~jRyAi3Y^CMdp>UVNQi`W-GbY+ z0KdR1%Xa2eR@M}?sX4efL%+}ydMG(mpRJ}|JYcr_BlokxuXeNdg_e4 z{Ct|m_%P;)%WU&cKW!%Ca?w_nL~7j%D`VBJiR#b{EwCgXU>tIPL$VzVWc)U(8!=#O zp%{KMeAv_2<~Mn%e!$E&rHaq6xweFgst*A(>1izgwMn!r-u)opL%M;mV&Rt zyg@&>RD4u#0P2J->hf)AJ3igNt3*f>0hN_zpxXG+yQ29oYfy2o71vIAfnW1@ki zL+Zw(Jp-+^tJl^VbFG2KUn~a97=aF*y36eZ2M7NffcV`80}z1Wup4JT(h6|E2a=^s z)O;(8X=c!BXFuFQRBrYx)r8ZqZA|wvlj|Uh*9}*@5oHd9g65Q2jXdtzom}B9)yT>(S=1G*Wi-jVd6; zzACoDvn4U%YMrJH;zIfv4Wd~g~w8#LG(a59QAeGxCKe-(4h{T@$Oa~pkn)|-z#vB)& zwR)HBjY7w#W(JcKow9EQwziyi5>JfhYSKf2Pb?fW1q_h5U8ZcbW(Ty(u7ot$zOK=sTJwO91^ka( zd?R}5e{|3Yy=>iPr~deUCT3vio131xSj5XZ6q^mTEr%U!6FZJO| z3>Ye%_3UO~-WJSIx`*9i%x34G10;Wbln%&rvTbjY3Q$d6=-hN)1RHAg+}em_>?grB z?9qEMx_%>W)sy#H8GLr9!|+3op?Fu>Tav!k)x-Jhb@|9Kzvo^W22Z_|X=PI4Een5X z_ha}-ZUlUb9=X*}VH*&{I~FX2Y+Zfc{Lq6>3pPL@>qzE5x@P19EJrfB%1_A<-CMED^yLE|7Q0M>-A3t*Mni_|6<}N$^Y-c zZm8z0v?tf8~t)FV6h1GTpe{tFRI1alu)# zq0Rkp2Oo|#TZ~V|1jLfyZjs#k6j^LW|Ur&9cUR?}6gcNNHyQi7;G=r)6=np`S z=P&EI8rJownk$?7D#Jgo@9yM}@9f3OW>Qe_4fH{L+tP1xreI2_ai~68$u@4m)6D5*-OCztzu{ z1zamvuw?hP-EqTffA&i_(W9MWfN4jZ;wP*X5bxoOXB+yQ4)8 zxlTsxT<00CjL#*%urT%&V3Be|cAG(EXWPwrls|W><*vj5bF>SwJ+n9WrjA1}{UFHl z?hu{4&&BY31;QZm$QOHhuk2;&(x3aY*>7z+j%p1ANrg7B-hi|=0SQ3bt5{jZ_ zaXqJR?EAzDCSW^vuM=?+p>fNP_5O{lSiZoDPtn_w9X?>$ZtYFnQL6FZQ$p75gUYYc z&s_rc!kTeu>DB9?Nf3`ESlW-!9!;GIq;W7}^%5+TpsT-cqhE${K2sb@g$^zqe-@ACPn=#4@N-JP35ds)YQ&858o&5?2! z5Dl{g@D=xDBJsk)i=KJykRTG6E3E~x2$0JwCTkYxY-L_|TD&rr zqi?;%-l}EQ7MEM87HMH{yllsLY`yFP$G>ADf(`|yjS?D!BvzLyslU{|&3+4WMm4F$ z=o=dVW~7t8*4a^w|MqEJk`XSEFlpwD1u7Ao|HI_RZ7p>*xuw3oKXN+M%z{}z;0xAK z)^v~nG$*`@)+{hTeQKXL+;>Mf0Zz>VOpVRz0sfPSt+4z`Z6SHkbYFPx;`L`IVDUT& zZ)=cD!pys!J>;|^r^n**{aw^9(-gglQ>)Zc@XYR8x&YVm zg$1PV2O2TAr2wG&`mWZl>JATM-~w&%A(YmPCnz0f?jOt4yjW;u!aC{;)#t$(G55#9 zSM##}7XH?E7hBkTVV>2cO+t;%zCt{p_r$+xOEQ}{2W=MOZ9OAq4o0!6T^iM$!!ez)!h)RAiVYVCmv(yMa_`fI16TZFVw;Z5$St))rl53ocp!rh zbWSaW-C@6YYDyR7(+?y&weu@PDpbzkP}GasA?!BZP2~zQRpbjRq!f zXJ?D^-1UMZ#DaH3=c;(@)=imkF)&Bpq4UQ+myN*1C$hE$ zq6=_ql^@jLSLZZ+x56i|d7qlhu_AZFV<@jbS!tvBNu~Ou36@zqB-e#WfcBl-pHS!# zTR4^Zp1N7;EsJj7m9DwngWR8caprL43x&31hllK0O?SAGYP3^TZ1=w3()$PEJ&ToJ zNahZcf5+BIogJ9?8zy~qN;ElZ(&l|MmhSNP9DD7Sawbjrzly&HdEG)+GA(ym6|$42 zn8_WAR!UT2RdZor^e+CqboB5j%j{?R!cbXuFOoBpD(pG!X^qOzhT6X1WhVZ^p=)nKSl)=P+dj=y)H^0=^wSI}>P zy?jHgayf?B=@@t5DvK3#o8B{6G3@IuNiCx_6W*1bJ5i>RnVcg;MrV4ajKH($P7-u- zx?OF2X`heI^cbaOyNWa~wWY80$g*6aAAgkscNfDv?bdCOe6!(MazlX2kyoh$PbM9! zO7mYZ+>heS`=$9f;od%l841zb5}?s;8o z_$~f9uRIc`Q*1%i7Zq8@#AhewT`%oWSM36qhxXQMUg1Cx(&x7CF(21)JI--Rw&cuc zyI5`~y%LTr_I%@s9k<;&E;Iul#|nDO9Bb_J1;b#Z6DjYV0Ty2y*!= zJE*}*#6b5;!YDP%qu)3k&ySjjG^LDDA|X*okd49`^&^^JbZbj$p4I3-O+stjPD6Ac zQ|k90B&zoQH`{vo`n8$o zv>{}n+c38!gm|UFg6)Q!ckRRGnzH8iEo_bdBw+7ZzB@c8-qj}9*K+FSc*WZt<=Wgb zZzfm8TQ)x7vs$0*Ce9UJnnCikL$oLeL?~hc2uchMeR@g>d+k=_?w|8BlNz*Tpj*lRpy*ekJDB9)TYJQ&!(IKbmr;W2QRR(r8%;26Lw ziw~oA@6I>*I?lBLf(OH>zpf7EC4zlzKfo_<%K1wSPRx;h;w1jt@O(oIV4`Op;IQVn zWE?(>uAb7XE?c8oel!PKxQkmn-zbZ0Ovn#sf1gdiChO?trQu;+9cq!S6zrFla23K- zwV;nXR`)zkmJi0mcK)=iRzjIl9nM~DG}|2xTJy39-u_GA>64p$EbmBfU8j7hyZ1A! z;$xv{yCavWl0#7Z1mnI0pxSPH8uv(|R2NWMxSn$Ov-R!Mit8(n)*=fY-oN8Pr5qY@ zGs%tS&6h!I+rz2{UyRJj$l2N62@Ssm$CL38^y6l6@1_vP5hLRGiO&WqsGm}yL{9v@5+Q4HbFxZBq)X;Czt%6B8 zNl&kgerS?z^JJ}34N1CPZild1^0W3t_w|JmM|-rD^kw^W?_)gld?!|et{MVb(9Oz^Ptc*yP!ODa7KFA6hC8!TqMu>>tN>??aU zJt8;Ks~;?wNk`0<1eJjax z2m4@TsRD|7Xa;jf6C-8DJw5Ai8yzJy)UGy9l;;wgp(`EDhSS>WYc7T(=7_Wfz1uz; z2EYLn2tA@-YCDVZk5}g^z0IDCRdAgW&*%bf_kRaFo}R8r0KG*HHp3e)zr~D^SJ=rL z844NowO=|;NK3#6VpNGZI)rk~^6EbU*y>dLeGDhGD%r|q|~Jv5$UNx~30S<9lt*bwQYb>igN!yf|EDpraky^rKe)5elrD#x8|z8WH$w)#mR zDkFnYyVe7vdC=cjT-wljsqe;hqOKSKaZza9E9md)`oLg!J1na*o6wYszf&qp>fl-_ z8=W!gml|26%bZ<9hG)Hvo(p*oy({bH`2GZ&5dW{W#k(`=Zw)I!+stpG5hM|8WZ)hTsMD5JVthpTk8k_|sQ zoooXZLIkf{fR{K=|T)FMry8O!? zUQ?242$J-MdR1g_Z96%`QvB_?&CSQvdItR|$C8NkToCNQ+00aCnC#c4n$0pgqEt`kR{hb!bLGZ0i4boO1cPzyl9swvmLh1?D%nsNcERd1H-hng9RN(K zZmTwd!&AFwrKy`H(zI>Wu-(!1$g4~Q=GdAG9i=&tgc>h1l*vq$>J}dB?Z;iz&=Tau zA>oEjPg#YD$Dk&!30y*-&EZGpDAnPzkkkxXV%lf>v=ozJG= zw^di<$fxBpAeoJ7((LL%pr4k~X;Svy)>d}FfSat}L4nJi8a>3wYg0p`ITsEhBA|5n zu&`(#7lp(-S=7H=0A$&lNda@r*-i>eF*b!F0H2XX*tc@7DnJ?3NZGPX&%rVYs8rdA zuLj7Yikk$jl)sYja~T;nMV<(i7LyA@5ID1aFDHorEqy(1rXgZ@vqxE%zzG}M0P*#8eW00q5((?2=9FiN|i5E7eq0Ufi zE=?=O-73FFre2^`Sry;sbUzt!gA$Kz~o)28kpHYHizXjPS3*Pnyn zx0$Uf0ws6GOIEl2s!2;$1BMTj*Xs6LdK)`TH*XSYDwZEU1VLps_0_Mq{K#;>40w&j zGA`jNk`oEIk-B8VpH&yoofPLRy3DIUvMDhc8o{P2e-G3BYpbZgY%bIi}WK8bZFEw{we#{=RF#d8YLF#wOHd zNeztRCP9p=8h9gI2$KXtJ7;Pu?qS@=zvnqVtxUCdEdQ_RRw(~!5euQl4{K=gjwf~ z(gsK$pjzr&-<^o@XrF|Y?w;ZQ5kiI~C$q zItBD4BT;~0B3*9wV0DkzN($H|&TSU66&So!3w2xay`4~z&!?P8VOZUmm>Qle(bU3h zH{^n@4=mK1ke=v`1NHXN&w~w?(jzACgCkw=RiQFulggp+re?UaRbfWbh{Tq{5C^9kk zLY_@@E^~rn@(2C{Am3_;U9f(ua#UhlNSm+>sgQeiD{V7|^$pAy$|Ls6i*!*Q!_5cW z0h6WDzC=?uxr5cbn@rqpfF z>hzY$?(AlAHcROiLqAo3qeRp6V1IPC2kHeN?c${jg9ElZ3$olEm&%=CIxtnT5NI>* zd+F`qLphPuM*8YxNyOf@XR1qmeB_E)xsXA26%H%H-Omgt_s4w_ z0C9@Ems^?@$^kUSV(ZJ{gX~G?WxfigenJd47h*@F03M^Qv&wa3ARqFdE=9XKBwp>0E-%W0w_US8WC- zz{_RUdyG-;c=@H(YLoq61{HJC8JFSfC+)>D68T)pnTET`gfVQNB?xm#EPnRC{7nU` zE#jgHaOPk#)a^SV76YxmqCkCiOrxJn=0gC{hfq;k=zc*t)bneqCj=LU>PrpZJb@i~ z7s!=kg09?n8$+b&GE)b$W>Qr(HiTQQe{Gn=78i;|7|HrC{^Vw47?7BB9RkitXLB)S zO|AF=lKtjq6HQY6;k%c!=fg40(8aO%F|qjNQ&YyfBJ01w=Q9oj;2dzaCx_F=_ckhS zDGSzHvY4BspKs9wL6~B7N z_GR$KpCvE%hc|kn#jg&R{c8wtV#C+hk8AbIOtMqR!Ph{SVB|Jm=D^;v^H7Gx`RBBCt794EZz?W_K33VE9l66Ds zL9vbA5uOSu)hK2j5Dgfb|BuBNOl0oZ*^fSym< zr%x;Go9iRlgHfTo7+EW$isPMqZes&PKP(D#@h0hK&ASyh_^drrKg7o0)fXt^#7Ku^ z&O^&i{=cGkeOZQ9$x}&CpW&uN$fiMsp%hpZ$T*;H>$zN7=xwK^FT|DWyfUA0z>RUQ zFKOWlf&3IYFlCc$&XGEO$69+@QY^++n=g?evd+He8NlkK2Sn84Gm>eFSZ`!yD%Yl_ zW+feQmuyQLPfnVu1I_xv?-&`LSaTh$S?gg$vL7ptII@=OZP8>8DnZOyMVu5jp|xLy zrfa@7CvpLqG`5Y`4LuF)c>O|fT)r+u^Ed$*&V@$&w~SOvu=P%bFgyF((w{3I;J7UY zsV3Z1F*)H}a}pi*!1lSU{>~VDe(=rY!hlOPzKjjAYjUMfvpnWtIHjxHDCsd|$qWq^ zSI&g`xd_!_53qScCCViyzm3rzyDx?zu&i>q3mrDmEAjCPi45O0b8p`5KO0YcaWr7o z{Vb)}3Te!uj76^Ywu2p$4zq&{MD$(%SRz+{ERo(#{cJIG&5xGM0b*ksjQgO!@zCsL zQ6wCCKp)WaMvqj3?H~qIgdbJE6@X!tBX9NfXOMwI6zhDhjVYJC4}fs+J~oNO5V?FRx?{wjNds8%Yh> z>eVin*Kz+wb?m|<@pVF_&^A4gD!&Qs9$Cb!Bt>0HOM8HJCazBgJW-`>zlsR0ED zcs@=(P*T%nIukG}oXDzrx;G5o93X&xTva(V*y7oZ)xoBU<<$kf?i#CSBZD(e>?1w~ zJ*pU(xpIg(Yw@_p;dd~%|H2wp!M`+_hI*ErkFYVLjc%^p!OopiHJV`uDo{M75l-YfR+0(?bWt!-b)JJ3;dEWmKRu-t1nJ zp0Z3bP1fFJr{ig9UlZie8il_2i>S49Kq!|?C&(ouJtpwtFcBkD!*Wgj#87`e}y`PK^tu~P{8Msw=h)wE3Uu93^)WWi$ zwx&X>jswqw$}v?X)teb!|vIf7SHu<1uX|^K-14XQFYTkg?d``s*sBtcq};3^4oK$1TX4vb08H_bS$hnXIDVM}AP zPWJoTvI8gUUpEIG1j0iEN!IJQg8)8yyi7wqm98w~DwOmn@as(}#y5t2?LHapuf+(@Mca79svu4|03cx<^+_TTWQ(vk4o47WHec?g9eY)EtPm)d!tS zD^4J{`=POtwR;5%*>Gnkf2VQo3`5oKCsv0M`h+D z97j6W-zmg3;x$NhBrDCTTq3d3IU9(@elyug_`llw%BVP(ra=fnf+vvRmIO-(1b0c$ z;7)=Q++~0P21(Em7zn``oZ#-k-Q9HWL!_BhUc~Ch_mQL`=X7z7`BOgHCu|&NNg?P#(MP!S)eFlq$o#hbTDq6(HC9u zjU~r+|M!iKi`LrOoLa-aa%ij_sMGU*P)YZC7O2oYO!L?b>Rnxy7CVreSvx;p&KRCG z-H5z&pXqNMHQP3*Ruts7s^J=yyKFG*%YGM4L2aF_R+!Ur+y1-K$Dk?cc3yMEa^Z6F z>}^SYw;=NQ!5RWei{?QvFE+B3pY2%d#=%SGNnpjt{r2ba>7*eNeSHMxxNu!kTX=~) zRrp!OCBT#Ds#OV(7`iw&*10_iA4Z75WVkWF@?N0(siQAvr<@cXEL@yLaq5UQ$!Jc% zP|pi(r;dS5y_aNKAB<*F`-j7d7a%7bs-V!bBa{OXS|e7|?AcYk;EVwAlufCf7)l-i zlr*iggPL7lz1sRHo~kgTY%$h;iU?`)dUJE4<79{C5%fpx^k~Y{`q?H^;#m}1uFH$} z>=f>wWweG)^kqimk8*0ranBDAI=zM$U~6b!49;&%{%ESCFT~LAf2!esG+i_a&Q+2> zrCr}t{`~&?O8YAinxiI!&wd^zd9UpHeC`gXxB}szQMY#H-gNR?-8WeVx7q{$XKnwN z9sg7LzXSb05c&r~{|O5H2TlG#lYh|UKX;n_Lo5H#%0IO753SrwtN*V6VdAtMGdeo@ z%9Q$#h}}q7hx~86PIG%T4V}e*iQkRhvGV4%<{Dxs>zVVHQh#LpGG-0TT!X!5X}ZPJ z!^_9*hyN-*Y+tTg+i9vtTFZLRpNtUxi!CB7Llzo;`OA!R4c0V@wH1oBK}#Iv3sVk9*82!YZ!N4pgS769P;i`ko#jgWnRct# z^Zx@C<^LC^{znzN|5V4@L{Get;aR9@NiV?qU~1J&!QB08NzVM@61CO1CCmNz{Us%zn=M4FFi$jr{N(gbooxBvMBwbIav= z3P0}<=0085sPh}Rr5${I{HiG=Vrk3sEZKSsLOvu(#fMmVv$|UN;ScgGyKP|f(8LX} z3$Xe*b8`@TwX_GNE)-$#V_-y71n9j(2UxmK-?#f)Jif+S(E7GbSUj@)$bySARW@xg zIfNgK2;%3B^CQ|5+`7|EZqeo9=C0+SFaTL9U(pf4%O8|>Pu|}NN{YTD{5{jx18a<@@S zwhje3FOFUL#sIGmWPx~ZIkHW=kEwY%6RwQ#batv}l16S%K`!H@g)(V!DUzoDaTmao zOULJ|cv!s@6Pd!t z9nxrgRIf#3Ik!y~hqQ?hy!}R9>Fibl*+z*T-#?!5d#kfp>*ly*PGQh=u|X2iiygT= z{LUI43Rc;uUzpy=$t5atWb+AuS2FVew$9S#8}670Cuh*mjA7eJach^+_X#;tya;Ee z6HN(iN8sQMmGm?BnvId$js};@G+*&E(fe$DEPfU6#N>0MJI1>mpf6|3=@g{G`ZwoE zupVvS9SWQa7-bJz^4$)-SOZ2N`Um`e7P{BP+PONR1_)I?SPoyDOt>t~Z(OvBJNnLn z4mh%iV2OYd&sV1T@bsl&YPZScWZ-ym^4U(VxqCANTq?wlKb?Vpq6!qg1=8cOx={Hqp`N`mm_!VnzS9=?z7SyVJ1fPE+0Gs2(cg zK>RFn=N;<4jbD@SK*`Og+ZJ#iXHUx;*sZ4H?V6ET0tk4)H0gX;hj(XsZ!`3 zsX`Mn)R!Aky5Sfno?_5&3NIdF#hU3SNla|HJJEKy>VM&Rd+~kPdv|6jilq6jv~D$9 zEbBdClAnIF+fs(->HS)FYkE+f9&bV#H%70QawCQh-WVmqu4{FWL-{|fn>-J$E#meH z-@0yQTx^J>9vo0&o}JjBz;owMkXb(REbY|~sFk(+Av=Yd6*K6gA zI&&Oy)9A|Xh!}?5tt2m=ZtS48rp*0rFV~Fpendy?kF1(5B}d+_E{Zzn z$a)Daa0v>YxKr^(t_Qv#8t!VjExGPdaD;eeg^n%W9hQhEYEB%bzO;ttpQvoeiaTy? zitx}SXsArzCii$&gTvvs1S2;hs4Oiimnj1XVCt@kK9@s~$+6|8WU)oX&`MVsWN9Rn ziw~I^+$~_H7?LavD(p7wF@zoE+iEwThGtb<=nbGk4c7XyM%Ht#Xy+U|yqdL38g{Yk z9E|8{?7hS)JiMlxHNCgJ0PMGu{;r01S;?M=>F{jP9%@0+5Ibn{K#QlwEj<0x1(Nw0 zA`EtEa%-gmW1;nX*>bTlMX_+(-}4V%fZNh0!CHgUfa|H# zwd<=B@&3vi%XO7!;z>j|3pIX42yzAPg0Pnh`y)DUY}~=sj3pxemE`N>3#A+|O_6nC)tSZhhRKW38a4((VF+ z>>7E~KZ6yIAOX_SpE-!%8jBt4TUz%Y%yO&!lc2YZ&a|V~iamvR$o)aFHwj!nTjJ%GJ<;aotLGK> zS%#qyFdv??NTv_GvQ29=7rR%Hy<2;In+?7UIzv$R_M5q@{l0_6<3V=p!i_v2HDsju zY_!qzPbyKcNX_k${wXvKKA)?5Z$iI4jz-?mF7(C@Xu}Iw{bv_c43fot5qGeq8(C>j zSSYQZrEvm!KR(wrIYap7O9@;TDGcU7>GCM@;`_*m_DTUmH5oINIuIyRu0>(3!%xE@ zdmyhpf6b18GvpyqX%d<@`BIuHCcumVi|j4>F8v@5p?C@%8F?~=%N@CdgfBkRWYJCI z@DX()A`x*hj539Lij5$yF1!SNk0rkgtt|bz@?`I6UEL(+`5GXQ%+6*{lYG9(C97Dg zQ)`nxGzhY{W{V(r!ALBY_=<#`&A*yx*s_>2`5cEgNrEF{hc6d=wp{qaCmBJ`O(yvT^v%;P{>Y z@JaPZ)pK~suMO^B>z*7RY}Po5G1Q5^RmIig%9KmYlG<=_?sHAl@tqN=8aXfox*b}t zY?F>}7xbpQvb9WL`ksq?(mR2jv44>Lt1&7wSPj~}O5!1Jog-y!Y+IAsX0g9kvT2&#)vI_e_qJ8?XM8G?|h}A?_4rv64T;juU z1?lu4&5Q$(U%H2F@_}ws>dBn5nM}c0J&3i?Ze1dUqqXksi$t0_wU_SMsLGcd`nKk0 zbIQA>)0}4Y0B(Wrx$01Adv%Q;WGcZ9R?XvHxR8CMqZ30Q@q6!!-yp#YIEA4V@+N=0 z3t32KafHkv9d6Q8*h{g8((o?mScrd2?98<%G`s}f)go;f!Bws&%ECtLI{vdi<9knQ z?{-i;M>(xdsTaP4926MmrarHk6&-jUs}<=dbwzoutl#Ct*$_#|=286KZ>J^0zhP$+ zZ7%VPxE(u7FDRrzH zreev;`bZ7$+JC@fCrbRlYO&R`!ySJ;yk7;ekkxuWt;*F#pA_WpXFcGQbjUMdB zyFT%yVpk%?gz3FEG+-QwTL_LTrnlF^(xX(|74}VhR+}_97}VG-_{Hst&GlF?uc)Tn z_Z=GZz)%&p+?CXp%2%%%wz6_W9tnz;EMk)rq3h8>BB^=M|t()=Ey~ zp~q@m$0>P9RLY9L9o1z-4tgaxu9xNK1IXs#7wEX)9+8Wp9C1lW;*Bc-&s1eDN66mR z(6xCrmAx0&4$X;o$0~$2<%qZkwbzOluat!zoH$hiC-ntx$wwmI88Do z;$CFAe!dhH--;FHxp^M!`62Y$+g50|etEzUZ>(Glg~dE_Hu^O_7>k>Iz{wWl+Xt1k zjb6f2v~(tBgUI?VT#$V7?EB2wdRB;$T0dNfV=qoyzJ67eUw_4lWRzc%&UjGhaA)zQ zlx$nQ0H3QHU>)k^ZV1SVm_%LY*uk4F+SVyR?{V(Q+V24d)c*R{FUa-5W-os5d`2e) ztfNDK%T*qWT3NoIN>CMXYyj(IKCBesHJ9CCr7h{FrtXSkCV4wKje0+I$otDb+M}*9b8)<~1XvcjYYXW;{(kcduOa_ErcnYzIX>-QJX1`op zF~c2qBY(e`02yp_@8=q~Xw`x6o}uHacZ{xP=FCfF80od+$rIRWLsfi#mMYsRZJ3{G z&Xr{uuT;3!1Xm%WfyJf@=9_C5bK6b@%1Xy??;paOwx7>@w`kGDNPndLgzoXG&f!)H zp3Ypw^RWYSm7av^KsqXnJOSU}Yc53ZEhl{4<+H(ajt!p5Qw-*(=31InY?(Z6gctjn5nTwK~#|jSsuB;WoJTb zKhF7D-D^#c7`xu@FP2Q;iAFxXSEde4q=H0}m8)s!8i>2zd^ zjkL;tkc^17_Opo=VBw|2Z{IVT?xkOKi!+`g=V zTD1qcN`k5zv4;EI!X=f%6*Ug0lC!Bl#kUt03V-y>v{Y1VOQ;NF(crc9Tj1dX*s|0= zam3#^Z;2_}7 zOmFce8Jr1E*r-9y^z=Q_jUSPmTXhs25HD!ImDREG zYx^2Am7U1EXB($1fknH!@*Tg|9D`l2rnfg@HT97$vAY&b4`ay<&e5Pi;-?g!yos%C@ zz(fTPt&k)b>-<=%wuOG|<+4~z3MLXke=BEhz!Cg%$dw8p^|>Z0A^3H(Ifj*gx??6j z51G69XeV@sDdYZPS^v;-N=x5L3f%^BKeFOtuH&&O?zK`?YE}Pi{&Tv26USs+K8-NDC8r(Eab0+h^aru#*Ql=%nhs3M zdN^tY9?W-@(7)jh@}GnlpjYLG+Kr@+Oiw-HFIB%@1Z$it;*FU@J~?>k;& zIhN}6_6!s+Sn(J{sxKZ#l!mpM9}SVDru3~$W%$}*@J2fJTl75Ok9E_wp|+V8N8udU zhrjQlhl}yrPG^TVr1=NcvDztVnW9Wb>jQgLhkb==`U_W6K{&XY>G;1-lC+&i)}hGm z2B&OnpWpKb7rD=dbJ%FMY4I9fC!0Q?%E2XhdV|iU4DJvJV054K5qg!rGV#fB#gu=M ziUuxRB9hYRc_bv&S;1g^e5(56Dm~Sz1m!MGv~1qG7PPDy{&=l+;g~|<#RH7dyjZJA zn|(0&v-$9o2ygMAu49+gGo~D+R2MLT2s*y&$BW*Y-UxdGcknZ5lPNGb}Wh z9~u8p>1S7;eBA027H$Ssm{S84H6pbq)(xTcB2+j7LM3Y?*aPvdpQY+=4h$wXia6v#<)J@timJ98&r+Q(pJ!loKRJEX_WN`L7 zx;Z|$nf`Otmo(@Pt$s8oBA#W8dEja6sw1xSaKuix9TXh4PMexZ7*ibujgvjU(5B&;DznXlWc8EF>c%Hp z_C%gob2|TIL%OOi_za9QA$ojIhxnmKTM)5J=927TZ|-z#3A6%Za;M0fjPkm7q91Cd z(+uaKUR7Vu>!lyOQG%}7kQ0uNZ!qbTplOIfaaQt`LT6Qyvpl|=I;Izr=^WzwmRUME zxD3h9{l4KJ-Sh73)Db&b{hVF>vAJGAt<$Oq=MU(h~3>uDEHVJNhjsU?A_znqiNfi{4I~v#oECDmXKZqTtsNa_vdE9RY+;BIlXsIk zs;n62yOkrGu7roqD7RPaKx8RGZ?j>gZ6kG;)|@SOpd)+wh#$Z91I@dBY9}|AkI8Q0 zVLgiY(mPo2z{hkrOyaWGjW((2J&`>FU$7^?qQBtqQ6E#3Ru+2Ay*jF2v5cm=^9e#7 zUYMOql7be{Xk@XfK8|5`*Y+yTZ?g9*V5ku$SgBjJy{=ercQ!k-OH;giWzSoBu0e^7 zhI!V#Ef-2Cou!?wHoVd-@#V|blE9ve2HaY8VxR{ex(}t+tU9h_e34uvs47$-o?Mt6 zd&Ydw>R*_9HQk!jLq@-E2Uw~|qoy@dVxKagPWjp(biUrLOuRBN^z4azctiZ@qx-a> z?EJdLl!qzgr=i>Ua|Tr*fg~83T70Ikq}Fzzk53($#ZGyAJWP?wt)X0nB%1tft$LP1 z4t;a1&odawx8z2F-Kp5DaTVrI5s)qMgH1QL6MuG6-V}US!7}OKC>#=Z(8RVR`R4XX z7M|&YQpZW>W8R%EH)MRx~b|6s()78lMboarXpSM zdg3!0@Ahk)ie=QTa)hVAP|N$lf=*9#&R2AP3F6QwLV1}t*6nKxk+6py6|T7Tw&ya? z&BUiKb8-)_xoEKN!^$a}vwYaUyblv^hlR)~Jixm1!Ag1hhd2zBDCCz&1Yg#@d-_EF z=evM{9b5?P7&B0_TOiJchBP*Euiup2F~`n_DKfWU&elzRd~zT{Q`GunK5qDDC9cWF zF*${Gby^i7lY<5yDdw#YuE5Fx9-OagA`bZJN18-7O$YU~>CoNQSSn?imoWt5q%G5f zB?aYVMR*fga@#u=wW2XnH;08jJnoofVuBB*=J>|d-8x;}RCz8}(tEn2gs?{t%C#mA zP9^g~@OqfOeGa_O75Ot}R~Xjz)rKs4>ya6Ci(D+WX7`0iiJMc!WXsp!eJ~DuGScN! z9JT1maotMWudWbOyddggp+_T+$8ADv#izCSc}wPVT@>cVWC&)4n!nI+Yh)Gy$>4Gq zue6ita|{RZ0KplEX9Gi!PV|$HR2CV%kCN6e*0DhOc@^QLE3|K?Do*L_Mb50asAv^) z-@YbL2usAswP8K3oc&#Rf(sI_;QT@++Y(l2FY3JrM{)z?m9&SS8|u@_erH0{@#9EQ z<{;#k!o)C=IO9=Dc;Yn1xl%*hxpaW#KWarf>WNyiNx$okNi#|JLw1i=WKsWaZ z#60aZ=R0~iUkM0JSy?#nW5}NbCi2^uwb2!D{;IsD$YLUFnLRqfhEul;y0Il>pc zrGM8PQD`cw#ltoLKbh1{75%6%b%>F99^_r-jx~_lR>yiT``{dHtaR;fX0`2`w>o5R z2F9)mA%^a)7nE-tbRD*2rWHU&XrWOIU&6Y#9p(XU>zf%CC10~$D~d}Xr)FfBTh8Q5 zyitjiggIf&J7OtG2|v)dX2joj)jNl|3**qa4Q+3`5Sq&nXb@*<1-KJl8HG1&pnjKN zqTyEQG9C*$*10<#xG2Ha%rdd7eAwrq=!1A_4;%{Q2Pe&_Sma7F3J6^)OhfD36WDiM>c|04Do$G?D?yIMrg8=~n)2At>JztBtXuKjba_8u z-mC}ei8O9hOluA9?b0GXMwbh(u0@J!ZeJ?H(T0n(#uZ&O9_x5j5T<1;zYyP7Db-_Tt6ZE$aKp_|@Omeq9h+IFMX9cn z3{Xxh%c+CWFu$W|;@XA36t1StavWOBgK93cL#|N{Ub7Y)pyS~qGUfr$Tt)wvj114k z_#KuTKAXiOGn;gG#b?<+2QhIeRbV3M1of&_$KiE-+7xb`A43|}2-MXbOcgA6bj_(l z<1A{11oATJx%rcV16mM4`TiCY$kiP$fv3ztxdx4uoL?(^hNy)+&`&N&2_1>hYUA(U zE_-CuM|PSkcz4z1zB1mMYie%enLZe@%PWPYxP=K+aC+u)VWBn@9mR$?;nsu2uZ)Z0}2~7=qDGs zRxt8MY+?<}W`37^QVAZk&C4~bHsw#}m2?waXSl*x|jiJO~Q;{5erMELT@ zrHWl*iAZ@%cQrj`$a~<3l}uSO^p?h&OBp-P_ak*~ha6R!H>bkM!k50zD^b*3S?`yl z%&_WidZF!-@Yif&Holfb%*wHV%;%{X;=QZo)Gv%~^4y;-&~*1>yX9T!d2SqqojwasT&A+BdqKg#KogDyiW#d9VR-Ly}FO_B~Xn=T(LB(2W1FeW}_;KPNnnNIDi zYv1@#a7?y;XLj)rZHl&c{K$MjNmhiggpib{o&RW3iv9)iDx$W+F;_6`92$8--FDhY zEH7WoJ*zB_Uw_o1EAKL$T|Vhz-k&5HyR(T~#_w zH>3>2rI-&FpNrF)on38XTKhBOTtlZXK?17IG@sYb;Q>cxtRU8#(a5Wa9jLcAjVjh4 zGF=Q^GT?)!s8p4BicvNy^M|3P`QSw zAcZW3PzU$DQo5%nbic|gViT(88Uw;kZ_z#uXPZ2 zNkcb^N(_krCIohi1S`DagkE8Bacm9 z(`@ike=vH#1rx~MBBEy^r|h@&P>OfbE5?$DZXMAHAu%=V6wc2wJgBSy`H3>C@K)S{ z>KY-|9g4)9ZR`=r=P~_S{FcRUTKs)%`3}hKsu~&u4;_^>!Fpi7e{)~I7tm*?n#`!n zOU2Z!W(kvMyjZcgcz%ixJP?l>_w)H#rB5jYd-V^In{eii6JAkmWj>F&G>XXj!V3!; zW)-3!Mw)f;y1?2J17p4XSeaVN00w`NCP*|wn0c^<&Z4v^*9xj$<7oK)3KB0c=ZaA+ zBdM$YP}i2E>)B?WLynmblQ7?#bht4|^^N?KiHUP-uR~IWX4W^3kM!8IsI8p7Dvu0M zt%Y(zxXdij0}?u;jRm3+)Ez;qC^z}herJzPZdxBlK;zw2 z7&X5|`MzGHQ|D~&!ilnw+LNcx`d8NlG8M^KSf~nYyqcmFBCzk}1Rg`X{FOaI)d_{J zz@Um7(|W7S%Q07E3)VIviDQxsESh!husFNuxC+_a*F8l`#%_+o8b+ILOnY6P=@wU6 zO&ya2ntMXl6WLPzuGzm*xpqAZ#DGOtS2(?p+kV zYKmJ`uZquLTzmG}JzT6#KUqwBcuRsYSdRA?oJN?A2ILn$aKc==xAx%>N}yX?2x#-V903- z#YrusqQkOxJTQuUwaYH$GOSM@m^kDXZ{k5U3*x+*s&)G@fvu7W@s&B8Wm)tK&Riyj zTj$RDO73x7OK&|a6=uCJd`~L1^WF!uE?0l}oUiIAo}(Ii>E%asb(&^kRF))lA1pjR zIzlgQEA!lmwX2Qcr45D?*e=V95Io2|=bKt2s{EZOUTwDrK_Ot&jXdJ$<}kafFyy<- zwA26cHXKpI!C+)*?g%6T`Ki6nUwpU+s#pr1eZZnMO4iX-^; z!0Pi9QO>s#-K5!bb?&iC)pjVmZoN;}AkiKbZ)9~Sm_zriRDs&r$@nHkEn#aZ02OzJ zZ(ZMuyou&l`{PYm9d=UF|JYq~U{+?jpz9k%7Bi~uxVm*ytqHCt!kY zJZi^RKXoQD>ozI1rO{~4JhBs{icP%shN>6L5H)Q!zmwd+R8CFH`IM?$)1>IlEO?pf;bT8i^~-2sb3!%NndZ-U^8m zp~s-l4(iM{`swqFt6*clGFV5j3c3`z$q>c+0m}(MEmZc0F&$#CxLYd2+qEo?SLYA) zYs)tH4NJtLWPG)SN-dzt>)Ts!xv4x?s>oQKh(x(LE3=924Z8>WZcl$*p8_T^n;lk$ zf_Z#XC_A&iTNLyt9=l9E3ImLL*l9L6ovEv-wZUjxnrOvtseY{jV|P=d`v6-+>M{GE zsVM-sDMbEay-kELd`oB*-g&@Y!R7U6r#Y@GsuVrqwcV_NG$Q6KG(a;=s0R!2+)&qH z{mvWsTd^fRBJTC|;Vfo^-1-(CV5C5+Pz)mO$o?$sVEROH@a(`Tx;a^s2ywWys2fKI zu}~~-#qq7#L$@(}jTlvwUBwSyjTa7&*}MFawWj<;Yy8R5KvTErRZD`u>*{{GL0pG$ z6xksitaiZ;}V}JGg65Y#wBdacOA}AwJ#m91w(O(rZX6 zYy?qYr=s}+;9Qsk;Flv)isF@Ny7gX`9ET5^Ta_VnJzsl*`mQl)I83mR(E-kh97?Is zHD^F|WeCSxm~A&!vU0&q2}(@P(sIP!J(}uQB_PScYZVZ+9NltxuVFLO%|j2Ju&w~S zqxSGQeg}Snv97TIt-NVQ)MaGMi-3YUYCdIJ;hv08nJ5y!CAMl2pXZ4*RF$mceeATq zvWGh-u8K7#x1an$0OHkwjxq_AByA;l1t~JN*p9vKhM{|UUvR*0&rr$f2VK^oQpeg& zI4@|}cYnjXj-}5ar5(BK8q+%r>^btSjT{E~mhdjDK zfJwjYmKF08@tE<+qdSKdPNU~rL~l$cL>Se|)y%puC)9M>UdJ<06zhNENO+j`N8kU` z@#4FH7#iV@Ra2wIC$?;SakmE(*8^vC&qYm!15v-50`PrL=K?3TOXe^3?~0eBr$-)R zUa6~nQ6KUtH?a~?R=(bU02-Lzw~7}!LP-0o{Hs$OhJB&RDgt#w7URn*>?h%r;2>N)s+loLR2WZB}R@Uq)*GgX3{alXz_q@I}Omn~-m(zyz zEucR^o-d_GGCcvKK8)XT0dSHhq^;T7!go`j*8)_E8&P9gmkx@CJ32VdpqJ+_|Gl!+ z624`M^}RbD)Tv+p50UqakG=~-(f%y?uXW=%pZz`9eMtiE6YqcR>tFSiGJmfnkofbo z+~J3RHT%=SAmt;mzp(nRqHmvGv@!qPFQ@I_bGYxy0{l0u?@fnA_o(+h6S6dG}Dh_^(*Nuuqugb4uYv zbcR->U$--<2m8DAF8~gtkJbv$5 literal 140299 zcma&Obx>Sg^Dc_JJHg%E-Q6X)ySoi8!QEYhCb+v3AXspBOK=98K~H|S&OLRj-t&F$ z{bNtn-m_|Tucv!=KWlZ*#Hy>xp(1@kf`EWPRgjm~gn)qEf`EWlM1cGIBxNJv? zxLx{V_&2xu2CW|BADyWUAp)nqqSy00;^Vq`hdBy_W;;M*p5oNx;+!lY9SX!b3A%gn zs>%S>ZX;GG)*q2_D&za)i%Zhn$b5d4zLr-9dURYl^V<0r|xNg z4WG+A^98+bHT91<9#>w2TmRcXz!Tc!gP!DN6W{SAjF5WAE`?Dxs{RuYA#DimZQl09uk`M1(>zaHgw$lL=S)<->HFfcR75x@f;MmO7Jk!Rr)-)#IJhAE50Q(h-@`*t(-uYLWD>=8_#UE2K0T>~_H_l+@^z|xjOpf0p znUYM=JTX>STC|TuW2O5_eu!VKR{iK?5!gGV@G#^w=1Rhmd{484Yl-Pfy>pSO`%*xZ zmp|nxb|l)ySUR^<|1Hb9k3>3Hy6lCwG`8l|7aO+yPBS~){~Ni%~W zw#ppM`Vw#)I^sT>gIc?q%Oh()w~u-Cg*e{}JMv=ul$;?=$|@%zRC$NmHFbnKegV_N zZLys=e$HZh{r*mr`xnz8NyKzlk*q1sb#1`HuZEEOCp^cO)B<~^Vn6u75wzAE=-cN8 z*0)9`V3QS@k&k;ld=0ZVeH1|2ARUkf zNEKuPBDC61bJGVYbW#RV86^}hAD&v?T}1dBE_15%m}s$AB(xg(EBsVslW&k8n`0;_Jh3WgZDF91)O}S$X%0=4?#VeNNZ4jmVa=VY-Sm6y3jKjpQP1y{VDizL95oQ z!23{K%a?@R0U`^~ZC_^AFZ`A=4RW>Jz&t{Mjilq*vD2_-ENmByzf4>QvqBL`T@(N)64ri?Y-v<8g zDKNfs{@axQGm^D96zM8^PTleU$6(B>4fy}Na{phoN5ey9JIBMzR!aZFO8(DaK4D1Q zEmsh03BBd-a|Bh}C<+BfgNZ|HS=p?J|`eW zb0I}9wZ-QiANHMQ6OAf1YRu{XSGQH%OG7lu4;RYtMt<)q0J*Qx%$kpVRUy`iU%RnH zbiDbs5)B1ONPu;_k00_19RZ?X)0(7-Sj5fzR|KW8I(=Y#<9?4wq>3=?Q2en+)a2wd zJ~^$F*HA69s`#O#7;a3=yD48T^ybkUA+>ONnZ2Squ+;iE0j@Nuss58O>r&hq^tP}| zhPA_UBhqa8+wO1w6L`R#TU?m%+Xv}lZE*Zvb&4-%h+i=KIT;>i;IB9GQ~L4X8CJibgQpF#ZSvZ zH@jDEinD|NVh%GMZ1Kh#*rD3j@ehNHL2o$-_vNM7^?~RS-XmReI+k1Xx0I&CmX<+R z5r$b>nXZ~K1^axvSI-}!>wY)(ZvJTgXrp_u2SqAS!SUT#$uPx*uQTmlRRq_qS z{{k1g2o#w@!j9}L$W$QUeGc4UP-Wgn{ia0F@rM?|~!v(RO0~WFEo)#Sh|iZk5_|0TfYc*78*_3%@)JF36b z3UP0gFe!&ZZUmEg9TB!?=Y9_FLB;$Ho2vl)<$QNsBcw>ZW1Wef#52VvTAN~NYml<% z9kToY>1V-i$#fa`Ej|^p{)6qx*tM3s2_Ydqb|Sq1g6gH$AIAugc&$ zwYm(pU46>LZ4>aUW7W~Fi5SJ_He*hh%^arBaPZKYn`=}7YJ5sUqmONN<3ZiNgY?#IR9h1lDAo_T-